mirror of
https://github.com/mayswind/ezbookkeeping.git
synced 2026-05-22 02:34:26 +08:00
Compare commits
93 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f7411a002 | |||
| a739498ef7 | |||
| e6bb128cda | |||
| b9855faf38 | |||
| ea9fce9bae | |||
| 7557c1014d | |||
| aee8757ad5 | |||
| d0f76fea22 | |||
| e6c6d02112 | |||
| 563bef69cf | |||
| 11f2c9fff7 | |||
| 9e0275a11a | |||
| 231d3210cb | |||
| 75d801f775 | |||
| de132dd7fd | |||
| 39ee47e05a | |||
| 0038321781 | |||
| 7c67d30222 | |||
| da2f1ef410 | |||
| 416e7cece1 | |||
| 1d5102a015 | |||
| 1e38d1b18b | |||
| bab7a0041b | |||
| e38ba2ea0a | |||
| 9d25914411 | |||
| bb2068f4db | |||
| e4e74304b6 | |||
| de885c963d | |||
| 1428ce921c | |||
| 60477f7f27 | |||
| 9dcbf1aa7e | |||
| 1d5a6562f3 | |||
| 0c427e9857 | |||
| 0b984b3c20 | |||
| b87a39464e | |||
| 0d2b3196e6 | |||
| e172e040f9 | |||
| 629dbeeaa4 | |||
| 53e515fb92 | |||
| 9c87436a36 | |||
| f56b5c471d | |||
| cd8f746745 | |||
| d39494a78e | |||
| e69df56874 | |||
| 808cb98002 | |||
| 892404924c | |||
| 374132fe7c | |||
| 5d8e709070 | |||
| 124962a4f4 | |||
| b9b210c591 | |||
| 6a4ab4c145 | |||
| e89aa10137 | |||
| d73704af66 | |||
| bf7fe0c583 | |||
| a6e252c30d | |||
| 1e4bb73874 | |||
| 8f01469a41 | |||
| 7a821abbb6 | |||
| 02d8b132f5 | |||
| c64c60c6a0 | |||
| 50472d437a | |||
| 53702e68d8 | |||
| 36d82254d6 | |||
| 36529abf08 | |||
| c0641b1db5 | |||
| c2d7bcc5f1 | |||
| 4af0797051 | |||
| 63ec0e4424 | |||
| c828db4988 | |||
| d7151bc7ab | |||
| 0222f61da6 | |||
| ff1158be00 | |||
| f214b7db88 | |||
| d605a8f4ec | |||
| 5d333a4e74 | |||
| fb35756601 | |||
| 721384b9cc | |||
| f2b633cc7b | |||
| 448fc760c0 | |||
| a604737c7c | |||
| d44798bf0f | |||
| fcedb3147d | |||
| cd59c4e6a5 | |||
| fe0187ac2c | |||
| b4c31fc9d0 | |||
| ae7ee274d5 | |||
| ee45d89730 | |||
| 5359e3c1fb | |||
| 97fb73ad43 | |||
| ce0c9ec65e | |||
| ed084e1ce0 | |||
| ec84065f73 | |||
| 2e8aedcfa6 |
@@ -10,11 +10,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_REPO }}/mayswind/ezbookkeeping
|
||||
@@ -24,10 +24,10 @@ jobs:
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Set up the environment
|
||||
id: setup
|
||||
|
||||
@@ -10,11 +10,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_REPO }}/mayswind/ezbookkeeping
|
||||
@@ -24,10 +24,10 @@ jobs:
|
||||
type=sha,format=short,prefix=SNAPSHOT-
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Set up the environment
|
||||
id: setup
|
||||
|
||||
@@ -3,81 +3,78 @@ name: Build docker image and package for linux
|
||||
inputs:
|
||||
release-build:
|
||||
required: false
|
||||
type: string
|
||||
description: "Whether to build the linux package in release mode. If set to '1', the package will be built in release mode. Otherwise, it will be built in development mode."
|
||||
build-unix-time:
|
||||
required: false
|
||||
type: string
|
||||
description: "The unix time to use for building the linux package. The value should be a string representing the unix time in seconds."
|
||||
build-date:
|
||||
required: false
|
||||
type: string
|
||||
description: "The date to use for building the linux package. The value should be a string representing the date in the format of 'YYYYMMDD'."
|
||||
check-3rd-api:
|
||||
required: false
|
||||
type: string
|
||||
description: "Whether to run integration tests that call third party APIs. If set to '1', the tests will be run. Otherwise, the tests will be skipped."
|
||||
skip-tests:
|
||||
required: false
|
||||
type: string
|
||||
description: "Whether to skip tests when building the linux package. If set to '1', the tests will be skipped. Otherwise, the tests will be run."
|
||||
platform:
|
||||
required: true
|
||||
type: string
|
||||
description: "The platform to build the linux package for. The value should be in the format of 'os/arch[/variant]'. For example, 'linux/amd64', 'linux/arm64/v8', 'linux/arm/v7', or 'linux/arm/v6'."
|
||||
platform-name:
|
||||
required: true
|
||||
type: string
|
||||
description: "The name of the platform to build the linux package for. The value should be a string that can be used in file names. For example, 'linux-amd64', 'linux-arm64', 'linux-armv7', or 'linux-armv6'."
|
||||
docker-push:
|
||||
required: true
|
||||
type: boolean
|
||||
description: "Whether to push the built docker image to the registry. If set to 'true', the image will be pushed. Otherwise, it will not be pushed."
|
||||
docker-image-name:
|
||||
required: true
|
||||
type: string
|
||||
required: false
|
||||
description: "The repository name of the docker image to build. This is required if 'docker-push' is set to 'true'."
|
||||
docker-username:
|
||||
required: false
|
||||
type: string
|
||||
description: "Username for logging in to the docker registry. This is required if 'docker-push' is set to 'true'."
|
||||
docker-password:
|
||||
required: false
|
||||
type: string
|
||||
description: "Password for logging in to the docker registry. This is required if 'docker-push' is set to 'true'."
|
||||
docker-bake-meta-file-path:
|
||||
required: true
|
||||
type: string
|
||||
description: "The file path to the docker bake meta file."
|
||||
docker-bake-meta-artifact-name:
|
||||
required: true
|
||||
type: string
|
||||
description: "The name of the artifact that contains the docker bake meta file."
|
||||
docker-bake-digests-file-path:
|
||||
required: true
|
||||
type: string
|
||||
description: "The file path to save the docker bake digests file. The file will be created with the name of the digest under this path."
|
||||
docker-bake-digests-artifact-name-prefix:
|
||||
required: true
|
||||
type: string
|
||||
description: "The prefix for the docker bake digests artifact name."
|
||||
package-file-name-prefix:
|
||||
required: true
|
||||
type: string
|
||||
package-artifact-name-prefix:
|
||||
required: true
|
||||
type: string
|
||||
description: "The prefix for the linux package file name."
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Download docker bake meta artifact
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: ${{ inputs.docker-bake-meta-artifact-name }}
|
||||
path: ${{ runner.temp }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: ${{ inputs.docker-username != '' && inputs.docker-password != '' }}
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ inputs.docker-username }}
|
||||
password: ${{ inputs.docker-password }}
|
||||
|
||||
- name: Build docker for ${{ inputs.platform-name }}
|
||||
id: bake
|
||||
uses: docker/bake-action@v6
|
||||
uses: docker/bake-action@v7
|
||||
with:
|
||||
files: |
|
||||
./docker-bake.hcl
|
||||
@@ -110,15 +107,15 @@ runs:
|
||||
tar -czf ${{ runner.temp }}/${{ inputs.package-file-name-prefix }}-${{ inputs.platform-name }}.tar.gz *
|
||||
|
||||
- name: Upload ${{ inputs.platform-name }} digests artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ inputs.docker-bake-digests-artifact-name-prefix }}-${{ inputs.platform-name }}
|
||||
path: ${{ inputs.docker-bake-digests-file-path }}/*
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload artifact for ${{ inputs.platform-name }}
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ inputs.package-artifact-name-prefix }}-${{ inputs.platform-name }}
|
||||
archive: false
|
||||
path: ${{ runner.temp }}/${{ inputs.package-file-name-prefix }}-${{ inputs.platform-name }}.tar.gz
|
||||
if-no-files-found: error
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
name: Build backend file for macOS
|
||||
|
||||
inputs:
|
||||
go-version:
|
||||
required: false
|
||||
description: "The Go version to use for building the macOS backend. The version should be in the format of 'x.y.z'."
|
||||
default: "1.26.2"
|
||||
release-build:
|
||||
required: false
|
||||
description: "Whether to build the macOS backend in release mode. If set to '1', the backend will be built in release mode. Otherwise, it will be built in development mode."
|
||||
build-unix-time:
|
||||
required: false
|
||||
description: "The unix time to use for building the macOS backend. The value should be a string representing the unix time in seconds."
|
||||
build-date:
|
||||
required: false
|
||||
description: "The date to use for building the macOS backend. The value should be a string representing the date in the format of 'YYYYMMDD'."
|
||||
check-3rd-api:
|
||||
required: false
|
||||
description: "Whether to run integration tests that call third party APIs. If set to '1', the tests will be run. Otherwise, the tests will be skipped."
|
||||
skip-tests:
|
||||
required: false
|
||||
description: "Whether to skip tests when building the macOS backend. If set to '1', the tests will be skipped. Otherwise, the tests will be run."
|
||||
architecture:
|
||||
required: true
|
||||
description: "The name of the architecture to build the macOS package for."
|
||||
backend-artifact-name-prefix:
|
||||
required: true
|
||||
description: "The prefix for the macOS backend artifact name."
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: ${{ inputs.go-version }}
|
||||
|
||||
- name: Build backend for macOS-${{ inputs.architecture }}
|
||||
shell: bash
|
||||
env:
|
||||
RELEASE_BUILD: "${{ inputs.release-build }}"
|
||||
BUILD_PIPELINE: "1"
|
||||
BUILD_UNIXTIME: "${{ inputs.build-unix-time }}"
|
||||
BUILD_DATE: "${{ inputs.build-date }}"
|
||||
CHECK_3RD_API: "${{ inputs.check-3rd-api }}"
|
||||
SKIP_TESTS: "${{ inputs.skip-tests }}"
|
||||
run: |
|
||||
./build.sh backend
|
||||
|
||||
- name: Upload macOS-${{ inputs.architecture }} backend artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ inputs.backend-artifact-name-prefix }}-macos-${{ inputs.architecture }}
|
||||
path: ezbookkeeping
|
||||
if-no-files-found: error
|
||||
@@ -0,0 +1,58 @@
|
||||
name: Build packages for macOS
|
||||
|
||||
inputs:
|
||||
architecture:
|
||||
required: true
|
||||
description: "The name of the architecture to build the macOS package for."
|
||||
package-file-name-prefix:
|
||||
required: true
|
||||
description: "The prefix for the macOS package file name."
|
||||
backend-artifact-name-prefix:
|
||||
required: true
|
||||
description: "The prefix for the macOS backend artifact name."
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Download macOS-${{ inputs.architecture }} backend file
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: ${{ inputs.backend-artifact-name-prefix }}-macos-${{ inputs.architecture }}
|
||||
path: ${{ runner.temp }}/backend
|
||||
|
||||
- name: Download linux-amd64 packaged files
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: ${{ inputs.package-file-name-prefix }}-linux-amd64.tar.gz
|
||||
path: ${{ runner.temp }}/package
|
||||
|
||||
- name: Extract frontend files from linux-amd64 package
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p package
|
||||
tar -xzf ${{ runner.temp }}/package/${{ inputs.package-file-name-prefix }}-linux-amd64.tar.gz -C package
|
||||
|
||||
- name: Package macOS-${{ inputs.architecture }} package
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p ezbookkeeping
|
||||
mkdir -p ezbookkeeping/data
|
||||
mkdir -p ezbookkeeping/storage
|
||||
mkdir -p ezbookkeeping/log
|
||||
cp ${{ runner.temp }}/backend/ezbookkeeping ezbookkeeping/
|
||||
cp -R package/public ezbookkeeping/public
|
||||
cp -R conf ezbookkeeping/conf
|
||||
cp -R templates ezbookkeeping/templates
|
||||
cp LICENSE ezbookkeeping/
|
||||
cd ezbookkeeping
|
||||
chmod +x ezbookkeeping
|
||||
tar -czf ${{ runner.temp }}/${{ inputs.package-file-name-prefix }}-macos-${{ inputs.architecture }}.tar.gz *
|
||||
cd ..
|
||||
rm -rf ezbookkeeping
|
||||
|
||||
- name: Upload macOS-${{ inputs.architecture }} artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
archive: false
|
||||
path: ${{ runner.temp }}/${{ inputs.package-file-name-prefix }}-macos-${{ inputs.architecture }}.tar.gz
|
||||
if-no-files-found: error
|
||||
@@ -3,31 +3,34 @@ name: Build backend file for windows
|
||||
inputs:
|
||||
go-version:
|
||||
required: false
|
||||
default: "1.25.7"
|
||||
description: "The Go version to use for building the windows backend. The version should be in the format of 'x.y.z'."
|
||||
default: "1.26.2"
|
||||
mingw-version:
|
||||
required: false
|
||||
description: "The MinGW version to use for building the windows backend. The version should be in the format of 'x.y.z'."
|
||||
default: "15.2.0"
|
||||
mingw-revison:
|
||||
required: false
|
||||
description: "The MinGW revision to use for building the windows backend. The revision should be in the format of 'vX-revY'."
|
||||
default: "v13-rev1"
|
||||
release-build:
|
||||
required: false
|
||||
type: string
|
||||
description: "Whether to build the windows backend in release mode. If set to '1', the backend will be built in release mode. Otherwise, it will be built in development mode."
|
||||
build-unix-time:
|
||||
required: false
|
||||
type: string
|
||||
description: "The unix time to use for building the windows backend. The value should be a string representing the unix time in seconds."
|
||||
build-date:
|
||||
required: false
|
||||
type: string
|
||||
description: "The date to use for building the windows backend. The value should be a string representing the date in the format of 'YYYYMMDD'."
|
||||
check-3rd-api:
|
||||
required: false
|
||||
type: string
|
||||
description: "Whether to run integration tests that call third party APIs. If set to '1', the tests will be run. Otherwise, the tests will be skipped."
|
||||
skip-tests:
|
||||
required: false
|
||||
type: string
|
||||
description: "Whether to skip tests when building the windows backend. If set to '1', the tests will be skipped. Otherwise, the tests will be run."
|
||||
backend-artifact-name-prefix:
|
||||
required: true
|
||||
type: string
|
||||
description: "The prefix for the windows backend artifact name."
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
@@ -69,7 +72,7 @@ runs:
|
||||
.\build.ps1 backend
|
||||
|
||||
- name: Upload windows backend artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ inputs.backend-artifact-name-prefix }}-windows-x64
|
||||
path: ezbookkeeping.exe
|
||||
|
||||
@@ -3,27 +3,24 @@ name: Build packages for windows
|
||||
inputs:
|
||||
package-file-name-prefix:
|
||||
required: true
|
||||
type: string
|
||||
package-artifact-name-prefix:
|
||||
required: true
|
||||
type: string
|
||||
description: "The prefix for the windows package file name."
|
||||
backend-artifact-name-prefix:
|
||||
required: true
|
||||
type: string
|
||||
description: "The prefix for the windows backend artifact name."
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Download windows-x64 backend file
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: ${{ inputs.backend-artifact-name-prefix }}-windows-x64
|
||||
path: ${{ runner.temp }}\backend
|
||||
|
||||
- name: Download linux-amd64 packaged files
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: ${{ inputs.package-artifact-name-prefix }}-linux-amd64
|
||||
name: ${{ inputs.package-file-name-prefix }}-linux-amd64.tar.gz
|
||||
path: ${{ runner.temp }}\package
|
||||
|
||||
- name: Extract frontend files from linux-amd64 package
|
||||
@@ -50,8 +47,8 @@ runs:
|
||||
Remove-Item -Recurse -Force ezbookkeeping
|
||||
|
||||
- name: Upload windows artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ inputs.package-artifact-name-prefix }}-windows-x64
|
||||
archive: false
|
||||
path: ${{ inputs.package-file-name-prefix }}-windows-x64.zip
|
||||
if-no-files-found: error
|
||||
|
||||
@@ -3,50 +3,50 @@ name: Push linux docker multi-arch image to registry
|
||||
inputs:
|
||||
docker-image-name:
|
||||
required: true
|
||||
type: string
|
||||
description: "The repository name of the docker image to build."
|
||||
docker-username:
|
||||
required: true
|
||||
type: string
|
||||
description: "Username for logging in to the docker registry."
|
||||
docker-password:
|
||||
required: true
|
||||
type: string
|
||||
description: "Password for logging in to the docker registry."
|
||||
docker-bake-meta-file-path:
|
||||
required: true
|
||||
type: string
|
||||
description: "The file path to the docker bake meta file."
|
||||
docker-bake-meta-artifact-name:
|
||||
required: true
|
||||
type: string
|
||||
description: "The name of the artifact that contains the docker bake meta file."
|
||||
docker-bake-digests-file-path:
|
||||
required: true
|
||||
type: string
|
||||
description: "The file path to save the docker bake digests file. The file will be created with the name of the digest under this path."
|
||||
docker-bake-digests-artifact-name-prefix:
|
||||
required: true
|
||||
type: string
|
||||
description: "The prefix for the docker bake digests artifact name."
|
||||
docker-image-tags:
|
||||
required: true
|
||||
type: string
|
||||
description: "The tags for the docker image to push."
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Download docker bake meta artifact
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: ${{ inputs.docker-bake-meta-artifact-name }}
|
||||
path: ${{ runner.temp }}
|
||||
|
||||
- name: Download digests artifact
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: ${{ inputs.docker-bake-digests-artifact-name-prefix }}-*
|
||||
merge-multiple: true
|
||||
path: ${{ inputs.docker-bake-digests-file-path }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ inputs.docker-username }}
|
||||
password: ${{ inputs.docker-password }}
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const FRONTEND_LOCALES_DIR = path.join(__dirname, '..', '..', 'src', 'locales');
|
||||
const BACKEND_LOCALES_DIR = path.join(__dirname, '..', '..', 'pkg', 'locales');
|
||||
const OUTPUT_DIR = process.argv[2] || path.join(__dirname, '..', '..', 'i18n-badge');
|
||||
|
||||
const DEFAULT_LANGUAGE_TAG = 'en';
|
||||
|
||||
const BACKEND_SKIP_STRUCTS = new Set([
|
||||
'GlobalTextItems',
|
||||
'DefaultTypes',
|
||||
'DataConverterTextItems',
|
||||
]);
|
||||
|
||||
function discoverFrontendLanguages() {
|
||||
const indexPath = path.join(FRONTEND_LOCALES_DIR, 'index.ts');
|
||||
const content = fs.readFileSync(indexPath, 'utf-8');
|
||||
|
||||
const importMap = {};
|
||||
const importRegex = /import\s+(\w+)\s+from\s+['"]\.\/([\w_]+\.json)['"]/g;
|
||||
let match;
|
||||
|
||||
while ((match = importRegex.exec(content)) !== null) {
|
||||
importMap[match[1]] = match[2];
|
||||
}
|
||||
|
||||
const result = {};
|
||||
const langRegex = /['"]([^'"]+)['"]\s*:\s*\{[^}]*content\s*:\s*(\w+)/g;
|
||||
|
||||
while ((match = langRegex.exec(content)) !== null) {
|
||||
const tag = match[1];
|
||||
const varName = match[2];
|
||||
|
||||
if (importMap[varName]) {
|
||||
result[tag] = importMap[varName];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function discoverBackendLanguages() {
|
||||
const allLocalesPath = path.join(BACKEND_LOCALES_DIR, 'all_locales.go');
|
||||
const content = fs.readFileSync(allLocalesPath, 'utf-8');
|
||||
|
||||
const result = {};
|
||||
const entryRegex = /"([^"]+)"\s*:\s*\{[^}]*Content\s*:\s*(\w+)/g;
|
||||
let match;
|
||||
|
||||
while ((match = entryRegex.exec(content)) !== null) {
|
||||
const tag = match[1];
|
||||
const fileName = tag.toLowerCase().replace(/-/g, '_') + '.go';
|
||||
const filePath = path.join(BACKEND_LOCALES_DIR, fileName);
|
||||
|
||||
if (fs.existsSync(filePath)) {
|
||||
result[tag] = fileName;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function flattenJSON(obj, prefix) {
|
||||
const result = {};
|
||||
|
||||
for (const key of Object.keys(obj)) {
|
||||
const fullKey = prefix ? prefix + '.' + key : key;
|
||||
|
||||
if (typeof obj[key] === 'object' && obj[key] !== null) {
|
||||
Object.assign(result, flattenJSON(obj[key], fullKey));
|
||||
} else {
|
||||
result[fullKey] = obj[key];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function shouldSkipFrontendKey(key) {
|
||||
if (key.startsWith('global.')) {
|
||||
return true;
|
||||
} else if (key.startsWith('default.')) {
|
||||
return true;
|
||||
} else if (key.startsWith('currency.')) {
|
||||
if (key.startsWith('currency.unit.')) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
} else if (key.startsWith('mapprovider.')) {
|
||||
return true;
|
||||
} else if (key.startsWith('encoding.')) {
|
||||
return true;
|
||||
} else if (key.startsWith('document.')) {
|
||||
if (key.startsWith('document.anchor.')) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isFrontendAlwaysTranslatedKey(key) {
|
||||
if (key.startsWith('language.')) {
|
||||
return true;
|
||||
} else if (key.startsWith('format.')) {
|
||||
if (key.startsWith('format.misc.')) {
|
||||
if (key === 'format.misc.multiTextJoinSeparator') {
|
||||
return true;
|
||||
} else if (key === 'format.misc.eachMonthDayInMonthDays') {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
} else if (key.startsWith('datetime.')) {
|
||||
return true;
|
||||
} else if (key.startsWith('timezone.')) {
|
||||
return true;
|
||||
} else if (key.startsWith('currency.')) {
|
||||
if (key === 'currency.name.EUR') {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
} else if (key.startsWith('parameter.')) {
|
||||
if (key === 'parameter.id') {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
if (key === 'OK') {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractGoStringFields(content) {
|
||||
const fields = [];
|
||||
const structBlockRegex = /(\w+):\s*&\w+\{([^}]*)\}/gs;
|
||||
let blockMatch;
|
||||
|
||||
while ((blockMatch = structBlockRegex.exec(content)) !== null) {
|
||||
const structName = blockMatch[1];
|
||||
const blockBody = blockMatch[2];
|
||||
const fieldRegex = /(\w+):\s+"((?:[^"\\]|\\.)*)"/g;
|
||||
let fieldMatch;
|
||||
|
||||
while ((fieldMatch = fieldRegex.exec(blockBody)) !== null) {
|
||||
fields.push({
|
||||
struct: structName,
|
||||
name: fieldMatch[1],
|
||||
value: fieldMatch[2],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
function getProgressColor(progress) {
|
||||
if (progress >= 95) {
|
||||
return 'brightgreen';
|
||||
} else if (progress >= 90) {
|
||||
return 'green';
|
||||
} else if (progress >= 70) {
|
||||
return 'yellowgreen';
|
||||
} else if (progress >= 50) {
|
||||
return 'yellow';
|
||||
} else if (progress >= 20) {
|
||||
return 'orange';
|
||||
} else {
|
||||
return 'red';
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const frontendLangs = discoverFrontendLanguages();
|
||||
const backendLangs = discoverBackendLanguages();
|
||||
const allTags = new Set([...Object.keys(frontendLangs), ...Object.keys(backendLangs)]);
|
||||
|
||||
console.log('Discovered ' + allTags.size + ' languages: ' + [...allTags].sort().join(', '));
|
||||
|
||||
const defaultFrontendJSON = JSON.parse(fs.readFileSync(path.join(FRONTEND_LOCALES_DIR, `${DEFAULT_LANGUAGE_TAG}.json`), 'utf-8'));
|
||||
const defaultFrontendItemsMap = flattenJSON(defaultFrontendJSON, '');
|
||||
const defaultFrontendKeys = Object.keys(defaultFrontendItemsMap);
|
||||
const frontendTranslatableKeys = defaultFrontendKeys.filter(function (k) {
|
||||
return !shouldSkipFrontendKey(k);
|
||||
});
|
||||
const frontendSkippedCount = defaultFrontendKeys.length - frontendTranslatableKeys.length;
|
||||
const frontendTotal = frontendTranslatableKeys.length;
|
||||
|
||||
const defaultBackendContent = fs.readFileSync(path.join(BACKEND_LOCALES_DIR, `${DEFAULT_LANGUAGE_TAG}.go`), 'utf-8');
|
||||
const defaultBackendItems = extractGoStringFields(defaultBackendContent);
|
||||
const defaultBackendTranslatableItems = defaultBackendItems.filter(function (f) {
|
||||
return !BACKEND_SKIP_STRUCTS.has(f.struct);
|
||||
});
|
||||
const backendSkippedCount = defaultBackendItems.length - defaultBackendTranslatableItems.length;
|
||||
const backendTotal = defaultBackendTranslatableItems.length;
|
||||
|
||||
console.log('Frontend: ' + frontendTotal + ' translatable keys (' + frontendSkippedCount + ' excluded)');
|
||||
console.log('Backend: ' + backendTotal + ' translatable fields (' + backendSkippedCount + ' excluded)');
|
||||
|
||||
const results = {};
|
||||
const untranslatedKeys = {};
|
||||
|
||||
for (const tag of allTags) {
|
||||
results[tag] = {
|
||||
languageTag: tag,
|
||||
frontendTranslated: 0,
|
||||
frontendTotal: frontendTotal,
|
||||
backendTranslated: 0,
|
||||
backendTotal: backendTotal
|
||||
};
|
||||
untranslatedKeys[tag] = [];
|
||||
}
|
||||
|
||||
for (const tag of Object.keys(frontendLangs)) {
|
||||
if (tag === DEFAULT_LANGUAGE_TAG) {
|
||||
results[tag].frontendTranslated = frontendTotal;
|
||||
continue;
|
||||
}
|
||||
|
||||
const file = frontendLangs[tag];
|
||||
const filePath = path.join(FRONTEND_LOCALES_DIR, file);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const json = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
const kv = flattenJSON(json, '');
|
||||
let translated = 0;
|
||||
|
||||
for (const key of frontendTranslatableKeys) {
|
||||
if (kv[key] !== undefined && kv[key] !== '' && (kv[key] !== defaultFrontendItemsMap[key] || isFrontendAlwaysTranslatedKey(key))) {
|
||||
translated++;
|
||||
} else {
|
||||
untranslatedKeys[tag].push({ source: path.join('src', 'locales', file), key: key, defaultValue: defaultFrontendItemsMap[key], value: kv[key] });
|
||||
}
|
||||
}
|
||||
|
||||
results[tag].frontendTranslated = translated;
|
||||
}
|
||||
|
||||
for (const tag of Object.keys(backendLangs)) {
|
||||
if (tag === DEFAULT_LANGUAGE_TAG) {
|
||||
results[tag].backendTranslated = backendTotal;
|
||||
continue;
|
||||
}
|
||||
|
||||
const file = backendLangs[tag];
|
||||
const filePath = path.join(BACKEND_LOCALES_DIR, file);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const fields = extractGoStringFields(content).filter(function (f) {
|
||||
return !BACKEND_SKIP_STRUCTS.has(f.struct);
|
||||
});
|
||||
let translated = 0;
|
||||
|
||||
for (let i = 0; i < defaultBackendTranslatableItems.length; i++) {
|
||||
if (i < fields.length && fields[i].value !== defaultBackendTranslatableItems[i].value) {
|
||||
translated++;
|
||||
} else {
|
||||
untranslatedKeys[tag].push({ source: path.join('pkg', 'locales', file), key: defaultBackendTranslatableItems[i].struct + '.' + defaultBackendTranslatableItems[i].name, defaultValue: defaultBackendTranslatableItems[i].value, value: (i < fields.length) ? fields[i].value : null });
|
||||
}
|
||||
}
|
||||
|
||||
results[tag].backendTranslated = translated;
|
||||
}
|
||||
|
||||
for (const tag of Object.keys(results)) {
|
||||
const r = results[tag];
|
||||
const totalTranslated = r.frontendTranslated + r.backendTranslated;
|
||||
const totalItems = r.frontendTotal + r.backendTotal;
|
||||
r.totalProgress = Math.round((totalTranslated / totalItems) * 10000) / 100;
|
||||
}
|
||||
|
||||
const sortedResults = {};
|
||||
var sortedTags = Object.keys(results).sort();
|
||||
|
||||
for (const tag of sortedTags) {
|
||||
sortedResults[tag] = results[tag];
|
||||
}
|
||||
|
||||
if (!fs.existsSync(OUTPUT_DIR)) {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
var badgesDir = path.join(OUTPUT_DIR, 'badges');
|
||||
|
||||
if (!fs.existsSync(badgesDir)) {
|
||||
fs.mkdirSync(badgesDir, { recursive: true });
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(OUTPUT_DIR, 'i18n-progress.json'),
|
||||
JSON.stringify(sortedResults, null, 4) + '\n'
|
||||
);
|
||||
|
||||
for (const tag of sortedTags) {
|
||||
const data = sortedResults[tag];
|
||||
const badge = {
|
||||
schemaVersion: 1,
|
||||
label: 'translation',
|
||||
message: data.totalProgress + '%',
|
||||
color: getProgressColor(data.totalProgress)
|
||||
};
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(badgesDir, tag + '.json'),
|
||||
JSON.stringify(badge, null, 4) + '\n'
|
||||
);
|
||||
}
|
||||
|
||||
var untranslatedDir = path.join(OUTPUT_DIR, 'untranslated');
|
||||
|
||||
if (!fs.existsSync(untranslatedDir)) {
|
||||
fs.mkdirSync(untranslatedDir, { recursive: true });
|
||||
}
|
||||
|
||||
for (const tag of sortedTags) {
|
||||
const items = untranslatedKeys[tag] || [];
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(untranslatedDir, tag + '.json'),
|
||||
JSON.stringify(items, null, 4) + '\n'
|
||||
);
|
||||
}
|
||||
|
||||
for (const tag of sortedTags) {
|
||||
const data = sortedResults[tag];
|
||||
const missingCount = (untranslatedKeys[tag] || []).length;
|
||||
console.log(tag + ': ' + data.totalProgress + '% (frontend: ' + data.frontendTranslated + '/' + data.frontendTotal + ', backend: ' + data.backendTranslated + '/' + data.backendTotal + ', untranslated: ' + missingCount + ')');
|
||||
}
|
||||
|
||||
console.log('\nResults written to ' + OUTPUT_DIR);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -18,15 +18,14 @@ jobs:
|
||||
ezbookkeeping-docker-digests-file-path: ${{ steps.variable.outputs.ezbookkeeping_docker_digests_file_path }}
|
||||
ezbookkeeping-docker-digests-artifact-name-prefix: ${{ steps.variable.outputs.ezbookkeeping_docker_digests_artifact_name_prefix }}
|
||||
ezbookkeeping-package-file-name-prefix: ${{ steps.variable.outputs.ezbookkeeping_package_file_name_prefix }}
|
||||
ezbookkeeping-package-artifact-name-prefix: ${{ steps.variable.outputs.ezbookkeeping_package_artifact_name_prefix }}
|
||||
ezbookkeeping-backend-artifact-name-prefix: ${{ steps.variable.outputs.ezbookkeeping_backend_artifact_name_prefix }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
${{ vars.DOCKER_IMAGE_NAME }}
|
||||
@@ -43,7 +42,6 @@ jobs:
|
||||
echo "ezbookkeeping_docker_digests_file_path=${{ runner.temp }}/digests" >> "$GITHUB_OUTPUT"
|
||||
echo "ezbookkeeping_docker_digests_artifact_name_prefix=ezbookkeeping-build-dev-digests-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
|
||||
echo "ezbookkeeping_package_file_name_prefix=ezbookkeeping-dev-package-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
|
||||
echo "ezbookkeeping_package_artifact_name_prefix=ezbookkeeping-build-dev-package-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
|
||||
echo "ezbookkeeping_backend_artifact_name_prefix=ezbookkeeping-build-dev-backend-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Rename docker bake meta file
|
||||
@@ -51,7 +49,7 @@ jobs:
|
||||
mv "${{ steps.meta.outputs.bake-file }}" "${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_file_path }}"
|
||||
|
||||
- name: Upload docker bake meta artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_artifact_name }}
|
||||
path: ${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_file_path }}
|
||||
@@ -63,7 +61,7 @@ jobs:
|
||||
- setup
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- uses: ./.github/actions/build-linux-docker-and-package
|
||||
with:
|
||||
@@ -80,7 +78,6 @@ jobs:
|
||||
docker-bake-digests-file-path: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-file-path }}
|
||||
docker-bake-digests-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-artifact-name-prefix }}
|
||||
package-file-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-file-name-prefix }}
|
||||
package-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-artifact-name-prefix }}
|
||||
|
||||
build-linux-docker-and-package-arm:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
@@ -97,7 +94,7 @@ jobs:
|
||||
platform-name: linux-armv6
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- uses: ./.github/actions/build-linux-docker-and-package
|
||||
with:
|
||||
@@ -114,7 +111,6 @@ jobs:
|
||||
docker-bake-digests-file-path: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-file-path }}
|
||||
docker-bake-digests-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-artifact-name-prefix }}
|
||||
package-file-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-file-name-prefix }}
|
||||
package-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-artifact-name-prefix }}
|
||||
|
||||
build-windows-backend:
|
||||
needs:
|
||||
@@ -122,7 +118,7 @@ jobs:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- uses: ./.github/actions/build-windows-backend
|
||||
with:
|
||||
@@ -132,6 +128,23 @@ jobs:
|
||||
skip-tests: ${{ vars.SKIP_TESTS }}
|
||||
backend-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-backend-artifact-name-prefix }}
|
||||
|
||||
build-macos-backend:
|
||||
needs:
|
||||
- setup
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- uses: ./.github/actions/build-macos-backend
|
||||
with:
|
||||
architecture: arm64
|
||||
build-unix-time: ${{ needs.setup.outputs.build-unix-time }}
|
||||
build-date: ${{ needs.setup.outputs.build-date }}
|
||||
check-3rd-api: ${{ vars.CHECK_3RD_API }}
|
||||
skip-tests: ${{ vars.SKIP_TESTS }}
|
||||
backend-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-backend-artifact-name-prefix }}
|
||||
|
||||
build-windows-package:
|
||||
needs:
|
||||
- setup
|
||||
@@ -140,10 +153,25 @@ jobs:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- uses: ./.github/actions/build-windows-package
|
||||
with:
|
||||
package-file-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-file-name-prefix }}
|
||||
package-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-artifact-name-prefix }}
|
||||
backend-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-backend-artifact-name-prefix }}
|
||||
|
||||
build-macos-package:
|
||||
needs:
|
||||
- setup
|
||||
- build-macos-backend
|
||||
- build-linux-docker-and-package-x86
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- uses: ./.github/actions/build-macos-package
|
||||
with:
|
||||
architecture: arm64
|
||||
package-file-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-file-name-prefix }}
|
||||
backend-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-backend-artifact-name-prefix }}
|
||||
|
||||
@@ -9,6 +9,7 @@ jobs:
|
||||
setup:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
docker-push: ${{ steps.variable.outputs.docker_push }}
|
||||
build-unix-time: ${{ steps.variable.outputs.build_unix_time }}
|
||||
build-date: ${{ steps.variable.outputs.build_date }}
|
||||
docker-version: ${{ steps.meta.outputs.version }}
|
||||
@@ -19,15 +20,14 @@ jobs:
|
||||
ezbookkeeping-docker-digests-file-path: ${{ steps.variable.outputs.ezbookkeeping_docker_digests_file_path }}
|
||||
ezbookkeeping-docker-digests-artifact-name-prefix: ${{ steps.variable.outputs.ezbookkeeping_docker_digests_artifact_name_prefix }}
|
||||
ezbookkeeping-package-file-name-prefix: ${{ steps.variable.outputs.ezbookkeeping_package_file_name_prefix }}
|
||||
ezbookkeeping-package-artifact-name-prefix: ${{ steps.variable.outputs.ezbookkeeping_package_artifact_name_prefix }}
|
||||
ezbookkeeping-backend-artifact-name-prefix: ${{ steps.variable.outputs.ezbookkeeping_backend_artifact_name_prefix }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
${{ vars.DOCKER_IMAGE_NAME }}
|
||||
@@ -39,6 +39,7 @@ jobs:
|
||||
- name: Set up variables
|
||||
id: variable
|
||||
run: |
|
||||
echo "docker_push=${{ vars.DOCKER_IMAGE_NAME != '' && vars.DOCKER_USERNAME != '' && secrets.DOCKER_PASSWORD != '' }}" >> "$GITHUB_OUTPUT"
|
||||
echo "build_unix_time=$(date '+%s')" >> "$GITHUB_OUTPUT"
|
||||
echo "build_date=$(date '+%Y%m%d')" >> "$GITHUB_OUTPUT"
|
||||
echo "ezbookkeeping_docker_bake_meta_file_path=${{ runner.temp }}/bake-meta.json" >> "$GITHUB_OUTPUT"
|
||||
@@ -46,7 +47,6 @@ jobs:
|
||||
echo "ezbookkeeping_docker_digests_file_path=${{ runner.temp }}/digests" >> "$GITHUB_OUTPUT"
|
||||
echo "ezbookkeeping_docker_digests_artifact_name_prefix=ezbookkeeping-build-release-digests-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
|
||||
echo "ezbookkeeping_package_file_name_prefix=ezbookkeeping-${{ github.ref_name }}" >> "$GITHUB_OUTPUT"
|
||||
echo "ezbookkeeping_package_artifact_name_prefix=ezbookkeeping-build-release-package-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
|
||||
echo "ezbookkeeping_backend_artifact_name_prefix=ezbookkeeping-build-release-backend-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Rename docker bake meta file
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
mv "${{ steps.meta.outputs.bake-file }}" "${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_file_path }}"
|
||||
|
||||
- name: Upload docker bake meta artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_artifact_name }}
|
||||
path: ${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_file_path }}
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
- setup
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- uses: ./.github/actions/build-linux-docker-and-package
|
||||
with:
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
skip-tests: ${{ vars.SKIP_TESTS }}
|
||||
platform: linux/amd64
|
||||
platform-name: linux-amd64
|
||||
docker-push: true
|
||||
docker-push: ${{ needs.setup.outputs.docker-push }}
|
||||
docker-image-name: ${{ vars.DOCKER_IMAGE_NAME }}
|
||||
docker-username: ${{ vars.DOCKER_USERNAME }}
|
||||
docker-password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
@@ -86,7 +86,6 @@ jobs:
|
||||
docker-bake-digests-file-path: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-file-path }}
|
||||
docker-bake-digests-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-artifact-name-prefix }}
|
||||
package-file-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-file-name-prefix }}
|
||||
package-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-artifact-name-prefix }}
|
||||
|
||||
build-linux-docker-and-package-arm:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
@@ -103,7 +102,7 @@ jobs:
|
||||
platform-name: linux-armv6
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- uses: ./.github/actions/build-linux-docker-and-package
|
||||
with:
|
||||
@@ -114,7 +113,7 @@ jobs:
|
||||
skip-tests: ${{ vars.SKIP_TESTS }}
|
||||
platform: ${{ matrix.platform }}
|
||||
platform-name: ${{ matrix.platform-name }}
|
||||
docker-push: true
|
||||
docker-push: ${{ needs.setup.outputs.docker-push }}
|
||||
docker-image-name: ${{ vars.DOCKER_IMAGE_NAME }}
|
||||
docker-username: ${{ vars.DOCKER_USERNAME }}
|
||||
docker-password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
@@ -123,9 +122,9 @@ jobs:
|
||||
docker-bake-digests-file-path: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-file-path }}
|
||||
docker-bake-digests-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-artifact-name-prefix }}
|
||||
package-file-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-file-name-prefix }}
|
||||
package-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-artifact-name-prefix }}
|
||||
|
||||
push-linux-docker:
|
||||
if: ${{ needs.setup.outputs.docker-push == 'true' }}
|
||||
needs:
|
||||
- setup
|
||||
- build-linux-docker-and-package-x86
|
||||
@@ -133,7 +132,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- uses: ./.github/actions/push-linux-docker
|
||||
with:
|
||||
@@ -152,7 +151,7 @@ jobs:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- uses: ./.github/actions/build-windows-backend
|
||||
with:
|
||||
@@ -163,6 +162,24 @@ jobs:
|
||||
skip-tests: ${{ vars.SKIP_TESTS }}
|
||||
backend-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-backend-artifact-name-prefix }}
|
||||
|
||||
build-macos-backend:
|
||||
needs:
|
||||
- setup
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- uses: ./.github/actions/build-macos-backend
|
||||
with:
|
||||
architecture: arm64
|
||||
release-build: 1
|
||||
build-unix-time: ${{ needs.setup.outputs.build-unix-time }}
|
||||
build-date: ${{ needs.setup.outputs.build-date }}
|
||||
check-3rd-api: ${{ vars.CHECK_3RD_API }}
|
||||
skip-tests: ${{ vars.SKIP_TESTS }}
|
||||
backend-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-backend-artifact-name-prefix }}
|
||||
|
||||
build-windows-package:
|
||||
needs:
|
||||
- setup
|
||||
@@ -171,12 +188,27 @@ jobs:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- uses: ./.github/actions/build-windows-package
|
||||
with:
|
||||
package-file-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-file-name-prefix }}
|
||||
package-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-artifact-name-prefix }}
|
||||
backend-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-backend-artifact-name-prefix }}
|
||||
|
||||
build-macos-package:
|
||||
needs:
|
||||
- setup
|
||||
- build-macos-backend
|
||||
- build-linux-docker-and-package-x86
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- uses: ./.github/actions/build-macos-package
|
||||
with:
|
||||
architecture: arm64
|
||||
package-file-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-file-name-prefix }}
|
||||
backend-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-backend-artifact-name-prefix }}
|
||||
|
||||
publish-release:
|
||||
@@ -186,17 +218,19 @@ jobs:
|
||||
- build-linux-docker-and-package-x86
|
||||
- build-linux-docker-and-package-arm
|
||||
- build-windows-package
|
||||
- build-macos-package
|
||||
- push-linux-docker
|
||||
steps:
|
||||
- name: Download all packaged files
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: ${{ needs.setup.outputs.ezbookkeeping-package-artifact-name-prefix }}-*
|
||||
pattern: ${{ needs.setup.outputs.ezbookkeeping-package-file-name-prefix }}-*
|
||||
merge-multiple: true
|
||||
skip-decompress: true
|
||||
path: release-files
|
||||
|
||||
- name: Publish Release ${{ github.ref_name }}
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
name: ${{ github.ref_name }}
|
||||
tag_name: ${{ github.ref_name }}
|
||||
|
||||
@@ -9,6 +9,7 @@ jobs:
|
||||
setup:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
docker-push: ${{ steps.variable.outputs.docker_push }}
|
||||
build-unix-time: ${{ steps.variable.outputs.build_unix_time }}
|
||||
build-date: ${{ steps.variable.outputs.build_date }}
|
||||
docker-version: ${{ steps.meta.outputs.version }}
|
||||
@@ -19,15 +20,14 @@ jobs:
|
||||
ezbookkeeping-docker-digests-file-path: ${{ steps.variable.outputs.ezbookkeeping_docker_digests_file_path }}
|
||||
ezbookkeeping-docker-digests-artifact-name-prefix: ${{ steps.variable.outputs.ezbookkeeping_docker_digests_artifact_name_prefix }}
|
||||
ezbookkeeping-package-file-name-prefix: ${{ steps.variable.outputs.ezbookkeeping_package_file_name_prefix }}
|
||||
ezbookkeeping-package-artifact-name-prefix: ${{ steps.variable.outputs.ezbookkeeping_package_artifact_name_prefix }}
|
||||
ezbookkeeping-backend-artifact-name-prefix: ${{ steps.variable.outputs.ezbookkeeping_backend_artifact_name_prefix }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
${{ vars.DOCKER_IMAGE_NAME }}
|
||||
@@ -39,6 +39,7 @@ jobs:
|
||||
- name: Set up variables
|
||||
id: variable
|
||||
run: |
|
||||
echo "docker_push=${{ vars.DOCKER_IMAGE_NAME != '' && vars.DOCKER_USERNAME != '' && secrets.DOCKER_PASSWORD != '' }}" >> "$GITHUB_OUTPUT"
|
||||
echo "build_unix_time=$(date '+%s')" >> "$GITHUB_OUTPUT"
|
||||
echo "build_date=$(date '+%Y%m%d')" >> "$GITHUB_OUTPUT"
|
||||
echo "ezbookkeeping_docker_bake_meta_file_path=${{ runner.temp }}/bake-meta.json" >> "$GITHUB_OUTPUT"
|
||||
@@ -46,7 +47,6 @@ jobs:
|
||||
echo "ezbookkeeping_docker_digests_file_path=${{ runner.temp }}/digests" >> "$GITHUB_OUTPUT"
|
||||
echo "ezbookkeeping_docker_digests_artifact_name_prefix=ezbookkeeping-build-dev-digests-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
|
||||
echo "ezbookkeeping_package_file_name_prefix=ezbookkeeping-dev-package-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
|
||||
echo "ezbookkeeping_package_artifact_name_prefix=ezbookkeeping-build-dev-package-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
|
||||
echo "ezbookkeeping_backend_artifact_name_prefix=ezbookkeeping-build-dev-backend-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Rename docker bake meta file
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
mv "${{ steps.meta.outputs.bake-file }}" "${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_file_path }}"
|
||||
|
||||
- name: Upload docker bake meta artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_artifact_name }}
|
||||
path: ${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_file_path }}
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
- setup
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- uses: ./.github/actions/build-linux-docker-and-package
|
||||
with:
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
skip-tests: ${{ vars.SKIP_TESTS }}
|
||||
platform: linux/amd64
|
||||
platform-name: linux-amd64
|
||||
docker-push: true
|
||||
docker-push: ${{ needs.setup.outputs.docker-push }}
|
||||
docker-image-name: ${{ vars.DOCKER_IMAGE_NAME }}
|
||||
docker-username: ${{ vars.DOCKER_USERNAME }}
|
||||
docker-password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
@@ -85,7 +85,6 @@ jobs:
|
||||
docker-bake-digests-file-path: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-file-path }}
|
||||
docker-bake-digests-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-artifact-name-prefix }}
|
||||
package-file-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-file-name-prefix }}
|
||||
package-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-artifact-name-prefix }}
|
||||
|
||||
build-linux-docker-and-package-arm:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
@@ -102,7 +101,7 @@ jobs:
|
||||
platform-name: linux-armv6
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- uses: ./.github/actions/build-linux-docker-and-package
|
||||
with:
|
||||
@@ -112,7 +111,7 @@ jobs:
|
||||
skip-tests: ${{ vars.SKIP_TESTS }}
|
||||
platform: ${{ matrix.platform }}
|
||||
platform-name: ${{ matrix.platform-name }}
|
||||
docker-push: true
|
||||
docker-push: ${{ needs.setup.outputs.docker-push }}
|
||||
docker-image-name: ${{ vars.DOCKER_IMAGE_NAME }}
|
||||
docker-username: ${{ vars.DOCKER_USERNAME }}
|
||||
docker-password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
@@ -121,9 +120,9 @@ jobs:
|
||||
docker-bake-digests-file-path: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-file-path }}
|
||||
docker-bake-digests-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-artifact-name-prefix }}
|
||||
package-file-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-file-name-prefix }}
|
||||
package-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-artifact-name-prefix }}
|
||||
|
||||
push-linux-docker:
|
||||
if: ${{ needs.setup.outputs.docker-push == 'true' }}
|
||||
needs:
|
||||
- setup
|
||||
- build-linux-docker-and-package-x86
|
||||
@@ -131,7 +130,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- uses: ./.github/actions/push-linux-docker
|
||||
with:
|
||||
@@ -150,7 +149,7 @@ jobs:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- uses: ./.github/actions/build-windows-backend
|
||||
with:
|
||||
@@ -160,6 +159,23 @@ jobs:
|
||||
skip-tests: ${{ vars.SKIP_TESTS }}
|
||||
backend-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-backend-artifact-name-prefix }}
|
||||
|
||||
build-macos-backend:
|
||||
needs:
|
||||
- setup
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- uses: ./.github/actions/build-macos-backend
|
||||
with:
|
||||
architecture: arm64
|
||||
build-unix-time: ${{ needs.setup.outputs.build-unix-time }}
|
||||
build-date: ${{ needs.setup.outputs.build-date }}
|
||||
check-3rd-api: ${{ vars.CHECK_3RD_API }}
|
||||
skip-tests: ${{ vars.SKIP_TESTS }}
|
||||
backend-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-backend-artifact-name-prefix }}
|
||||
|
||||
build-windows-package:
|
||||
needs:
|
||||
- setup
|
||||
@@ -168,10 +184,25 @@ jobs:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- uses: ./.github/actions/build-windows-package
|
||||
with:
|
||||
package-file-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-file-name-prefix }}
|
||||
package-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-artifact-name-prefix }}
|
||||
backend-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-backend-artifact-name-prefix }}
|
||||
|
||||
build-macos-package:
|
||||
needs:
|
||||
- setup
|
||||
- build-macos-backend
|
||||
- build-linux-docker-and-package-x86
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- uses: ./.github/actions/build-macos-package
|
||||
with:
|
||||
architecture: arm64
|
||||
package-file-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-file-name-prefix }}
|
||||
backend-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-backend-artifact-name-prefix }}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
name: Update i18n Translation Progress Badges
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'src/locales/**'
|
||||
- 'pkg/locales/**'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
update-i18n-progress:
|
||||
if: ${{ vars.UPDATE_I18N_BADGE_REPO == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Update translation progress data
|
||||
run: |
|
||||
node .github/scripts/update-i18n-progress.js ${{ runner.temp }}/i18n-badge
|
||||
|
||||
- name: Checkout badge repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repository: mayswind/ezbookkeeping-i18n-badge
|
||||
token: ${{ secrets.I18N_BADGE_REPO_TOKEN }}
|
||||
path: ezbookkeeping-i18n-badge
|
||||
|
||||
- name: Update badge data
|
||||
run: |
|
||||
rm -rf ezbookkeeping-i18n-badge/i18n-progress.json
|
||||
cp ${{ runner.temp }}/i18n-badge/i18n-progress.json ezbookkeeping-i18n-badge/
|
||||
mkdir -p ezbookkeeping-i18n-badge/badges
|
||||
rm -rf ezbookkeeping-i18n-badge/badges/*
|
||||
cp ${{ runner.temp }}/i18n-badge/badges/*.json ezbookkeeping-i18n-badge/badges/
|
||||
mkdir -p ezbookkeeping-i18n-badge/untranslated
|
||||
rm -rf ezbookkeeping-i18n-badge/untranslated/*
|
||||
cp ${{ runner.temp }}/i18n-badge/untranslated/*.json ezbookkeeping-i18n-badge/untranslated/
|
||||
|
||||
- name: Commit and push
|
||||
run: |
|
||||
cd ezbookkeeping-i18n-badge
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add -A
|
||||
if git diff --cached --quiet; then
|
||||
echo "No changes to commit"
|
||||
else
|
||||
git commit -m "Update i18n progress data (${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }})"
|
||||
git push
|
||||
fi
|
||||
|
||||
- name: Purge GitHub camo image cache
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
CAMO_URLS=$(curl -s -H "Accept: application/vnd.github.html+json" -H "Authorization: token $GITHUB_TOKEN" "https://api.github.com/repos/${{ github.repository }}/readme" | grep -oP 'https://camo\.githubusercontent\.com/[^"]+' | sort -u)
|
||||
|
||||
if [ -z "$CAMO_URLS" ]; then
|
||||
echo "No camo URLs found, skipping cache purge"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
for url in $CAMO_URLS; do
|
||||
echo "Purging: $url"
|
||||
curl -s -X PURGE "$url" > /dev/null
|
||||
done
|
||||
|
||||
echo "Purged $(echo "$CAMO_URLS" | wc -l) camo URLs"
|
||||
+4
-4
@@ -151,13 +151,13 @@ dist/
|
||||
# Binary and build files
|
||||
ezbookkeeping
|
||||
!**/ezbookkeeping/
|
||||
package/
|
||||
/package/
|
||||
|
||||
# Environment variable files
|
||||
.env
|
||||
**/.env
|
||||
|
||||
# Other directories
|
||||
data/
|
||||
storage/
|
||||
log/
|
||||
/data/
|
||||
/storage/
|
||||
/log/
|
||||
|
||||
+3
-3
@@ -1,5 +1,5 @@
|
||||
# Build backend binary file
|
||||
FROM golang:1.25.7-alpine3.23 AS be-builder
|
||||
FROM golang:1.26.2-alpine3.23 AS be-builder
|
||||
ARG RELEASE_BUILD
|
||||
ARG BUILD_PIPELINE
|
||||
ARG BUILD_UNIXTIME
|
||||
@@ -19,7 +19,7 @@ RUN apk add git gcc g++ libc-dev
|
||||
RUN ./build.sh backend
|
||||
|
||||
# Build frontend files
|
||||
FROM --platform=$BUILDPLATFORM node:24.14.0-alpine3.23 AS fe-builder
|
||||
FROM --platform=$BUILDPLATFORM node:24.15.0-alpine3.23 AS fe-builder
|
||||
ARG RELEASE_BUILD
|
||||
ARG BUILD_PIPELINE
|
||||
ARG BUILD_UNIXTIME
|
||||
@@ -35,7 +35,7 @@ RUN apk add git
|
||||
RUN ./build.sh frontend
|
||||
|
||||
# Package docker image
|
||||
FROM alpine:3.23.3
|
||||
FROM alpine:3.23.4
|
||||
LABEL maintainer="MaysWind <i@mayswind.net>"
|
||||
RUN addgroup -S -g 1000 ezbookkeeping && adduser -S -G ezbookkeeping -u 1000 ezbookkeeping
|
||||
RUN apk --no-cache add tzdata
|
||||
|
||||
@@ -34,7 +34,7 @@ Live Demo: [https://ezbookkeeping-demo.mayswind.net](https://ezbookkeeping-demo.
|
||||
- **AI-Powered Features**
|
||||
- Receipt image recognition
|
||||
- MCP (Model Context Protocol) support for AI integration
|
||||
- API command-line script tools for AI integration
|
||||
- Agent Skill and API command-line script tools support for AI integration
|
||||
- **Powerful Bookkeeping**
|
||||
- Two-level accounts and categories
|
||||
- Image attachments for transactions
|
||||
@@ -54,7 +54,7 @@ Live Demo: [https://ezbookkeeping-demo.mayswind.net](https://ezbookkeeping-demo.
|
||||
- **Data Import & Export**
|
||||
- Supports CSV, OFX, QFX, QIF, IIF, Camt.052, Camt.053, MT940, GnuCash, Firefly III, Beancount and more
|
||||
|
||||
For a full list of features, visit the [Full Feature List](https://ezbookkeeping.mayswind.net/comparison/).
|
||||
For a full list of features, visit the [Full Feature List](https://ezbookkeeping.mayswind.net/features/).
|
||||
|
||||
## Screenshots
|
||||
### Desktop Version
|
||||
@@ -129,27 +129,27 @@ Help make ezBookkeeping accessible to users around the world. We welcome help to
|
||||
|
||||
Currently available translations:
|
||||
|
||||
| Tag | Language | Contributors |
|
||||
| --- | --- | --- |
|
||||
| de | Deutsch | [@chrgm](https://github.com/chrgm) |
|
||||
| en | English | / |
|
||||
| es | Español | [@Miguelonlonlon](https://github.com/Miguelonlonlon), [@abrugues](https://github.com/abrugues), [@AndresTeller](https://github.com/AndresTeller), [@diegofercri](https://github.com/diegofercri) |
|
||||
| fr | Français | [@brieucdlf](https://github.com/brieucdlf) |
|
||||
| it | Italiano | [@waron97](https://github.com/waron97) |
|
||||
| ja | 日本語 | [@tkymmm](https://github.com/tkymmm) |
|
||||
| kn | ಕನ್ನಡ | [@Darshanbm05](https://github.com/Darshanbm05) |
|
||||
| ko | 한국어 | [@overworks](https://github.com/overworks) |
|
||||
| nl | Nederlands | [@automagics](https://github.com/automagics) |
|
||||
| pt-BR | Português (Brasil) | [@thecodergus](https://github.com/thecodergus), [@balaios](https://github.com/balaios) |
|
||||
| ru | Русский | [@artegoser](https://github.com/artegoser), [@dshemin](https://github.com/dshemin) |
|
||||
| sl | Slovenščina | [@thehijacker](https://github.com/thehijacker) |
|
||||
| ta | தமிழ் | [@hhharsha36](https://github.com/hhharsha36) |
|
||||
| th | ไทย | [@natthavat28](https://github.com/natthavat28) |
|
||||
| tr | Türkçe | [@aydnykn](https://github.com/aydnykn) |
|
||||
| uk | Українська | [@nktlitvinenko](https://github.com/nktlitvinenko) |
|
||||
| vi | Tiếng Việt | [@f97](https://github.com/f97) |
|
||||
| zh-Hans | 中文 (简体) | / |
|
||||
| zh-Hant | 中文 (繁體) | / |
|
||||
| Tag | Language | Progress | Contributors |
|
||||
| --- | --- | --- | --- |
|
||||
| de | Deutsch | [](https://github.com/mayswind/ezBookkeeping-i18n-badge/blob/main/untranslated/de.json) | [@chrgm](https://github.com/chrgm), [@1270o1](https://github.com/1270o1) |
|
||||
| en | English | [](https://github.com/mayswind/ezBookkeeping-i18n-badge/blob/main/untranslated/en.json) | / |
|
||||
| es | Español | [](https://github.com/mayswind/ezBookkeeping-i18n-badge/blob/main/untranslated/es.json) | [@Miguelonlonlon](https://github.com/Miguelonlonlon), [@abrugues](https://github.com/abrugues), [@AndresTeller](https://github.com/AndresTeller), [@diegofercri](https://github.com/diegofercri) |
|
||||
| fr | Français | [](https://github.com/mayswind/ezBookkeeping-i18n-badge/blob/main/untranslated/fr.json) | [@brieucdlf](https://github.com/brieucdlf) |
|
||||
| it | Italiano | [](https://github.com/mayswind/ezBookkeeping-i18n-badge/blob/main/untranslated/it.json) | [@waron97](https://github.com/waron97) |
|
||||
| ja | 日本語 | [](https://github.com/mayswind/ezBookkeeping-i18n-badge/blob/main/untranslated/ja.json) | [@tkymmm](https://github.com/tkymmm) |
|
||||
| kn | ಕನ್ನಡ | [](https://github.com/mayswind/ezBookkeeping-i18n-badge/blob/main/untranslated/kn.json) | [@Darshanbm05](https://github.com/Darshanbm05) |
|
||||
| ko | 한국어 | [](https://github.com/mayswind/ezBookkeeping-i18n-badge/blob/main/untranslated/ko.json) | [@overworks](https://github.com/overworks) |
|
||||
| nl | Nederlands | [](https://github.com/mayswind/ezBookkeeping-i18n-badge/blob/main/untranslated/nl.json) | [@automagics](https://github.com/automagics) |
|
||||
| pt-BR | Português (Brasil) | [](https://github.com/mayswind/ezBookkeeping-i18n-badge/blob/main/untranslated/pt-BR.json) | [@thecodergus](https://github.com/thecodergus), [@balaios](https://github.com/balaios) |
|
||||
| ru | Русский | [](https://github.com/mayswind/ezBookkeeping-i18n-badge/blob/main/untranslated/ru.json) | [@artegoser](https://github.com/artegoser), [@dshemin](https://github.com/dshemin) |
|
||||
| sl | Slovenščina | [](https://github.com/mayswind/ezBookkeeping-i18n-badge/blob/main/untranslated/sl.json) | [@thehijacker](https://github.com/thehijacker) |
|
||||
| ta | தமிழ் | [](https://github.com/mayswind/ezBookkeeping-i18n-badge/blob/main/untranslated/ta.json) | [@hhharsha36](https://github.com/hhharsha36) |
|
||||
| th | ไทย | [](https://github.com/mayswind/ezBookkeeping-i18n-badge/blob/main/untranslated/th.json) | [@natthavat28](https://github.com/natthavat28) |
|
||||
| tr | Türkçe | [](https://github.com/mayswind/ezBookkeeping-i18n-badge/blob/main/untranslated/tr.json) | [@aydnykn](https://github.com/aydnykn) |
|
||||
| uk | Українська | [](https://github.com/mayswind/ezBookkeeping-i18n-badge/blob/main/untranslated/uk.json) | [@nktlitvinenko](https://github.com/nktlitvinenko) |
|
||||
| vi | Tiếng Việt | [](https://github.com/mayswind/ezBookkeeping-i18n-badge/blob/main/untranslated/vi.json) | [@f97](https://github.com/f97) |
|
||||
| zh-Hans | 中文 (简体) | [](https://github.com/mayswind/ezBookkeeping-i18n-badge/blob/main/untranslated/zh-Hans.json) | / |
|
||||
| zh-Hant | 中文 (繁體) | [](https://github.com/mayswind/ezBookkeeping-i18n-badge/blob/main/untranslated/zh-Hant.json) | / |
|
||||
|
||||
## Documentation
|
||||
1. [English](https://ezbookkeeping.mayswind.net)
|
||||
|
||||
@@ -160,6 +160,12 @@ build_backend() {
|
||||
fi
|
||||
fi
|
||||
|
||||
ld_static_link_flags=""
|
||||
|
||||
if [ "$(uname -s)" = "Linux" ]; then
|
||||
ld_static_link_flags="-linkmode external -extldflags '-static'"
|
||||
fi
|
||||
|
||||
backend_build_extra_arguments="-X main.Version=$VERSION"
|
||||
backend_build_extra_arguments="$backend_build_extra_arguments -X main.CommitHash=$COMMIT_HASH"
|
||||
|
||||
@@ -169,7 +175,7 @@ build_backend() {
|
||||
|
||||
echo "Building backend binary file ($RELEASE_TYPE)..."
|
||||
|
||||
CGO_ENABLED=1 go build -a -v -trimpath -ldflags "-w -s -linkmode external -extldflags '-static' $backend_build_extra_arguments" -o ezbookkeeping ezbookkeeping.go
|
||||
CGO_ENABLED=1 go build -a -v -trimpath -ldflags "-w -s $ld_static_link_flags $backend_build_extra_arguments" -o ezbookkeeping ezbookkeeping.go
|
||||
chmod +x ezbookkeeping
|
||||
}
|
||||
|
||||
|
||||
@@ -953,6 +953,7 @@ func printUserInfo(user *models.User) {
|
||||
fmt.Printf("[Password] %s\n", user.Password)
|
||||
fmt.Printf("[Salt] %s\n", user.Salt)
|
||||
fmt.Printf("[DefaultAccountId] %d\n", user.DefaultAccountId)
|
||||
fmt.Printf("[UseLastReconciledTime] %t\n", user.UseLastReconciledTime)
|
||||
fmt.Printf("[TransactionEditScope] %s (%d)\n", user.TransactionEditScope, user.TransactionEditScope)
|
||||
fmt.Printf("[Language] %s\n", user.Language)
|
||||
fmt.Printf("[DefaultCurrency] %s\n", user.DefaultCurrency)
|
||||
|
||||
@@ -375,6 +375,7 @@ func startWebServer(c *core.CliContext) error {
|
||||
apiV1Route.GET("/accounts/get.json", bindApi(api.Accounts.AccountGetHandler))
|
||||
apiV1Route.POST("/accounts/add.json", bindApi(api.Accounts.AccountCreateHandler))
|
||||
apiV1Route.POST("/accounts/modify.json", bindApi(api.Accounts.AccountModifyHandler))
|
||||
apiV1Route.POST("/accounts/update/last_reconciled_time.json", bindApi(api.Accounts.AccountUpdateLastReconciledTimeHandler))
|
||||
apiV1Route.POST("/accounts/hide.json", bindApi(api.Accounts.AccountHideHandler))
|
||||
apiV1Route.POST("/accounts/move.json", bindApi(api.Accounts.AccountMoveHandler))
|
||||
apiV1Route.POST("/accounts/delete.json", bindApi(api.Accounts.AccountDeleteHandler))
|
||||
@@ -393,8 +394,14 @@ func startWebServer(c *core.CliContext) error {
|
||||
apiV1Route.GET("/transactions/get.json", bindApi(api.Transactions.TransactionGetHandler))
|
||||
apiV1Route.POST("/transactions/add.json", bindApi(api.Transactions.TransactionCreateHandler))
|
||||
apiV1Route.POST("/transactions/modify.json", bindApi(api.Transactions.TransactionModifyHandler))
|
||||
apiV1Route.POST("/transactions/batch_update/category.json", bindApi(api.Transactions.TransactionBatchUpdateCategoriesHandler))
|
||||
apiV1Route.POST("/transactions/batch_update/account.json", bindApi(api.Transactions.TransactionBatchUpdateAccountsHandler))
|
||||
apiV1Route.POST("/transactions/batch_update/tag/add.json", bindApi(api.Transactions.TransactionBatchAddTagsHandler))
|
||||
apiV1Route.POST("/transactions/batch_update/tag/remove.json", bindApi(api.Transactions.TransactionBatchRemoveTagsHandler))
|
||||
apiV1Route.POST("/transactions/batch_update/tag/clear.json", bindApi(api.Transactions.TransactionBatchClearTagsHandler))
|
||||
apiV1Route.POST("/transactions/move/all.json", bindApi(api.Transactions.TransactionMoveAllBetweenAccountsHandler))
|
||||
apiV1Route.POST("/transactions/delete.json", bindApi(api.Transactions.TransactionDeleteHandler))
|
||||
apiV1Route.POST("/transactions/batch_delete.json", bindApi(api.Transactions.TransactionBatchDeleteHandler))
|
||||
|
||||
if config.EnableDataImport {
|
||||
apiV1Route.POST("/transactions/parse_custom_file.json", bindApi(api.Transactions.TransactionParseImportCustomFileDataHandler))
|
||||
|
||||
@@ -536,6 +536,7 @@ custom_map_tile_server_default_zoom_level = 14
|
||||
# "national_bank_of_georgia": https://nbg.gov.ge/en/monetary-policy/currency
|
||||
# "central_bank_of_hungary": https://www.mnb.hu/en/arfolyamok
|
||||
# "bank_of_israel": https://www.boi.org.il/en/economic-roles/financial-markets/exchange-rates/
|
||||
# "national_bank_of_kazakhstan": https://nationalbank.kz/en/exchangerates/ezhednevnye-oficialnye-rynochnye-kursy-valyut
|
||||
# "central_bank_of_myanmar": https://forex.cbm.gov.mm/index.php/fxrate
|
||||
# "norges_bank": https://www.norges-bank.no/en/topics/Statistics/exchange_rates/
|
||||
# "national_bank_of_poland": https://nbp.pl/en/statistic-and-financial-reporting/rates/
|
||||
|
||||
+2
-1
@@ -14,7 +14,8 @@
|
||||
],
|
||||
"translators": {
|
||||
"de": [
|
||||
"chrgm"
|
||||
"chrgm",
|
||||
"1270o1"
|
||||
],
|
||||
"en": [],
|
||||
"es": [
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
module github.com/mayswind/ezbookkeeping
|
||||
|
||||
go 1.25
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/boombuler/barcode v1.1.0
|
||||
github.com/coreos/go-oidc/v3 v3.17.0
|
||||
github.com/coreos/go-oidc/v3 v3.18.0
|
||||
github.com/extrame/xls v0.0.2-0.20200426124601-4a6cf263071b
|
||||
github.com/gin-contrib/cache v1.4.1
|
||||
github.com/gin-contrib/gzip v1.2.5
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/go-co-op/gocron/v2 v2.19.1
|
||||
github.com/go-playground/validator/v10 v10.30.1
|
||||
github.com/gin-contrib/cache v1.4.4
|
||||
github.com/gin-contrib/gzip v1.2.6
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/go-co-op/gocron/v2 v2.21.1
|
||||
github.com/go-playground/validator/v10 v10.30.2
|
||||
github.com/go-sql-driver/mysql v1.9.3
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/invopop/jsonschema v0.13.0
|
||||
github.com/lib/pq v1.11.1
|
||||
github.com/mattn/go-sqlite3 v1.14.33
|
||||
github.com/minio/minio-go/v7 v7.0.98
|
||||
github.com/lib/pq v1.12.3
|
||||
github.com/mattn/go-sqlite3 v1.14.42
|
||||
github.com/minio/minio-go/v7 v7.0.100
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/sirupsen/logrus v1.9.4
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/urfave/cli/v3 v3.6.2
|
||||
github.com/urfave/cli/v3 v3.8.0
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8
|
||||
github.com/xuri/excelize/v2 v2.10.0
|
||||
golang.org/x/crypto v0.47.0
|
||||
golang.org/x/net v0.49.0
|
||||
golang.org/x/oauth2 v0.34.0
|
||||
golang.org/x/text v0.33.0
|
||||
github.com/xuri/excelize/v2 v2.10.1
|
||||
golang.org/x/crypto v0.50.0
|
||||
golang.org/x/net v0.53.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
golang.org/x/text v0.36.0
|
||||
gopkg.in/ini.v1 v1.67.1
|
||||
gopkg.in/mail.v2 v2.3.1
|
||||
xorm.io/builder v0.3.13
|
||||
@@ -40,8 +40,8 @@ require (
|
||||
github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf // indirect
|
||||
github.com/buger/jsonparser v1.1.1 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.14.1 // indirect
|
||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
|
||||
github.com/chenzhuoyu/iasm v0.9.1 // indirect
|
||||
@@ -52,20 +52,20 @@ require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/extrame/goyymmdd v0.0.0-20210114090516-7cc815f00d1a // indirect
|
||||
github.com/extrame/ole2 v0.0.0-20160812065207-d69429661ad7 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-ini/ini v1.67.0 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
github.com/gomodule/redigo v1.9.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jonboulle/clockwork v0.5.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.18.2 // indirect
|
||||
github.com/klauspost/compress v1.18.5 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/klauspost/crc32 v1.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
@@ -80,30 +80,31 @@ require (
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/quic-go v0.55.0 // indirect
|
||||
github.com/richardlehane/mscfb v1.0.4 // indirect
|
||||
github.com/richardlehane/msoleps v1.0.4 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/richardlehane/mscfb v1.0.6 // indirect
|
||||
github.com/richardlehane/msoleps v1.0.6 // indirect
|
||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||
github.com/robfig/go-cache v0.0.0-20130306151617-9fc39e0dbf62 // indirect
|
||||
github.com/rs/xid v1.6.0 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/syndtr/goleveldb v1.0.0 // indirect
|
||||
github.com/tealeg/xlsx v1.0.5 // indirect
|
||||
github.com/tiendc/go-deepcopy v1.7.1 // indirect
|
||||
github.com/tiendc/go-deepcopy v1.7.2 // indirect
|
||||
github.com/tinylib/msgp v1.6.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
|
||||
github.com/xuri/efp v0.0.1 // indirect
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect
|
||||
golang.org/x/mod v0.31.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/tools v0.40.0 // indirect
|
||||
golang.org/x/mod v0.34.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/tools v0.43.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
||||
@@ -14,10 +14,10 @@ github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
||||
github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM=
|
||||
github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7w=
|
||||
github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc=
|
||||
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
||||
@@ -28,8 +28,8 @@ github.com/chenzhuoyu/iasm v0.9.1/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLI
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
|
||||
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
|
||||
github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
|
||||
github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
@@ -43,34 +43,34 @@ github.com/extrame/ole2 v0.0.0-20160812065207-d69429661ad7/go.mod h1:GPpMrAfHdb8
|
||||
github.com/extrame/xls v0.0.2-0.20200426124601-4a6cf263071b h1:jqW/h4gcXYEB6kVf6iuxjU9ONWA0ugUB94TP9UNmgdg=
|
||||
github.com/extrame/xls v0.0.2-0.20200426124601-4a6cf263071b/go.mod h1:iACcgahst7BboCpIMSpnFs4SKyU9ZjsvZBfNbUxZOJI=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/cache v1.4.1 h1:HcLwLfw7p+FasNp5VAnFbbBj9SzB4bDtswvon7wYSg4=
|
||||
github.com/gin-contrib/cache v1.4.1/go.mod h1:tykDV+FgItJHYEO0eCasuRYsZKPPyb4BYhAjuTlG6RM=
|
||||
github.com/gin-contrib/gzip v1.2.5 h1:fIZs0S+l17pIu1P5XRJOo/YNqfIuPCrZZ3TWB7pjckI=
|
||||
github.com/gin-contrib/gzip v1.2.5/go.mod h1:aomRgR7ftdZV3uWY0gW/m8rChfxau0n8YVvwlOHONzw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/cache v1.4.4 h1:4Sasrroa8CrbRYQ3aEMutRJGhz7ujyPlKvAPmJdIx9U=
|
||||
github.com/gin-contrib/cache v1.4.4/go.mod h1:OfwzOu0CcBcQYgvc+wg7moQWFzmJCKqmo0NU7Wx3xyQ=
|
||||
github.com/gin-contrib/gzip v1.2.6 h1:OtN8DplD5DNZCSLAnQ5HxRkD2qZ5VU+JhOrcfJrcRvg=
|
||||
github.com/gin-contrib/gzip v1.2.6/go.mod h1:BQy8/+JApnRjAVUplSGZiVtD2k8GmIE2e9rYu/hLzzU=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||
github.com/go-co-op/gocron/v2 v2.19.1 h1:B4iLeA0NB/2iO3EKQ7NfKn5KsQgZfjb2fkvoZJU3yBI=
|
||||
github.com/go-co-op/gocron/v2 v2.19.1/go.mod h1:5lEiCKk1oVJV39Zg7/YG10OnaVrDAV5GGR6O0663k6U=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/go-co-op/gocron/v2 v2.21.1 h1:QYOK6iOQVCut+jDcs4zRdWRTBHRxRCEeeFi1TnAmgbU=
|
||||
github.com/go-co-op/gocron/v2 v2.21.1/go.mod h1:5lEiCKk1oVJV39Zg7/YG10OnaVrDAV5GGR6O0663k6U=
|
||||
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ=
|
||||
github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc=
|
||||
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
@@ -90,8 +90,8 @@ github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7X
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
@@ -104,22 +104,22 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/lib/pq v1.11.1 h1:wuChtj2hfsGmmx3nf1m7xC2XpK6OtelS2shMY+bGMtI=
|
||||
github.com/lib/pq v1.11.1/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
|
||||
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
|
||||
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo=
|
||||
github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
|
||||
github.com/memcachier/mc/v3 v3.0.3 h1:qii+lDiPKi36O4Xg+HVKwHu6Oq+Gt17b+uEiA0Drwv4=
|
||||
github.com/memcachier/mc/v3 v3.0.3/go.mod h1:GzjocBahcXPxt2cmqzknrgqCOmMxiSzhVKPOe90Tpug=
|
||||
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
|
||||
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
|
||||
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||
github.com/minio/minio-go/v7 v7.0.98 h1:MeAVKjLVz+XJ28zFcuYyImNSAh8Mq725uNW4beRisi0=
|
||||
github.com/minio/minio-go/v7 v7.0.98/go.mod h1:cY0Y+W7yozf0mdIclrttzo1Iiu7mEf9y7nk2uXqMOvM=
|
||||
github.com/minio/minio-go/v7 v7.0.100 h1:ShkWi8Tyj9RtU57OQB2HIXKz4bFgtVib0bbT1sbtLI8=
|
||||
github.com/minio/minio-go/v7 v7.0.100/go.mod h1:EtGNKtlX20iL2yaYnxEigaIvj0G0GwSDnifnG8ClIdw=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
@@ -139,15 +139,14 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||
github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk=
|
||||
github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U=
|
||||
github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM=
|
||||
github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk=
|
||||
github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
||||
github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00=
|
||||
github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8=
|
||||
github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
|
||||
github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=
|
||||
github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/robfig/go-cache v0.0.0-20130306151617-9fc39e0dbf62 h1:pyecQtsPmlkCsMkYhT5iZ+sUXuwee+OvfuJjinEA3ko=
|
||||
@@ -167,58 +166,58 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE=
|
||||
github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=
|
||||
github.com/tealeg/xlsx v1.0.5 h1:+f8oFmvY8Gw1iUXzPk+kz+4GpbDZPK1FhPiQRd+ypgE=
|
||||
github.com/tealeg/xlsx v1.0.5/go.mod h1:btRS8dz54TDnvKNosuAqxrM1QgN1udgk9O34bDCnORM=
|
||||
github.com/tiendc/go-deepcopy v1.7.1 h1:LnubftI6nYaaMOcaz0LphzwraqN8jiWTwm416sitff4=
|
||||
github.com/tiendc/go-deepcopy v1.7.1/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
|
||||
github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
|
||||
github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
|
||||
github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
|
||||
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/urfave/cli/v3 v3.6.2 h1:lQuqiPrZ1cIz8hz+HcrG0TNZFxU70dPZ3Yl+pSrH9A8=
|
||||
github.com/urfave/cli/v3 v3.6.2/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/urfave/cli/v3 v3.8.0 h1:XqKPrm0q4P0q5JpoclYoCAv0/MIvH/jZ2umzuf8pNTI=
|
||||
github.com/urfave/cli/v3 v3.8.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
||||
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
|
||||
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
|
||||
github.com/xuri/excelize/v2 v2.10.0 h1:8aKsP7JD39iKLc6dH5Tw3dgV3sPRh8uRVXu/fMstfW4=
|
||||
github.com/xuri/excelize/v2 v2.10.0/go.mod h1:SC5TzhQkaOsTWpANfm+7bJCldzcnU/jrhqkTi/iBHBU=
|
||||
github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzxN0=
|
||||
github.com/xuri/excelize/v2 v2.10.1/go.mod h1:iG5tARpgaEeIhTqt3/fgXCGoBRt4hNXgCp3tfXKoOIc=
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI=
|
||||
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { type JestConfigWithTsJest, createDefaultEsmPreset } from 'ts-jest';
|
||||
|
||||
const presetConfig = createDefaultEsmPreset({
|
||||
tsconfig: '<rootDir>/tsconfig.jest.json'
|
||||
});
|
||||
|
||||
const config: JestConfigWithTsJest = {
|
||||
...presetConfig,
|
||||
clearMocks: true,
|
||||
collectCoverage: false,
|
||||
moduleNameMapper: {
|
||||
"^@/(.*)$": "<rootDir>/src/$1"
|
||||
},
|
||||
testEnvironment: "node",
|
||||
testMatch: [
|
||||
"**/__tests__/**/*.[jt]s?(x)",
|
||||
"!**/__tests__/*_gen.[jt]s?(x)"
|
||||
]
|
||||
};
|
||||
|
||||
export default config;
|
||||
Generated
+1776
-3959
File diff suppressed because it is too large
Load Diff
+56
-58
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ezbookkeeping",
|
||||
"version": "1.4.0",
|
||||
"version": "1.5.0",
|
||||
"private": true,
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -16,67 +16,65 @@
|
||||
"build": "cross-env NODE_ENV=production vite build",
|
||||
"serve:dist": "vite preview",
|
||||
"lint": "vue-tsc --noEmit && eslint . --fix",
|
||||
"test": "cross-env TS_NODE_PROJECT=\"./tsconfig.jest.json\" jest"
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mdi/js": "^7.4.47",
|
||||
"@vuepic/vue-datepicker": "^12.1.0",
|
||||
"axios": "^1.13.4",
|
||||
"cbor-js": "^0.1.0",
|
||||
"chardet": "^2.1.1",
|
||||
"clipboard": "^2.0.11",
|
||||
"crypto-js": "^4.2.0",
|
||||
"dom7": "^4.0.6",
|
||||
"echarts": "^6.0.0",
|
||||
"framework7": "^9.0.3",
|
||||
"framework7-icons": "^5.0.5",
|
||||
"framework7-vue": "^9.0.3",
|
||||
"jalaali-js": "^1.2.8",
|
||||
"leaflet": "^1.9.4",
|
||||
"line-awesome": "^1.3.0",
|
||||
"moment": "^2.30.1",
|
||||
"moment-timezone": "^0.6.0",
|
||||
"pinia": "^3.0.4",
|
||||
"register-service-worker": "^1.7.2",
|
||||
"skeleton-elements": "^4.0.1",
|
||||
"swiper": "^12.1.0",
|
||||
"ua-parser-js": "^1.0.39",
|
||||
"vue": "^3.5.27",
|
||||
"vue-echarts": "^8.0.1",
|
||||
"vue-i18n": "^11.2.8",
|
||||
"vue-router": "^5.0.2",
|
||||
"vue3-perfect-scrollbar": "^2.0.0",
|
||||
"vuedraggable": "^4.1.0",
|
||||
"vuetify": "^3.11.8"
|
||||
"@mdi/js": "7.4.47",
|
||||
"@vuepic/vue-datepicker": "12.1.0",
|
||||
"axios": "1.15.2",
|
||||
"cbor-js": "0.1.0",
|
||||
"chardet": "2.1.1",
|
||||
"clipboard": "2.0.11",
|
||||
"crypto-js": "4.2.0",
|
||||
"dom7": "4.0.6",
|
||||
"echarts": "6.0.0",
|
||||
"framework7": "9.0.3",
|
||||
"framework7-icons": "5.0.5",
|
||||
"framework7-vue": "9.0.3",
|
||||
"jalaali-js": "1.2.8",
|
||||
"leaflet": "1.9.4",
|
||||
"line-awesome": "1.3.0",
|
||||
"moment": "2.30.1",
|
||||
"moment-timezone": "0.6.1",
|
||||
"pinia": "3.0.4",
|
||||
"register-service-worker": "1.7.2",
|
||||
"skeleton-elements": "4.0.1",
|
||||
"swiper": "12.1.3",
|
||||
"ua-parser-js": "1.0.39",
|
||||
"vue": "3.5.33",
|
||||
"vue-echarts": "8.0.1",
|
||||
"vue-i18n": "11.3.2",
|
||||
"vue-router": "5.0.6",
|
||||
"vue3-perfect-scrollbar": "2.0.0",
|
||||
"vuedraggable": "4.1.0",
|
||||
"vuetify": "3.12.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@jest/globals": "^30.2.0",
|
||||
"@tsconfig/node24": "^24.0.4",
|
||||
"@types/cbor-js": "^0.1.1",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/git-rev-sync": "^2.0.2",
|
||||
"@types/jalaali-js": "^1.2.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^24.1.0",
|
||||
"@types/ua-parser-js": "^0.7.39",
|
||||
"@vitejs/plugin-vue": "^6.0.4",
|
||||
"@vue/eslint-config-typescript": "^14.6.0",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-vue": "^10.7.0",
|
||||
"git-rev-sync": "^3.0.2",
|
||||
"jest": "^30.2.0",
|
||||
"postcss-preset-env": "^11.1.3",
|
||||
"sass": "^1.97.3",
|
||||
"ts-jest": "^29.4.6",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-checker": "^0.12.0",
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
"vite-plugin-vuetify": "^2.1.3",
|
||||
"vue-tsc": "^3.2.4"
|
||||
"@jest/globals": "30.3.0",
|
||||
"@tsconfig/node24": "24.0.4",
|
||||
"@types/cbor-js": "0.1.1",
|
||||
"@types/crypto-js": "4.2.2",
|
||||
"@types/git-rev-sync": "2.0.2",
|
||||
"@types/jalaali-js": "1.2.0",
|
||||
"@types/node": "25.6.0",
|
||||
"@types/ua-parser-js": "0.7.39",
|
||||
"@vitejs/plugin-vue": "6.0.6",
|
||||
"@vue/eslint-config-typescript": "14.7.0",
|
||||
"@vue/tsconfig": "0.9.1",
|
||||
"cross-env": "10.1.0",
|
||||
"eslint": "10.2.1",
|
||||
"eslint-plugin-vue": "10.9.0",
|
||||
"git-rev-sync": "3.0.2",
|
||||
"postcss-preset-env": "11.2.1",
|
||||
"sass": "1.99.0",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "6.0.3",
|
||||
"vite": "7.3.2",
|
||||
"vite-plugin-checker": "0.13.0",
|
||||
"vite-plugin-pwa": "1.2.0",
|
||||
"vite-plugin-vuetify": "2.1.3",
|
||||
"vitest": "4.1.5",
|
||||
"vue-tsc": "3.2.7"
|
||||
},
|
||||
"browserslist": [
|
||||
"last 5 Chrome versions",
|
||||
|
||||
+104
-13
@@ -19,6 +19,7 @@ type AccountsApi struct {
|
||||
ApiUsingConfig
|
||||
ApiUsingDuplicateChecker
|
||||
accounts *services.AccountService
|
||||
users *services.UserService
|
||||
}
|
||||
|
||||
// Initialize an account api singleton instance
|
||||
@@ -34,6 +35,7 @@ var (
|
||||
container: duplicatechecker.Container,
|
||||
},
|
||||
accounts: services.Accounts,
|
||||
users: services.Users,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -333,6 +335,16 @@ func (a *AccountsApi) AccountModifyHandler(c *core.WebContext) (any, *errs.Error
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil {
|
||||
if !errs.IsCustomError(err) {
|
||||
log.Errorf(c, "[accounts.AccountModifyHandler] failed to get user, because %s", err.Error())
|
||||
}
|
||||
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
accountAndSubAccounts, err := a.accounts.GetAccountAndSubAccountsByAccountId(c, uid, accountModifyReq.Id)
|
||||
|
||||
if err != nil {
|
||||
@@ -434,7 +446,11 @@ func (a *AccountsApi) AccountModifyHandler(c *core.WebContext) (any, *errs.Error
|
||||
var toAddAccountBalanceTimes []int64
|
||||
var toDeleteAccountIds []int64
|
||||
|
||||
toUpdateAccount := a.getToUpdateAccount(uid, &accountModifyReq, mainAccount, false)
|
||||
toUpdateAccount, err := a.getToUpdateAccount(user, &accountModifyReq, mainAccount, false)
|
||||
|
||||
if err != nil {
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
if toUpdateAccount != nil {
|
||||
if toUpdateAccount.Category != mainAccount.Category {
|
||||
@@ -483,7 +499,11 @@ func (a *AccountsApi) AccountModifyHandler(c *core.WebContext) (any, *errs.Error
|
||||
toAddAccountBalanceTimes = append(toAddAccountBalanceTimes, 0)
|
||||
}
|
||||
} else {
|
||||
toUpdateSubAccount := a.getToUpdateAccount(uid, subAccountReq, accountMap[subAccountReq.Id], true)
|
||||
toUpdateSubAccount, err := a.getToUpdateAccount(user, subAccountReq, accountMap[subAccountReq.Id], true)
|
||||
|
||||
if err != nil {
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
if toUpdateSubAccount != nil {
|
||||
anythingUpdate = true
|
||||
@@ -607,6 +627,69 @@ func (a *AccountsApi) AccountModifyHandler(c *core.WebContext) (any, *errs.Error
|
||||
return accountResp, nil
|
||||
}
|
||||
|
||||
// AccountUpdateLastReconciledTimeHandler updates last reconciled time of an existed account by request parameters for current user
|
||||
func (a *AccountsApi) AccountUpdateLastReconciledTimeHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var accountUpdateReq models.AccountUpdateLastReconciledTimeRequest
|
||||
err := c.ShouldBindJSON(&accountUpdateReq)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[accounts.AccountUpdateLastReconciledTimeHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
if accountUpdateReq.Id <= 0 {
|
||||
return nil, errs.ErrAccountIdInvalid
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil {
|
||||
if !errs.IsCustomError(err) {
|
||||
log.Errorf(c, "[accounts.AccountUpdateLastReconciledTimeHandler] failed to get user, because %s", err.Error())
|
||||
}
|
||||
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
if !user.UseLastReconciledTime {
|
||||
return nil, errs.ErrLastReconciledTimeIsNotEnabled
|
||||
}
|
||||
|
||||
account, err := a.accounts.GetAccountByAccountId(c, uid, accountUpdateReq.Id)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[accounts.AccountUpdateLastReconciledTimeHandler] failed to get account \"id:%d\" for user \"uid:%d\", because %s", accountUpdateReq.Id, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
if account.Type == models.ACCOUNT_TYPE_MULTI_SUB_ACCOUNTS {
|
||||
return nil, errs.ErrParentAccountCannotSetLastReconciledTime
|
||||
}
|
||||
|
||||
if account.Extend == nil {
|
||||
account.Extend = &models.AccountExtend{}
|
||||
}
|
||||
|
||||
if account.Extend.LastReconciledTime != nil && accountUpdateReq.LastReconciledTime < *account.Extend.LastReconciledTime {
|
||||
return nil, errs.ErrCannotSetLastReconciledTimeBeforeCurrent
|
||||
} else if account.Extend.LastReconciledTime != nil && accountUpdateReq.LastReconciledTime == *account.Extend.LastReconciledTime {
|
||||
return nil, errs.ErrNothingWillBeUpdated
|
||||
}
|
||||
|
||||
account.Extend.LastReconciledTime = &accountUpdateReq.LastReconciledTime
|
||||
|
||||
err = a.accounts.UpdateAccountExtend(c, uid, account)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[accounts.AccountUpdateLastReconciledTimeHandler] failed to update last reconciled time for account \"id:%d\" of user \"uid:%d\", because %s", account.AccountId, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
log.Infof(c, "[accounts.AccountUpdateLastReconciledTimeHandler] user \"uid:%d\" has updated last reconciled time \"%d\" for account \"id:%d\"", uid, account.Extend.LastReconciledTime, account.AccountId)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// AccountHideHandler hides an existed account by request parameters for current user
|
||||
func (a *AccountsApi) AccountHideHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var accountHideReq models.AccountHideRequest
|
||||
@@ -764,8 +847,9 @@ func (a *AccountsApi) createSubAccountModels(uid int64, accountCreateReq *models
|
||||
return childrenAccounts, childrenAccountBalanceTimes
|
||||
}
|
||||
|
||||
func (a *AccountsApi) getToUpdateAccount(uid int64, accountModifyReq *models.AccountModifyRequest, oldAccount *models.Account, isSubAccount bool) *models.Account {
|
||||
func (a *AccountsApi) getToUpdateAccount(user *models.User, accountModifyReq *models.AccountModifyRequest, oldAccount *models.Account, isSubAccount bool) (*models.Account, error) {
|
||||
newAccountExtend := &models.AccountExtend{}
|
||||
newAccountExtend.LastReconciledTime = accountModifyReq.LastReconciledTime
|
||||
|
||||
if !isSubAccount && accountModifyReq.Category == models.ACCOUNT_CATEGORY_CREDIT_CARD {
|
||||
newAccountExtend.CreditCardStatementDate = &accountModifyReq.CreditCardStatementDate
|
||||
@@ -773,7 +857,7 @@ func (a *AccountsApi) getToUpdateAccount(uid int64, accountModifyReq *models.Acc
|
||||
|
||||
newAccount := &models.Account{
|
||||
AccountId: oldAccount.AccountId,
|
||||
Uid: uid,
|
||||
Uid: user.Uid,
|
||||
Name: accountModifyReq.Name,
|
||||
DisplayOrder: oldAccount.DisplayOrder,
|
||||
Category: accountModifyReq.Category,
|
||||
@@ -790,21 +874,28 @@ func (a *AccountsApi) getToUpdateAccount(uid int64, accountModifyReq *models.Acc
|
||||
newAccount.Color != oldAccount.Color ||
|
||||
newAccount.Comment != oldAccount.Comment ||
|
||||
newAccount.Hidden != oldAccount.Hidden {
|
||||
return newAccount
|
||||
}
|
||||
|
||||
if (newAccount.Extend != nil && oldAccount.Extend == nil) ||
|
||||
(newAccount.Extend == nil && oldAccount.Extend != nil) {
|
||||
return newAccount
|
||||
return newAccount, nil
|
||||
}
|
||||
|
||||
oldAccountExtend := oldAccount.Extend
|
||||
|
||||
if newAccountExtend.CreditCardStatementDate != oldAccountExtend.CreditCardStatementDate {
|
||||
return newAccount
|
||||
if (newAccountExtend.LastReconciledTime != nil && (oldAccountExtend == nil || oldAccountExtend.LastReconciledTime == nil)) ||
|
||||
(newAccountExtend.LastReconciledTime == nil && oldAccountExtend != nil && oldAccountExtend.LastReconciledTime != nil) ||
|
||||
(newAccountExtend.LastReconciledTime != nil && oldAccountExtend != nil && oldAccountExtend.LastReconciledTime != nil && *newAccountExtend.LastReconciledTime != *oldAccountExtend.LastReconciledTime) {
|
||||
if !user.UseLastReconciledTime {
|
||||
return nil, errs.ErrLastReconciledTimeIsNotEnabled
|
||||
}
|
||||
|
||||
return newAccount, nil
|
||||
}
|
||||
|
||||
return nil
|
||||
if (newAccountExtend.CreditCardStatementDate != nil && (oldAccountExtend == nil || oldAccountExtend.CreditCardStatementDate == nil)) ||
|
||||
(newAccountExtend.CreditCardStatementDate == nil && oldAccountExtend != nil && oldAccountExtend.CreditCardStatementDate != nil) ||
|
||||
(newAccountExtend.CreditCardStatementDate != nil && oldAccountExtend != nil && oldAccountExtend.CreditCardStatementDate != nil && *newAccountExtend.CreditCardStatementDate != *oldAccountExtend.CreditCardStatementDate) {
|
||||
return newAccount, nil
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (a *AccountsApi) getToDeleteSubAccountIds(accountModifyReq *models.AccountModifyRequest, mainAccount *models.Account, accountAndSubAccounts []*models.Account) []int64 {
|
||||
|
||||
@@ -419,7 +419,7 @@ func (a *DataManagementsApi) getExportedFileContent(c *core.WebContext, fileType
|
||||
minTransactionTime = utils.GetMinTransactionTimeFromUnixTime(exportTransactionDataReq.MinTime)
|
||||
}
|
||||
|
||||
allTransactions, err := a.transactions.GetAllSpecifiedTransactions(c, uid, maxTransactionTime, minTransactionTime, exportTransactionDataReq.Type, allCategoryIds, allAccountIds, tagFilters, noTags, exportTransactionDataReq.AmountFilter, exportTransactionDataReq.Keyword, pageCountForDataExport, true)
|
||||
allTransactions, err := a.transactions.GetAllSpecifiedTransactions(c, uid, maxTransactionTime, minTransactionTime, exportTransactionDataReq.Type, allCategoryIds, allAccountIds, tagFilters, noTags, exportTransactionDataReq.AmountFilter, exportTransactionDataReq.Keyword, false, pageCountForDataExport, true)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[data_managements.getExportedFileContent] failed to all transactions user \"uid:%d\", because %s", uid, err.Error())
|
||||
|
||||
@@ -166,7 +166,7 @@ func (a *ForgetPasswordsApi) UserResetPasswordHandler(c *core.WebContext) (any,
|
||||
Password: request.Password,
|
||||
}
|
||||
|
||||
_, _, err = a.users.UpdateUser(c, userNew, false)
|
||||
_, _, err = a.users.UpdateUser(c, userNew, false, false)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[forget_passwords.UserResetPasswordHandler] failed to update user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
|
||||
+785
-14
@@ -25,6 +25,7 @@ import (
|
||||
)
|
||||
|
||||
const pageCountForAccountStatement = 1000
|
||||
const pageCountForMovingAccountTransactions = 1000
|
||||
|
||||
// TransactionsApi represents transaction api
|
||||
type TransactionsApi struct {
|
||||
@@ -97,7 +98,7 @@ func (a *TransactionsApi) TransactionCountHandler(c *core.WebContext) (any, *err
|
||||
}
|
||||
}
|
||||
|
||||
totalCount, err := a.transactions.GetTransactionCount(c, uid, transactionCountReq.MaxTime, transactionCountReq.MinTime, transactionCountReq.Type, allCategoryIds, allAccountIds, tagFilters, noTags, transactionCountReq.AmountFilter, transactionCountReq.Keyword)
|
||||
totalCount, err := a.transactions.GetTransactionCount(c, uid, transactionCountReq.MaxTime, transactionCountReq.MinTime, transactionCountReq.Type, allCategoryIds, allAccountIds, tagFilters, noTags, transactionCountReq.AmountFilter, transactionCountReq.Keyword, transactionCountReq.MustHavePictures)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionCountHandler] failed to get transaction count for user \"uid:%d\", because %s", uid, err.Error())
|
||||
@@ -168,7 +169,7 @@ func (a *TransactionsApi) TransactionListHandler(c *core.WebContext) (any, *errs
|
||||
var totalCount int64
|
||||
|
||||
if transactionListReq.WithCount {
|
||||
totalCount, err = a.transactions.GetTransactionCount(c, uid, transactionListReq.MaxTime, transactionListReq.MinTime, transactionListReq.Type, allCategoryIds, allAccountIds, tagFilters, noTags, transactionListReq.AmountFilter, transactionListReq.Keyword)
|
||||
totalCount, err = a.transactions.GetTransactionCount(c, uid, transactionListReq.MaxTime, transactionListReq.MinTime, transactionListReq.Type, allCategoryIds, allAccountIds, tagFilters, noTags, transactionListReq.AmountFilter, transactionListReq.Keyword, transactionListReq.MustHavePictures)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionListHandler] failed to get transaction count for user \"uid:%d\", because %s", uid, err.Error())
|
||||
@@ -176,7 +177,7 @@ func (a *TransactionsApi) TransactionListHandler(c *core.WebContext) (any, *errs
|
||||
}
|
||||
}
|
||||
|
||||
transactions, err := a.transactions.GetTransactionsByMaxTime(c, uid, transactionListReq.MaxTime, transactionListReq.MinTime, transactionListReq.Type, allCategoryIds, allAccountIds, tagFilters, noTags, transactionListReq.AmountFilter, transactionListReq.Keyword, transactionListReq.Page, transactionListReq.Count, true, true)
|
||||
transactions, err := a.transactions.GetTransactionsByMaxTime(c, uid, transactionListReq.MaxTime, transactionListReq.MinTime, transactionListReq.Type, allCategoryIds, allAccountIds, tagFilters, noTags, transactionListReq.AmountFilter, transactionListReq.Keyword, transactionListReq.MustHavePictures, transactionListReq.Page, transactionListReq.Count, true, true)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionListHandler] failed to get transactions earlier than \"%d\" for user \"uid:%d\", because %s", transactionListReq.MaxTime, uid, err.Error())
|
||||
@@ -276,7 +277,7 @@ func (a *TransactionsApi) TransactionMonthListHandler(c *core.WebContext) (any,
|
||||
}
|
||||
}
|
||||
|
||||
transactions, err := a.transactions.GetTransactionsInMonthByPage(c, uid, transactionListReq.Year, transactionListReq.Month, transactionListReq.Type, allCategoryIds, allAccountIds, tagFilters, noTags, transactionListReq.AmountFilter, transactionListReq.Keyword)
|
||||
transactions, err := a.transactions.GetTransactionsInMonthByPage(c, uid, transactionListReq.Year, transactionListReq.Month, transactionListReq.Type, allCategoryIds, allAccountIds, tagFilters, noTags, transactionListReq.AmountFilter, transactionListReq.Keyword, transactionListReq.MustHavePictures)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionMonthListHandler] failed to get transactions in month \"%d-%d\" for user \"uid:%d\", because %s", transactionListReq.Year, transactionListReq.Month, uid, err.Error())
|
||||
@@ -371,7 +372,7 @@ func (a *TransactionsApi) TransactionListAllHandler(c *core.WebContext) (any, *e
|
||||
minTransactionTime = utils.GetMinTransactionTimeFromUnixTime(transactionAllListReq.StartTime)
|
||||
}
|
||||
|
||||
allTransactions, err := a.transactions.GetAllSpecifiedTransactions(c, uid, maxTransactionTime, minTransactionTime, transactionAllListReq.Type, allCategoryIds, allAccountIds, tagFilters, noTags, transactionAllListReq.AmountFilter, transactionAllListReq.Keyword, pageCountForDataExport, true)
|
||||
allTransactions, err := a.transactions.GetAllSpecifiedTransactions(c, uid, maxTransactionTime, minTransactionTime, transactionAllListReq.Type, allCategoryIds, allAccountIds, tagFilters, noTags, transactionAllListReq.AmountFilter, transactionAllListReq.Keyword, transactionAllListReq.MustHavePictures, pageCountForDataExport, true)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionListAllHandler] failed to get all transactions for user \"uid:%d\", because %s", uid, err.Error())
|
||||
@@ -1075,7 +1076,15 @@ func (a *TransactionsApi) TransactionCreateHandler(c *core.WebContext) (any, *er
|
||||
}
|
||||
|
||||
transaction := a.createNewTransactionModel(uid, &transactionCreateReq, c.ClientIP())
|
||||
transactionEditable := user.CanEditTransactionByTransactionTime(transaction.TransactionTime, clientTimezone)
|
||||
|
||||
allUsedAccounts, err := a.getTransactionUsedAccounts(c, uid, []*models.Transaction{transaction})
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionCreateHandler] failed to get transaction used accounts for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
transactionEditable := user.CanEditTransactionByTransactionTime(transaction.TransactionTime, clientTimezone, allUsedAccounts[transaction.AccountId], allUsedAccounts[transaction.RelatedAccountId])
|
||||
|
||||
if !transactionEditable {
|
||||
return nil, errs.ErrCannotCreateTransactionWithThisTransactionTime
|
||||
@@ -1268,8 +1277,15 @@ func (a *TransactionsApi) TransactionModifyHandler(c *core.WebContext) (any, *er
|
||||
return nil, errs.ErrNothingWillBeUpdated
|
||||
}
|
||||
|
||||
transactionEditable := user.CanEditTransactionByTransactionTime(transaction.TransactionTime, clientTimezone)
|
||||
newTransactionEditable := user.CanEditTransactionByTransactionTime(newTransaction.TransactionTime, clientTimezone)
|
||||
allUsedAccounts, err := a.getTransactionUsedAccounts(c, uid, []*models.Transaction{transaction, newTransaction})
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionModifyHandler] failed to get transaction used accounts for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
transactionEditable := user.CanEditTransactionByTransactionTime(transaction.TransactionTime, clientTimezone, allUsedAccounts[transaction.AccountId], allUsedAccounts[transaction.RelatedAccountId])
|
||||
newTransactionEditable := user.CanEditTransactionByTransactionTime(newTransaction.TransactionTime, clientTimezone, allUsedAccounts[newTransaction.AccountId], allUsedAccounts[newTransaction.RelatedAccountId])
|
||||
|
||||
if !transactionEditable || !newTransactionEditable {
|
||||
return nil, errs.ErrCannotModifyTransactionWithThisTransactionTime
|
||||
@@ -1338,6 +1354,579 @@ func (a *TransactionsApi) TransactionModifyHandler(c *core.WebContext) (any, *er
|
||||
return newTransactionResp, nil
|
||||
}
|
||||
|
||||
// TransactionBatchUpdateCategoriesHandler batch updates categories of transactions by request parameters for current user
|
||||
func (a *TransactionsApi) TransactionBatchUpdateCategoriesHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var transactionBatchUpdateReq models.TransactionBatchUpdateCategoryRequest
|
||||
err := c.ShouldBindJSON(&transactionBatchUpdateReq)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[transactions.TransactionBatchUpdateCategoriesHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
clientTimezone, err := c.GetClientTimezone()
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[transactions.TransactionBatchUpdateCategoriesHandler] cannot get client timezone, because %s", err.Error())
|
||||
return nil, errs.ErrClientTimezoneOffsetInvalid
|
||||
}
|
||||
|
||||
transactionIds, err := utils.StringArrayToInt64Array(transactionBatchUpdateReq.TransactionIds)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[transactions.TransactionBatchUpdateCategoriesHandler] parse transaction ids failed, because %s", err.Error())
|
||||
return nil, errs.ErrTransactionIdInvalid
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil {
|
||||
if !errs.IsCustomError(err) {
|
||||
log.Errorf(c, "[transactions.TransactionBatchUpdateCategoriesHandler] failed to get user, because %s", err.Error())
|
||||
}
|
||||
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
category, err := a.transactionCategories.GetCategoryByCategoryId(c, uid, transactionBatchUpdateReq.CategoryId)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionBatchUpdateCategoriesHandler] failed to get category \"id:%d\" for user \"uid:%d\", because %s", transactionBatchUpdateReq.CategoryId, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
if category.ParentCategoryId == models.LevelOneTransactionCategoryParentId {
|
||||
log.Warnf(c, "[transactions.TransactionBatchUpdateCategoriesHandler] transaction category \"id:%d\" is not a sub category", category.CategoryId)
|
||||
return nil, errs.ErrCannotUsePrimaryCategoryForTransaction
|
||||
}
|
||||
|
||||
var expectedTransactionType models.TransactionDbType
|
||||
|
||||
if category.Type == models.CATEGORY_TYPE_EXPENSE {
|
||||
expectedTransactionType = models.TRANSACTION_DB_TYPE_EXPENSE
|
||||
} else if category.Type == models.CATEGORY_TYPE_INCOME {
|
||||
expectedTransactionType = models.TRANSACTION_DB_TYPE_INCOME
|
||||
} else if category.Type == models.CATEGORY_TYPE_TRANSFER {
|
||||
expectedTransactionType = models.TRANSACTION_DB_TYPE_TRANSFER_OUT
|
||||
}
|
||||
|
||||
transactions, err := a.transactions.GetTransactionsByTransactionIds(c, uid, transactionIds)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionBatchUpdateCategoriesHandler] failed to get transactions for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
allUsedAccounts, err := a.getTransactionUsedAccounts(c, uid, transactions)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionBatchUpdateCategoriesHandler] failed to get transaction used accounts for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
allTransactionIds := make([]int64, 0, len(transactions))
|
||||
|
||||
for i := 0; i < len(transactions); i++ {
|
||||
transaction := transactions[i]
|
||||
|
||||
if transaction.Type != expectedTransactionType {
|
||||
log.Warnf(c, "[transactions.TransactionBatchUpdateCategoriesHandler] transaction \"id:%d\" type is not expected type \"%d\" for user \"uid:%d\"", transaction.TransactionId, expectedTransactionType, uid)
|
||||
return nil, errs.ErrTransactionTypeInvalid
|
||||
}
|
||||
|
||||
transactionEditable := user.CanEditTransactionByTransactionTime(transaction.TransactionTime, clientTimezone, allUsedAccounts[transaction.AccountId], allUsedAccounts[transaction.RelatedAccountId])
|
||||
|
||||
if !transactionEditable {
|
||||
log.Warnf(c, "[transactions.TransactionBatchUpdateCategoriesHandler] transaction \"id:%d\" is not editable for user \"uid:%d\"", transaction.TransactionId, uid)
|
||||
return nil, errs.ErrCannotModifyTransactionWithThisTransactionTime
|
||||
}
|
||||
|
||||
allTransactionIds = append(allTransactionIds, transaction.TransactionId)
|
||||
|
||||
if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT {
|
||||
allTransactionIds = append(allTransactionIds, transaction.RelatedId)
|
||||
}
|
||||
}
|
||||
|
||||
err = a.transactions.BatchUpdateTransactionsCategory(c, uid, allTransactionIds, category.CategoryId)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionBatchUpdateCategoriesHandler] failed to batch update transactions category for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
log.Infof(c, "[transactions.TransactionBatchUpdateCategoriesHandler] user \"uid:%d\" has batch updated category of %d transactions successfully", uid, len(transactionBatchUpdateReq.TransactionIds))
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// TransactionBatchUpdateAccountsHandler batch updates accounts of transactions by request parameters for current user
|
||||
func (a *TransactionsApi) TransactionBatchUpdateAccountsHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var transactionBatchUpdateReq models.TransactionBatchUpdateAccountRequest
|
||||
err := c.ShouldBindJSON(&transactionBatchUpdateReq)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[transactions.TransactionBatchUpdateAccountsHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
clientTimezone, err := c.GetClientTimezone()
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[transactions.TransactionBatchUpdateAccountsHandler] cannot get client timezone, because %s", err.Error())
|
||||
return nil, errs.ErrClientTimezoneOffsetInvalid
|
||||
}
|
||||
|
||||
transactionIds, err := utils.StringArrayToInt64Array(transactionBatchUpdateReq.TransactionIds)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[transactions.TransactionBatchUpdateAccountsHandler] parse transaction ids failed, because %s", err.Error())
|
||||
return nil, errs.ErrTransactionIdInvalid
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil {
|
||||
if !errs.IsCustomError(err) {
|
||||
log.Errorf(c, "[transactions.TransactionBatchUpdateAccountsHandler] failed to get user, because %s", err.Error())
|
||||
}
|
||||
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
allAccounts, err := a.accounts.GetAllAccountsByUid(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionBatchUpdateAccountsHandler] failed to get all accounts for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
accountMap := a.accounts.GetAccountMapByList(allAccounts)
|
||||
account, exists := accountMap[transactionBatchUpdateReq.AccountId]
|
||||
|
||||
if !exists || account == nil {
|
||||
log.Warnf(c, "[transactions.TransactionBatchUpdateAccountsHandler] account \"id:%d\" does not exist for user \"uid:%d\"", transactionBatchUpdateReq.AccountId, uid)
|
||||
return nil, errs.ErrAccountNotFound
|
||||
}
|
||||
|
||||
if account.Hidden {
|
||||
log.Warnf(c, "[transactions.TransactionBatchUpdateAccountsHandler] account \"id:%d\" is hidden for user \"uid:%d\"", account.AccountId, uid)
|
||||
return nil, errs.ErrCannotMoveTransactionFromOrToHiddenAccount
|
||||
}
|
||||
|
||||
if account.Type == models.ACCOUNT_TYPE_MULTI_SUB_ACCOUNTS {
|
||||
log.Warnf(c, "[transactions.TransactionBatchUpdateAccountsHandler] account \"id:%d\" is a parent account, cannot be used for transaction of user \"uid:%d\"", account.AccountId, uid)
|
||||
return nil, errs.ErrCannotModifyTransactionInParentAccount
|
||||
}
|
||||
|
||||
transactions, err := a.transactions.GetTransactionsByTransactionIds(c, uid, transactionIds)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionBatchUpdateAccountsHandler] failed to get transactions for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
for i := 0; i < len(transactions); i++ {
|
||||
transaction := transactions[i]
|
||||
|
||||
if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN {
|
||||
log.Warnf(c, "[transactions.TransactionBatchUpdateAccountsHandler] cannot modify transaction \"id:%d\" for user \"uid:%d\", because transaction type is transfer in", transaction.TransactionId, uid)
|
||||
return nil, errs.ErrTransactionTypeInvalid
|
||||
}
|
||||
|
||||
if transactionBatchUpdateReq.IsDestinationAccount && transaction.Type != models.TRANSACTION_DB_TYPE_TRANSFER_OUT {
|
||||
log.Warnf(c, "[transactions.TransactionBatchUpdateAccountsHandler] cannot update destination account of non-transfer transaction \"id:%d\" for user \"uid:%d\"", transaction.TransactionId, uid)
|
||||
return nil, errs.ErrTransactionDestinationAccountCannotBeSet
|
||||
}
|
||||
|
||||
if !transactionBatchUpdateReq.IsDestinationAccount && account.AccountId == transaction.RelatedAccountId {
|
||||
log.Warnf(c, "[transactions.TransactionBatchUpdateAccountsHandler] cannot update account to same destination account of transaction \"id:%d\" for user \"uid:%d\"", transaction.TransactionId, uid)
|
||||
return nil, errs.ErrTransactionSourceAndDestinationIdCannotBeEqual
|
||||
} else if transactionBatchUpdateReq.IsDestinationAccount && account.AccountId == transaction.AccountId {
|
||||
log.Warnf(c, "[transactions.TransactionBatchUpdateAccountsHandler] cannot update destination account to same source account of transaction \"id:%d\" for user \"uid:%d\"", transaction.TransactionId, uid)
|
||||
return nil, errs.ErrTransactionSourceAndDestinationIdCannotBeEqual
|
||||
}
|
||||
|
||||
var oldAccount *models.Account
|
||||
|
||||
if !transactionBatchUpdateReq.IsDestinationAccount {
|
||||
oldAccount = accountMap[transaction.AccountId]
|
||||
} else if transactionBatchUpdateReq.IsDestinationAccount {
|
||||
oldAccount = accountMap[transaction.RelatedAccountId]
|
||||
}
|
||||
|
||||
if oldAccount == nil {
|
||||
log.Warnf(c, "[transactions.TransactionBatchUpdateAccountsHandler] the original account of transaction \"id:%d\" does not exist for user \"uid:%d\"", transaction.TransactionId, uid)
|
||||
return nil, errs.ErrAccountNotFound
|
||||
}
|
||||
|
||||
if oldAccount.Hidden {
|
||||
log.Warnf(c, "[transactions.TransactionBatchUpdateAccountsHandler] the original account of transaction \"id:%d\" is hidden for user \"uid:%d\"", transaction.TransactionId, uid)
|
||||
return nil, errs.ErrCannotMoveTransactionFromOrToHiddenAccount
|
||||
}
|
||||
|
||||
if oldAccount.Currency != account.Currency {
|
||||
log.Warnf(c, "[transactions.TransactionBatchUpdateAccountsHandler] cannot update account of transaction \"id:%d\", because the original account currency \"%s\" is different from updated account currency \"%s\" for user \"uid:%d\"", transaction.TransactionId, oldAccount.Currency, account.Currency, uid)
|
||||
return nil, errs.ErrCannotMoveTransactionBetweenAccountsWithDifferentCurrencies
|
||||
}
|
||||
|
||||
newSourceAccount := accountMap[transaction.AccountId]
|
||||
newDestinationAccount := accountMap[transaction.RelatedAccountId]
|
||||
|
||||
if !transactionBatchUpdateReq.IsDestinationAccount && transaction.AccountId != account.AccountId {
|
||||
newSourceAccount = account
|
||||
} else if transactionBatchUpdateReq.IsDestinationAccount && transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT && transaction.RelatedAccountId != account.AccountId {
|
||||
newDestinationAccount = account
|
||||
}
|
||||
|
||||
transactionEditable := user.CanEditTransactionByTransactionTime(transaction.TransactionTime, clientTimezone, accountMap[transaction.AccountId], accountMap[transaction.RelatedAccountId])
|
||||
newTransactionEditable := user.CanEditTransactionByTransactionTime(transaction.TransactionTime, clientTimezone, newSourceAccount, newDestinationAccount)
|
||||
|
||||
if !transactionEditable || !newTransactionEditable {
|
||||
log.Warnf(c, "[transactions.TransactionBatchUpdateAccountsHandler] transaction \"id:%d\" is not editable for user \"uid:%d\"", transaction.TransactionId, uid)
|
||||
return nil, errs.ErrCannotModifyTransactionWithThisTransactionTime
|
||||
}
|
||||
}
|
||||
|
||||
updatedCount := 0
|
||||
|
||||
for i := 0; i < len(transactions); i++ {
|
||||
transaction := transactions[i]
|
||||
|
||||
if !transactionBatchUpdateReq.IsDestinationAccount && transaction.AccountId != account.AccountId {
|
||||
transaction.AccountId = account.AccountId
|
||||
} else if transactionBatchUpdateReq.IsDestinationAccount && transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT && transaction.RelatedAccountId != account.AccountId {
|
||||
transaction.RelatedAccountId = account.AccountId
|
||||
} else {
|
||||
log.Warnf(c, "[transactions.TransactionBatchUpdateAccountsHandler] skip updating transaction \"id:%d\", because the original account is same as updated account for user \"uid:%d\"", transaction.TransactionId, uid)
|
||||
continue
|
||||
}
|
||||
|
||||
err = a.transactions.ModifyTransaction(c, transaction, 0, nil, nil, nil, nil)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionBatchUpdateAccountsHandler] failed to update transaction \"id:%d\" for user \"uid:%d\", because %s", transaction.TransactionId, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
updatedCount++
|
||||
}
|
||||
|
||||
if updatedCount < 1 {
|
||||
return nil, errs.ErrNothingWillBeUpdated
|
||||
}
|
||||
|
||||
log.Infof(c, "[transactions.TransactionBatchUpdateAccountsHandler] user \"uid:%d\" has batch updated account of %d transactions successfully", uid, updatedCount)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// TransactionBatchAddTagsHandler batch add tags to transactions by request parameters for current user
|
||||
func (a *TransactionsApi) TransactionBatchAddTagsHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var transactionBatchUpdateReq models.TransactionBatchAddTagsRequest
|
||||
err := c.ShouldBindJSON(&transactionBatchUpdateReq)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[transactions.TransactionBatchAddTagsHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
clientTimezone, err := c.GetClientTimezone()
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[transactions.TransactionBatchAddTagsHandler] cannot get client timezone, because %s", err.Error())
|
||||
return nil, errs.ErrClientTimezoneOffsetInvalid
|
||||
}
|
||||
|
||||
transactionIds, err := utils.StringArrayToInt64Array(transactionBatchUpdateReq.TransactionIds)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[transactions.TransactionBatchAddTagsHandler] parse transaction ids failed, because %s", err.Error())
|
||||
return nil, errs.ErrTransactionIdInvalid
|
||||
}
|
||||
|
||||
tagIds, err := utils.StringArrayToInt64Array(transactionBatchUpdateReq.TagIds)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[transactions.TransactionBatchAddTagsHandler] parse tag ids failed, because %s", err.Error())
|
||||
return nil, errs.ErrTransactionTagIdInvalid
|
||||
}
|
||||
|
||||
tagIds = utils.ToUniqueInt64Slice(tagIds)
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil {
|
||||
if !errs.IsCustomError(err) {
|
||||
log.Errorf(c, "[transactions.TransactionBatchAddTagsHandler] failed to get user, because %s", err.Error())
|
||||
}
|
||||
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
tags, err := a.transactionTags.GetTagsByTagIds(c, uid, tagIds)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionBatchAddTagsHandler] failed to get tags for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
if len(tags) != len(tagIds) {
|
||||
log.Warnf(c, "[transactions.TransactionBatchAddTagsHandler] some tags do not exist for user \"uid:%d\"", uid)
|
||||
return nil, errs.ErrTransactionTagNotFound
|
||||
}
|
||||
|
||||
transactions, err := a.transactions.GetTransactionsByTransactionIds(c, uid, transactionIds)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionBatchAddTagsHandler] failed to get transactions for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
allUsedAccounts, err := a.getTransactionUsedAccounts(c, uid, transactions)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionBatchAddTagsHandler] failed to get transaction used accounts for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
transactionTagIndexes, err := a.transactionTags.GetAllTagIdsOfTransactions(c, uid, transactionIds)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionBatchAddTagsHandler] failed to get transactions tag indexes for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
allNewTransactionTagIndexes := make(map[int64][]int64, len(transactions))
|
||||
|
||||
for i := 0; i < len(transactions); i++ {
|
||||
transaction := transactions[i]
|
||||
|
||||
if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN {
|
||||
log.Warnf(c, "[transactions.TransactionBatchAddTagsHandler] cannot modify transaction \"id:%d\" for user \"uid:%d\", because transaction type is transfer in", transaction.TransactionId, uid)
|
||||
return nil, errs.ErrTransactionTypeInvalid
|
||||
}
|
||||
|
||||
transactionEditable := user.CanEditTransactionByTransactionTime(transaction.TransactionTime, clientTimezone, allUsedAccounts[transaction.AccountId], allUsedAccounts[transaction.RelatedAccountId])
|
||||
|
||||
if !transactionEditable {
|
||||
log.Warnf(c, "[transactions.TransactionBatchAddTagsHandler] transaction \"id:%d\" is not editable for user \"uid:%d\"", transaction.TransactionId, uid)
|
||||
return nil, errs.ErrCannotModifyTransactionWithThisTransactionTime
|
||||
}
|
||||
|
||||
existedTagIds := transactionTagIndexes[transaction.TransactionId]
|
||||
existedTagIdsMap := make(map[int64]bool, len(existedTagIds))
|
||||
|
||||
for j := 0; j < len(existedTagIds); j++ {
|
||||
existedTagIdsMap[existedTagIds[j]] = true
|
||||
}
|
||||
|
||||
var newTagIds []int64
|
||||
|
||||
for j := 0; j < len(tagIds); j++ {
|
||||
tagId := tagIds[j]
|
||||
|
||||
if _, exists := existedTagIdsMap[tagId]; !exists {
|
||||
newTagIds = append(newTagIds, tagId)
|
||||
}
|
||||
}
|
||||
|
||||
allNewTransactionTagIndexes[transaction.TransactionId] = newTagIds
|
||||
}
|
||||
|
||||
err = a.transactions.BatchAddTagsToTransactions(c, uid, transactions, allNewTransactionTagIndexes)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionBatchAddTagsHandler] failed to batch update transactions tags for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
log.Infof(c, "[transactions.TransactionBatchAddTagsHandler] user \"uid:%d\" has batch updated tag of %d transactions successfully", uid, len(allNewTransactionTagIndexes))
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// TransactionBatchRemoveTagsHandler batch remove tags from transactions by request parameters for current user
|
||||
func (a *TransactionsApi) TransactionBatchRemoveTagsHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var transactionBatchUpdateReq models.TransactionBatchRemoveTagsRequest
|
||||
err := c.ShouldBindJSON(&transactionBatchUpdateReq)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[transactions.TransactionBatchRemoveTagsHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
clientTimezone, err := c.GetClientTimezone()
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[transactions.TransactionBatchRemoveTagsHandler] cannot get client timezone, because %s", err.Error())
|
||||
return nil, errs.ErrClientTimezoneOffsetInvalid
|
||||
}
|
||||
|
||||
transactionIds, err := utils.StringArrayToInt64Array(transactionBatchUpdateReq.TransactionIds)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[transactions.TransactionBatchRemoveTagsHandler] parse transaction ids failed, because %s", err.Error())
|
||||
return nil, errs.ErrTransactionIdInvalid
|
||||
}
|
||||
|
||||
tagIds, err := utils.StringArrayToInt64Array(transactionBatchUpdateReq.TagIds)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[transactions.TransactionBatchRemoveTagsHandler] parse tag ids failed, because %s", err.Error())
|
||||
return nil, errs.ErrTransactionTagIdInvalid
|
||||
}
|
||||
|
||||
tagIds = utils.ToUniqueInt64Slice(tagIds)
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil {
|
||||
if !errs.IsCustomError(err) {
|
||||
log.Errorf(c, "[transactions.TransactionBatchRemoveTagsHandler] failed to get user, because %s", err.Error())
|
||||
}
|
||||
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
tags, err := a.transactionTags.GetTagsByTagIds(c, uid, tagIds)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionBatchRemoveTagsHandler] failed to get tags for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
if len(tags) != len(tagIds) {
|
||||
log.Warnf(c, "[transactions.TransactionBatchRemoveTagsHandler] some tags do not exist for user \"uid:%d\"", uid)
|
||||
return nil, errs.ErrTransactionTagNotFound
|
||||
}
|
||||
|
||||
transactions, err := a.transactions.GetTransactionsByTransactionIds(c, uid, transactionIds)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionBatchRemoveTagsHandler] failed to get transactions for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
allUsedAccounts, err := a.getTransactionUsedAccounts(c, uid, transactions)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionBatchRemoveTagsHandler] failed to get transaction used accounts for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
allTransactionIds := make([]int64, 0, len(transactions))
|
||||
|
||||
for i := 0; i < len(transactions); i++ {
|
||||
transaction := transactions[i]
|
||||
|
||||
if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN {
|
||||
log.Warnf(c, "[transactions.TransactionBatchRemoveTagsHandler] cannot modify transaction \"id:%d\" for user \"uid:%d\", because transaction type is transfer in", transaction.TransactionId, uid)
|
||||
return nil, errs.ErrTransactionTypeInvalid
|
||||
}
|
||||
|
||||
transactionEditable := user.CanEditTransactionByTransactionTime(transaction.TransactionTime, clientTimezone, allUsedAccounts[transaction.AccountId], allUsedAccounts[transaction.RelatedAccountId])
|
||||
|
||||
if !transactionEditable {
|
||||
log.Warnf(c, "[transactions.TransactionBatchRemoveTagsHandler] transaction \"id:%d\" is not editable for user \"uid:%d\"", transaction.TransactionId, uid)
|
||||
return nil, errs.ErrCannotModifyTransactionWithThisTransactionTime
|
||||
}
|
||||
|
||||
allTransactionIds = append(allTransactionIds, transaction.TransactionId)
|
||||
}
|
||||
|
||||
err = a.transactions.BatchRemoveTagsFromTransactions(c, uid, allTransactionIds, tagIds)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionBatchRemoveTagsHandler] failed to batch update transactions tags for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
log.Infof(c, "[transactions.TransactionBatchRemoveTagsHandler] user \"uid:%d\" has batch updated tag of %d transactions successfully", uid, len(allTransactionIds))
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// TransactionBatchClearTagsHandler batch clear all tags from transactions by request parameters for current user
|
||||
func (a *TransactionsApi) TransactionBatchClearTagsHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var transactionBatchUpdateReq models.TransactionBatchClearTagsRequest
|
||||
err := c.ShouldBindJSON(&transactionBatchUpdateReq)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[transactions.TransactionBatchClearTagsHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
clientTimezone, err := c.GetClientTimezone()
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[transactions.TransactionBatchClearTagsHandler] cannot get client timezone, because %s", err.Error())
|
||||
return nil, errs.ErrClientTimezoneOffsetInvalid
|
||||
}
|
||||
|
||||
transactionIds, err := utils.StringArrayToInt64Array(transactionBatchUpdateReq.TransactionIds)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[transactions.TransactionBatchClearTagsHandler] parse transaction ids failed, because %s", err.Error())
|
||||
return nil, errs.ErrTransactionIdInvalid
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil {
|
||||
if !errs.IsCustomError(err) {
|
||||
log.Errorf(c, "[transactions.TransactionBatchClearTagsHandler] failed to get user, because %s", err.Error())
|
||||
}
|
||||
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
transactions, err := a.transactions.GetTransactionsByTransactionIds(c, uid, transactionIds)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionBatchClearTagsHandler] failed to get transactions for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
allUsedAccounts, err := a.getTransactionUsedAccounts(c, uid, transactions)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionBatchClearTagsHandler] failed to get transaction used accounts for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
allTransactionIds := make([]int64, 0, len(transactions))
|
||||
|
||||
for i := 0; i < len(transactions); i++ {
|
||||
transaction := transactions[i]
|
||||
|
||||
if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN {
|
||||
log.Warnf(c, "[transactions.TransactionBatchClearTagsHandler] cannot modify transaction \"id:%d\" for user \"uid:%d\", because transaction type is transfer in", transaction.TransactionId, uid)
|
||||
return nil, errs.ErrTransactionTypeInvalid
|
||||
}
|
||||
|
||||
transactionEditable := user.CanEditTransactionByTransactionTime(transaction.TransactionTime, clientTimezone, allUsedAccounts[transaction.AccountId], allUsedAccounts[transaction.RelatedAccountId])
|
||||
|
||||
if !transactionEditable {
|
||||
log.Warnf(c, "[transactions.TransactionBatchClearTagsHandler] transaction \"id:%d\" is not editable for user \"uid:%d\"", transaction.TransactionId, uid)
|
||||
return nil, errs.ErrCannotModifyTransactionWithThisTransactionTime
|
||||
}
|
||||
|
||||
allTransactionIds = append(allTransactionIds, transaction.TransactionId)
|
||||
}
|
||||
|
||||
err = a.transactions.BatchClearAllTagsFromTransactions(c, uid, allTransactionIds)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionBatchClearTagsHandler] failed to batch update transactions tags for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
log.Infof(c, "[transactions.TransactionBatchClearTagsHandler] user \"uid:%d\" has batch updated tag of %d transactions successfully", uid, len(allTransactionIds))
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// TransactionMoveAllBetweenAccountsHandler moves all transactions from one account to another account for current user
|
||||
func (a *TransactionsApi) TransactionMoveAllBetweenAccountsHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var transactionMoveReq models.TransactionMoveBetweenAccountsRequest
|
||||
@@ -1348,11 +1937,28 @@ func (a *TransactionsApi) TransactionMoveAllBetweenAccountsHandler(c *core.WebCo
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
clientTimezone, err := c.GetClientTimezone()
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[transactions.TransactionMoveAllBetweenAccountsHandler] cannot get client timezone, because %s", err.Error())
|
||||
return nil, errs.ErrClientTimezoneOffsetInvalid
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil {
|
||||
if !errs.IsCustomError(err) {
|
||||
log.Errorf(c, "[transactions.TransactionMoveAllBetweenAccountsHandler] failed to get user, because %s", err.Error())
|
||||
}
|
||||
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
if transactionMoveReq.FromAccountId == transactionMoveReq.ToAccountId {
|
||||
return nil, errs.ErrCannotMoveTransactionToSameAccount
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
accountMap, err := a.accounts.GetAccountsByAccountIds(c, uid, []int64{transactionMoveReq.FromAccountId, transactionMoveReq.ToAccountId})
|
||||
|
||||
if err != nil {
|
||||
@@ -1384,7 +1990,38 @@ func (a *TransactionsApi) TransactionMoveAllBetweenAccountsHandler(c *core.WebCo
|
||||
return nil, errs.ErrCannotMoveTransactionBetweenAccountsWithDifferentCurrencies
|
||||
}
|
||||
|
||||
err = a.transactions.MoveAllTransactionsBetweenAccounts(c, uid, transactionMoveReq.FromAccountId, transactionMoveReq.ToAccountId)
|
||||
transactions, err := a.transactions.GetAllSpecifiedTransactions(c, uid, 0, 0, 0, nil, []int64{fromAccount.AccountId}, nil, false, "", "", false, pageCountForMovingAccountTransactions, true)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionMoveAllBetweenAccountsHandler] failed to get all transactions of account \"id:%d\" for user \"uid:%d\", because %s", fromAccount.AccountId, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
allUsedAccounts, err := a.getTransactionUsedAccounts(c, uid, transactions)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionMoveAllBetweenAccountsHandler] failed to get transaction used accounts for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
for i := 0; i < len(transactions); i++ {
|
||||
transaction := transactions[i]
|
||||
transactionEditable := user.CanEditTransactionByTransactionTime(transaction.TransactionTime, clientTimezone, allUsedAccounts[transaction.AccountId], allUsedAccounts[transaction.RelatedAccountId])
|
||||
newTransactionEditable := transactionEditable
|
||||
|
||||
if transaction.AccountId == fromAccount.AccountId {
|
||||
newTransactionEditable = user.CanEditTransactionByTransactionTime(transaction.TransactionTime, clientTimezone, toAccount, allUsedAccounts[transaction.RelatedAccountId])
|
||||
} else if transaction.RelatedAccountId == fromAccount.AccountId {
|
||||
newTransactionEditable = user.CanEditTransactionByTransactionTime(transaction.TransactionTime, clientTimezone, allUsedAccounts[transaction.AccountId], toAccount)
|
||||
}
|
||||
|
||||
if !transactionEditable || !newTransactionEditable {
|
||||
log.Warnf(c, "[transactions.TransactionMoveAllBetweenAccountsHandler] transaction \"id:%d\" is not editable for user \"uid:%d\"", transaction.TransactionId, uid)
|
||||
return nil, errs.ErrCannotModifyTransactionWithThisTransactionTime
|
||||
}
|
||||
}
|
||||
|
||||
err = a.transactions.MoveAllTransactionsBetweenAccounts(c, uid, fromAccount.AccountId, toAccount.AccountId)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionMoveAllBetweenAccountsHandler] failed to move all transactions from account \"id:%d\" to account \"id:%d\" for user \"uid:%d\", because %s", transactionMoveReq.FromAccountId, transactionMoveReq.ToAccountId, uid, err.Error())
|
||||
@@ -1435,7 +2072,14 @@ func (a *TransactionsApi) TransactionDeleteHandler(c *core.WebContext) (any, *er
|
||||
return nil, errs.ErrTransactionTypeInvalid
|
||||
}
|
||||
|
||||
transactionEditable := user.CanEditTransactionByTransactionTime(transaction.TransactionTime, clientTimezone)
|
||||
allUsedAccounts, err := a.getTransactionUsedAccounts(c, uid, []*models.Transaction{transaction})
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionDeleteHandler] failed to get transaction used accounts for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
transactionEditable := user.CanEditTransactionByTransactionTime(transaction.TransactionTime, clientTimezone, allUsedAccounts[transaction.AccountId], allUsedAccounts[transaction.RelatedAccountId])
|
||||
|
||||
if !transactionEditable {
|
||||
return nil, errs.ErrCannotDeleteTransactionWithThisTransactionTime
|
||||
@@ -1452,6 +2096,87 @@ func (a *TransactionsApi) TransactionDeleteHandler(c *core.WebContext) (any, *er
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// TransactionBatchDeleteHandler deletes existed transactions by request parameters for current user
|
||||
func (a *TransactionsApi) TransactionBatchDeleteHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var transactionBatchDeleteReq models.TransactionBatchDeleteRequest
|
||||
err := c.ShouldBindJSON(&transactionBatchDeleteReq)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[transactions.TransactionBatchDeleteHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
clientTimezone, err := c.GetClientTimezone()
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[transactions.TransactionBatchDeleteHandler] cannot get client timezone, because %s", err.Error())
|
||||
return nil, errs.ErrClientTimezoneOffsetInvalid
|
||||
}
|
||||
|
||||
transactionIds, err := utils.StringArrayToInt64Array(transactionBatchDeleteReq.Ids)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[transactions.TransactionBatchDeleteHandler] parse transaction ids failed, because %s", err.Error())
|
||||
return nil, errs.ErrTransactionIdInvalid
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil {
|
||||
if !errs.IsCustomError(err) {
|
||||
log.Errorf(c, "[transactions.TransactionBatchDeleteHandler] failed to get user, because %s", err.Error())
|
||||
}
|
||||
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
if !a.users.IsPasswordEqualsUserPassword(transactionBatchDeleteReq.Password, user) {
|
||||
return nil, errs.ErrUserPasswordWrong
|
||||
}
|
||||
|
||||
transactions, err := a.transactions.GetTransactionsByTransactionIds(c, uid, transactionIds)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionBatchDeleteHandler] failed to get transactions for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
allUsedAccounts, err := a.getTransactionUsedAccounts(c, uid, transactions)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionBatchDeleteHandler] failed to get transaction used accounts for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
for i := 0; i < len(transactions); i++ {
|
||||
transaction := transactions[i]
|
||||
transactionEditable := user.CanEditTransactionByTransactionTime(transaction.TransactionTime, clientTimezone, allUsedAccounts[transaction.AccountId], allUsedAccounts[transaction.RelatedAccountId])
|
||||
|
||||
if !transactionEditable {
|
||||
log.Warnf(c, "[transactions.TransactionBatchDeleteHandler] transaction \"id:%d\" is not editable for user \"uid:%d\"", transaction.TransactionId, uid)
|
||||
return nil, errs.ErrCannotDeleteTransactionWithThisTransactionTime
|
||||
}
|
||||
}
|
||||
|
||||
deletedCount := 0
|
||||
|
||||
for i := 0; i < len(transactions); i++ {
|
||||
transaction := transactions[i]
|
||||
err = a.transactions.DeleteTransaction(c, uid, transaction.TransactionId)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionBatchDeleteHandler] failed to delete transaction \"id:%d\" for user \"uid:%d\", because %s", transaction.TransactionId, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
deletedCount++
|
||||
}
|
||||
|
||||
log.Infof(c, "[transactions.TransactionBatchDeleteHandler] user \"uid:%d\" has deleted %d transactions", uid, deletedCount)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// TransactionParseImportCustomFileDataHandler returns the parsed file data by request parameters for current user
|
||||
func (a *TransactionsApi) TransactionParseImportCustomFileDataHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
uid := c.GetCurrentUid()
|
||||
@@ -1861,13 +2586,24 @@ func (a *TransactionsApi) TransactionImportHandler(c *core.WebContext) (any, *er
|
||||
for i := 0; i < len(transactionImportReq.Transactions); i++ {
|
||||
transactionCreateReq := transactionImportReq.Transactions[i]
|
||||
transaction := a.createNewTransactionModel(uid, transactionCreateReq, c.ClientIP())
|
||||
transactionEditable := user.CanEditTransactionByTransactionTime(transaction.TransactionTime, clientTimezone)
|
||||
newTransactions[i] = transaction
|
||||
}
|
||||
|
||||
allUsedAccounts, err := a.getTransactionUsedAccounts(c, uid, newTransactions)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionImportHandler] failed to get transaction used accounts for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
for i := 0; i < len(newTransactions); i++ {
|
||||
transaction := newTransactions[i]
|
||||
transactionEditable := user.CanEditTransactionByTransactionTime(transaction.TransactionTime, clientTimezone, allUsedAccounts[transaction.AccountId], allUsedAccounts[transaction.RelatedAccountId])
|
||||
|
||||
if !transactionEditable {
|
||||
log.Warnf(c, "[transactions.TransactionImportHandler] transaction \"index:%d\" is not editable for user \"uid:%d\"", i, uid)
|
||||
return nil, errs.ErrCannotCreateTransactionWithThisTransactionTime
|
||||
}
|
||||
|
||||
newTransactions[i] = transaction
|
||||
}
|
||||
|
||||
err = a.transactions.BatchCreateTransactions(c, user.Uid, newTransactions, newTransactionTagIdsMap, func(currentProcess float64) {
|
||||
@@ -2099,6 +2835,41 @@ func (a *TransactionsApi) getTransactionEssentialDataByTransactionIds(c *core.We
|
||||
return accountMap, categoryMap, tagMap, allTransactionTagIds, pictureInfoMap, nil
|
||||
}
|
||||
|
||||
func (a *TransactionsApi) getTransactionUsedAccounts(c *core.WebContext, uid int64, transactions []*models.Transaction) (map[int64]*models.Account, error) {
|
||||
accountIds := make([]int64, 0, len(transactions)*2)
|
||||
|
||||
for i := 0; i < len(transactions); i++ {
|
||||
accountIds = append(accountIds, transactions[i].AccountId)
|
||||
|
||||
if transactions[i].Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN || transactions[i].Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT {
|
||||
accountIds = append(accountIds, transactions[i].RelatedAccountId)
|
||||
}
|
||||
}
|
||||
|
||||
accountMap, err := a.accounts.GetAccountsByAccountIds(c, uid, utils.ToUniqueInt64Slice(accountIds))
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.getTransactionUsedAccounts] failed to get accounts for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
for i := 0; i < len(transactions); i++ {
|
||||
if _, exists := accountMap[transactions[i].AccountId]; !exists {
|
||||
log.Warnf(c, "[transactions.getTransactionUsedAccounts] account of transaction \"id:%d\" does not exist for user \"uid:%d\"", transactions[i].TransactionId, uid)
|
||||
return nil, errs.ErrSourceAccountNotFound
|
||||
}
|
||||
|
||||
if transactions[i].Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN || transactions[i].Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT {
|
||||
if _, exists := accountMap[transactions[i].RelatedAccountId]; !exists {
|
||||
log.Warnf(c, "[transactions.getTransactionUsedAccounts] related account of transaction \"id:%d\" does not exist for user \"uid:%d\"", transactions[i].TransactionId, uid)
|
||||
return nil, errs.ErrDestinationAccountNotFound
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return accountMap, nil
|
||||
}
|
||||
|
||||
func (a *TransactionsApi) getTransactionResponseListResult(c *core.WebContext, user *models.User, transactions []*models.Transaction, allAccounts map[int64]*models.Account, categoryMap map[int64]*models.TransactionCategory, tagMap map[int64]*models.TransactionTag, allTransactionTagIds map[int64][]int64, pictureInfoMap map[int64][]*models.TransactionPictureInfo, clientTimezone *time.Location, withPictures bool, trimAccount bool, trimCategory bool, trimTag bool) (models.TransactionInfoResponseSlice, error) {
|
||||
result := make(models.TransactionInfoResponseSlice, len(transactions))
|
||||
|
||||
|
||||
+12
-1
@@ -256,6 +256,7 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro
|
||||
userUpdateReq.Nickname = strings.TrimSpace(userUpdateReq.Nickname)
|
||||
|
||||
modifyProfileBasicInfo := false
|
||||
modifyUseLastReconciledTime := false
|
||||
anythingUpdate := false
|
||||
userNew := &models.User{
|
||||
Uid: user.Uid,
|
||||
@@ -317,6 +318,16 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro
|
||||
anythingUpdate = true
|
||||
}
|
||||
|
||||
if userUpdateReq.UseLastReconciledTime != nil && *userUpdateReq.UseLastReconciledTime != user.UseLastReconciledTime {
|
||||
user.UseLastReconciledTime = *userUpdateReq.UseLastReconciledTime
|
||||
userNew.UseLastReconciledTime = *userUpdateReq.UseLastReconciledTime
|
||||
modifyProfileBasicInfo = true
|
||||
modifyUseLastReconciledTime = true
|
||||
anythingUpdate = true
|
||||
} else {
|
||||
modifyUseLastReconciledTime = false
|
||||
}
|
||||
|
||||
if userUpdateReq.TransactionEditScope != nil && *userUpdateReq.TransactionEditScope != user.TransactionEditScope {
|
||||
user.TransactionEditScope = *userUpdateReq.TransactionEditScope
|
||||
userNew.TransactionEditScope = *userUpdateReq.TransactionEditScope
|
||||
@@ -531,7 +542,7 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro
|
||||
return nil, errs.ErrNothingWillBeUpdated
|
||||
}
|
||||
|
||||
keyProfileUpdated, emailSetToUnverified, err := a.users.UpdateUser(c, userNew, modifyUserLanguage)
|
||||
keyProfileUpdated, emailSetToUnverified, err := a.users.UpdateUser(c, userNew, modifyUserLanguage, modifyUseLastReconciledTime)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[users.UserUpdateProfileHandler] failed to update user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
|
||||
@@ -20,10 +20,20 @@ type DataTableTransactionDataExporter struct {
|
||||
|
||||
// BuildExportedContent writes the exported transaction data to the data table builder
|
||||
func (c *DataTableTransactionDataExporter) BuildExportedContent(ctx core.Context, dataTableBuilder datatable.TransactionDataTableBuilder, uid int64, transactions []*models.Transaction, accountMap map[int64]*models.Account, categoryMap map[int64]*models.TransactionCategory, tagMap map[int64]*models.TransactionTag, allTagIndexes map[int64][]int64) error {
|
||||
existsTransferOutTransactions := make(map[int64]bool)
|
||||
|
||||
for i := 0; i < len(transactions); i++ {
|
||||
transaction := transactions[i]
|
||||
|
||||
if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN {
|
||||
if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT {
|
||||
existsTransferOutTransactions[transaction.TransactionId] = true
|
||||
}
|
||||
}
|
||||
|
||||
for i := 0; i < len(transactions); i++ {
|
||||
transaction := transactions[i]
|
||||
|
||||
if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN && existsTransferOutTransactions[transaction.RelatedId] {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -36,14 +46,25 @@ func (c *DataTableTransactionDataExporter) BuildExportedContent(ctx core.Context
|
||||
dataRowMap[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = dataTableBuilder.ReplaceDelimiters(c.getDisplayTransactionTypeName(transaction.Type))
|
||||
dataRowMap[datatable.TRANSACTION_DATA_TABLE_CATEGORY] = c.getExportedTransactionCategoryName(dataTableBuilder, transaction.CategoryId, categoryMap)
|
||||
dataRowMap[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = c.getExportedTransactionSubCategoryName(dataTableBuilder, transaction.CategoryId, categoryMap)
|
||||
dataRowMap[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = c.getExportedAccountName(dataTableBuilder, transaction.AccountId, accountMap)
|
||||
dataRowMap[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY] = c.getAccountCurrency(dataTableBuilder, transaction.AccountId, accountMap)
|
||||
dataRowMap[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(transaction.Amount)
|
||||
|
||||
if transaction.Type != models.TRANSACTION_DB_TYPE_TRANSFER_IN {
|
||||
dataRowMap[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = c.getExportedAccountName(dataTableBuilder, transaction.AccountId, accountMap)
|
||||
dataRowMap[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY] = c.getAccountCurrency(dataTableBuilder, transaction.AccountId, accountMap)
|
||||
dataRowMap[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(transaction.Amount)
|
||||
} else { // if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN {
|
||||
dataRowMap[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = c.getExportedAccountName(dataTableBuilder, transaction.RelatedAccountId, accountMap)
|
||||
dataRowMap[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY] = c.getAccountCurrency(dataTableBuilder, transaction.RelatedAccountId, accountMap)
|
||||
dataRowMap[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(transaction.RelatedAccountAmount)
|
||||
}
|
||||
|
||||
if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT {
|
||||
dataRowMap[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = c.getExportedAccountName(dataTableBuilder, transaction.RelatedAccountId, accountMap)
|
||||
dataRowMap[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY] = c.getAccountCurrency(dataTableBuilder, transaction.RelatedAccountId, accountMap)
|
||||
dataRowMap[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = utils.FormatAmount(transaction.RelatedAccountAmount)
|
||||
} else if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN {
|
||||
dataRowMap[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = c.getExportedAccountName(dataTableBuilder, transaction.AccountId, accountMap)
|
||||
dataRowMap[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY] = c.getAccountCurrency(dataTableBuilder, transaction.AccountId, accountMap)
|
||||
dataRowMap[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = utils.FormatAmount(transaction.Amount)
|
||||
}
|
||||
|
||||
dataRowMap[datatable.TRANSACTION_DATA_TABLE_GEOGRAPHIC_LOCATION] = c.getExportedGeographicLocation(transaction)
|
||||
|
||||
@@ -383,7 +383,7 @@ func (c *DataTableTransactionDataImporter) ParseImportedData(ctx core.Context, u
|
||||
Comment: description,
|
||||
GeoLongitude: geoLongitude,
|
||||
GeoLatitude: geoLatitude,
|
||||
CreatedIp: "127.0.0.1",
|
||||
CreatedIp: ctx.ClientIP(),
|
||||
},
|
||||
TagIds: tagIds,
|
||||
OriginalCategoryName: subCategoryName,
|
||||
|
||||
@@ -5,6 +5,7 @@ import "context"
|
||||
// Context is the base context of ezBookkeeping
|
||||
type Context interface {
|
||||
context.Context
|
||||
ClientIP() string
|
||||
GetContextId() string
|
||||
GetClientLocale() string
|
||||
}
|
||||
|
||||
@@ -12,6 +12,11 @@ type CliContext struct {
|
||||
command *cli.Command
|
||||
}
|
||||
|
||||
// ClientIP returns the client IP address, for CLI context, it always returns the loopback address
|
||||
func (c *CliContext) ClientIP() string {
|
||||
return "127.0.0.1"
|
||||
}
|
||||
|
||||
// GetContextId returns the current context id
|
||||
func (c *CliContext) GetContextId() string {
|
||||
return ""
|
||||
|
||||
@@ -14,6 +14,11 @@ type CronContext struct {
|
||||
cronJobInterval time.Duration
|
||||
}
|
||||
|
||||
// ClientIP returns the client IP address, for cron job context, it always returns the loopback address
|
||||
func (c *CronContext) ClientIP() string {
|
||||
return "127.0.0.1"
|
||||
}
|
||||
|
||||
// GetContextId returns the current context id
|
||||
func (c *CronContext) GetContextId() string {
|
||||
return c.contextId
|
||||
|
||||
@@ -9,6 +9,11 @@ type NullContext struct {
|
||||
context.Context
|
||||
}
|
||||
|
||||
// ClientIP returns the client IP address, for null context, it always returns the loopback address
|
||||
func (c *NullContext) ClientIP() string {
|
||||
return "127.0.0.1"
|
||||
}
|
||||
|
||||
// GetContextId returns the current context id
|
||||
func (c *NullContext) GetContextId() string {
|
||||
return nullContextId
|
||||
|
||||
+24
-22
@@ -4,26 +4,28 @@ import "net/http"
|
||||
|
||||
// Error codes related to accounts
|
||||
var (
|
||||
ErrAccountIdInvalid = NewNormalError(NormalSubcategoryAccount, 0, http.StatusBadRequest, "account id is invalid")
|
||||
ErrAccountNotFound = NewNormalError(NormalSubcategoryAccount, 1, http.StatusBadRequest, "account not found")
|
||||
ErrAccountTypeInvalid = NewNormalError(NormalSubcategoryAccount, 2, http.StatusBadRequest, "account type is invalid")
|
||||
ErrAccountCurrencyInvalid = NewNormalError(NormalSubcategoryAccount, 3, http.StatusBadRequest, "account currency is invalid")
|
||||
ErrAccountHaveNoSubAccount = NewNormalError(NormalSubcategoryAccount, 4, http.StatusBadRequest, "account must have at least one sub-account")
|
||||
ErrAccountCannotHaveSubAccounts = NewNormalError(NormalSubcategoryAccount, 5, http.StatusBadRequest, "account cannot have sub-accounts")
|
||||
ErrParentAccountCannotSetCurrency = NewNormalError(NormalSubcategoryAccount, 6, http.StatusBadRequest, "parent account cannot set currency")
|
||||
ErrParentAccountCannotSetBalance = NewNormalError(NormalSubcategoryAccount, 7, http.StatusBadRequest, "parent account cannot set balance")
|
||||
ErrSubAccountCategoryNotEqualsToParent = NewNormalError(NormalSubcategoryAccount, 8, http.StatusBadRequest, "sub-account category not equals to parent")
|
||||
ErrSubAccountTypeInvalid = NewNormalError(NormalSubcategoryAccount, 9, http.StatusBadRequest, "sub-account type invalid")
|
||||
ErrSourceAccountNotFound = NewNormalError(NormalSubcategoryAccount, 11, http.StatusBadRequest, "source account not found")
|
||||
ErrDestinationAccountNotFound = NewNormalError(NormalSubcategoryAccount, 12, http.StatusBadRequest, "destination account not found")
|
||||
ErrAccountInUseCannotBeDeleted = NewNormalError(NormalSubcategoryAccount, 13, http.StatusBadRequest, "account is in use and cannot be deleted")
|
||||
ErrAccountCategoryInvalid = NewNormalError(NormalSubcategoryAccount, 14, http.StatusBadRequest, "account category is invalid")
|
||||
ErrAccountBalanceTimeNotSet = NewNormalError(NormalSubcategoryAccount, 15, http.StatusBadRequest, "account balance time is not set")
|
||||
ErrCannotSetStatementDateForNonCreditCard = NewNormalError(NormalSubcategoryAccount, 16, http.StatusBadRequest, "cannot set statement date for non credit card account")
|
||||
ErrCannotSetStatementDateForSubAccount = NewNormalError(NormalSubcategoryAccount, 17, http.StatusBadRequest, "cannot set statement date for sub account")
|
||||
ErrSubAccountNotFound = NewNormalError(NormalSubcategoryAccount, 18, http.StatusBadRequest, "sub-account not found")
|
||||
ErrSubAccountInUseCannotBeDeleted = NewNormalError(NormalSubcategoryAccount, 19, http.StatusBadRequest, "sub-account is in use and cannot be deleted")
|
||||
ErrNotSupportedChangeCurrency = NewNormalError(NormalSubcategoryAccount, 20, http.StatusBadRequest, "not supported to modify account currency")
|
||||
ErrNotSupportedChangeBalance = NewNormalError(NormalSubcategoryAccount, 21, http.StatusBadRequest, "not supported to modify account balance")
|
||||
ErrNotSupportedChangeBalanceTime = NewNormalError(NormalSubcategoryAccount, 22, http.StatusBadRequest, "not supported to modify account balance time")
|
||||
ErrAccountIdInvalid = NewNormalError(NormalSubcategoryAccount, 0, http.StatusBadRequest, "account id is invalid")
|
||||
ErrAccountNotFound = NewNormalError(NormalSubcategoryAccount, 1, http.StatusBadRequest, "account not found")
|
||||
ErrAccountTypeInvalid = NewNormalError(NormalSubcategoryAccount, 2, http.StatusBadRequest, "account type is invalid")
|
||||
ErrAccountCurrencyInvalid = NewNormalError(NormalSubcategoryAccount, 3, http.StatusBadRequest, "account currency is invalid")
|
||||
ErrAccountHaveNoSubAccount = NewNormalError(NormalSubcategoryAccount, 4, http.StatusBadRequest, "account must have at least one sub-account")
|
||||
ErrAccountCannotHaveSubAccounts = NewNormalError(NormalSubcategoryAccount, 5, http.StatusBadRequest, "account cannot have sub-accounts")
|
||||
ErrParentAccountCannotSetCurrency = NewNormalError(NormalSubcategoryAccount, 6, http.StatusBadRequest, "parent account cannot set currency")
|
||||
ErrParentAccountCannotSetBalance = NewNormalError(NormalSubcategoryAccount, 7, http.StatusBadRequest, "parent account cannot set balance")
|
||||
ErrSubAccountCategoryNotEqualsToParent = NewNormalError(NormalSubcategoryAccount, 8, http.StatusBadRequest, "sub-account category not equals to parent")
|
||||
ErrSubAccountTypeInvalid = NewNormalError(NormalSubcategoryAccount, 9, http.StatusBadRequest, "sub-account type invalid")
|
||||
ErrSourceAccountNotFound = NewNormalError(NormalSubcategoryAccount, 11, http.StatusBadRequest, "source account not found")
|
||||
ErrDestinationAccountNotFound = NewNormalError(NormalSubcategoryAccount, 12, http.StatusBadRequest, "destination account not found")
|
||||
ErrAccountInUseCannotBeDeleted = NewNormalError(NormalSubcategoryAccount, 13, http.StatusBadRequest, "account is in use and cannot be deleted")
|
||||
ErrAccountCategoryInvalid = NewNormalError(NormalSubcategoryAccount, 14, http.StatusBadRequest, "account category is invalid")
|
||||
ErrAccountBalanceTimeNotSet = NewNormalError(NormalSubcategoryAccount, 15, http.StatusBadRequest, "account balance time is not set")
|
||||
ErrCannotSetStatementDateForNonCreditCard = NewNormalError(NormalSubcategoryAccount, 16, http.StatusBadRequest, "cannot set statement date for non credit card account")
|
||||
ErrCannotSetStatementDateForSubAccount = NewNormalError(NormalSubcategoryAccount, 17, http.StatusBadRequest, "cannot set statement date for sub account")
|
||||
ErrSubAccountNotFound = NewNormalError(NormalSubcategoryAccount, 18, http.StatusBadRequest, "sub-account not found")
|
||||
ErrSubAccountInUseCannotBeDeleted = NewNormalError(NormalSubcategoryAccount, 19, http.StatusBadRequest, "sub-account is in use and cannot be deleted")
|
||||
ErrNotSupportedChangeCurrency = NewNormalError(NormalSubcategoryAccount, 20, http.StatusBadRequest, "not supported to modify account currency")
|
||||
ErrNotSupportedChangeBalance = NewNormalError(NormalSubcategoryAccount, 21, http.StatusBadRequest, "not supported to modify account balance")
|
||||
ErrNotSupportedChangeBalanceTime = NewNormalError(NormalSubcategoryAccount, 22, http.StatusBadRequest, "not supported to modify account balance time")
|
||||
ErrParentAccountCannotSetLastReconciledTime = NewNormalError(NormalSubcategoryAccount, 23, http.StatusBadRequest, "parent account cannot set last reconciled time")
|
||||
ErrCannotSetLastReconciledTimeBeforeCurrent = NewNormalError(NormalSubcategoryAccount, 24, http.StatusBadRequest, "cannot set last reconciled time before current value")
|
||||
)
|
||||
|
||||
@@ -45,4 +45,5 @@ var (
|
||||
ErrCannotMoveTransactionFromOrToHiddenAccount = NewNormalError(NormalSubcategoryTransaction, 38, http.StatusBadRequest, "cannot move transaction from or to hidden account")
|
||||
ErrCannotMoveTransactionFromOrToParentAccount = NewNormalError(NormalSubcategoryTransaction, 39, http.StatusBadRequest, "cannot move transaction from or to parent account")
|
||||
ErrCannotMoveTransactionBetweenAccountsWithDifferentCurrencies = NewNormalError(NormalSubcategoryTransaction, 40, http.StatusBadRequest, "cannot move transaction between accounts with different currencies")
|
||||
ErrCannotAddTagsToTooManyTransactionsOneTime = NewNormalError(NormalSubcategoryTransaction, 41, http.StatusBadRequest, "cannot add tags to too many transactions one time")
|
||||
)
|
||||
|
||||
@@ -41,4 +41,5 @@ var (
|
||||
ErrCannotLoginByPassword = NewNormalError(NormalSubcategoryUser, 32, http.StatusBadRequest, "cannot login by password")
|
||||
ErrUserNameIsInvalid = NewNormalError(NormalSubcategoryUser, 33, http.StatusBadRequest, "user name is invalid")
|
||||
ErrNickNameIsInvalid = NewNormalError(NormalSubcategoryUser, 34, http.StatusBadRequest, "nick name is invalid")
|
||||
ErrLastReconciledTimeIsNotEnabled = NewNormalError(NormalSubcategoryUser, 35, http.StatusBadRequest, "last reconciled time is not enabled")
|
||||
)
|
||||
|
||||
@@ -24,7 +24,7 @@ func TestExchangeRatesApiLatestExchangeRateHandler_BankOfCanadaDataSource(t *tes
|
||||
assert.Equal(t, "CAD", exchangeRateResponse.BaseCurrency)
|
||||
|
||||
supportedCurrencyCodes := []string{"AUD", "BRL", "CHF", "CNY", "EUR", "GBP", "HKD", "IDR", "INR",
|
||||
"JPY", "KRW", "MXN", "MYR", "NOK", "NZD", "PEN", "RUB", "SAR", "SEK", "SGD", "THB", "TRY", "TWD",
|
||||
"JPY", "KRW", "MXN", "MYR", "NOK", "NZD", "PEN", "PLN", "RUB", "SAR", "SEK", "SGD", "THB", "TRY", "TWD",
|
||||
"USD", "VND", "ZAR"}
|
||||
|
||||
checkExchangeRatesHaveSpecifiedCurrencies(t, exchangeRateResponse.BaseCurrency, supportedCurrencyCodes, exchangeRateResponse.ExchangeRates)
|
||||
@@ -135,6 +135,22 @@ func TestExchangeRatesApiLatestExchangeRateHandler_BankOfIsraelDataSource(t *tes
|
||||
checkExchangeRatesHaveSpecifiedCurrencies(t, exchangeRateResponse.BaseCurrency, supportedCurrencyCodes, exchangeRateResponse.ExchangeRates)
|
||||
}
|
||||
|
||||
func TestExchangeRatesApiLatestExchangeRateHandler_NationalBankOfKazakhstan(t *testing.T) {
|
||||
exchangeRateResponse := executeLatestExchangeRateHandler(t, settings.NationalBankOfKazakhstanDataSource)
|
||||
|
||||
if exchangeRateResponse == nil {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, "KZT", exchangeRateResponse.BaseCurrency)
|
||||
|
||||
supportedCurrencyCodes := []string{"AED", "AMD", "AUD", "AZN", "BRL", "BYN", "CAD", "CHF", "CNY", "CZK",
|
||||
"DKK", "EUR", "GBP", "GEL", "HKD", "HUF", "INR", "IRR", "JPY", "KGS", "KRW", "KWD", "MDL", "MXN",
|
||||
"MYR", "NOK", "PLN", "RUB", "SAR", "SEK", "SGD", "THB", "TJS", "TRY", "UAH", "USD", "UZS", "ZAR"}
|
||||
|
||||
checkExchangeRatesHaveSpecifiedCurrencies(t, exchangeRateResponse.BaseCurrency, supportedCurrencyCodes, exchangeRateResponse.ExchangeRates)
|
||||
}
|
||||
|
||||
func TestExchangeRatesApiLatestExchangeRateHandler_CentralBankOfMyanmarDataSource(t *testing.T) {
|
||||
exchangeRateResponse := executeLatestExchangeRateHandler(t, settings.CentralBankOfMyanmarDataSource)
|
||||
|
||||
@@ -270,7 +286,7 @@ func TestExchangeRatesApiLatestExchangeRateHandler_CentralBankOfUzbekistanDataSo
|
||||
assert.Equal(t, "UZS", exchangeRateResponse.BaseCurrency)
|
||||
|
||||
supportedCurrencyCodes := []string{"AED", "AFN", "AMD", "ARS", "AUD", "AZN",
|
||||
"BDT", "BGN", "BHD", "BND", "BRL", "BYN", "CAD", "CHF", "CNY", "CUP", "CZK",
|
||||
"BDT", "BHD", "BND", "BRL", "BYN", "CAD", "CHF", "CNY", "CUP", "CZK",
|
||||
"DKK", "DZD", "EGP", "EUR", "GBP", "GEL", "HKD", "HUF", "IDR", "ILS", "INR", "IQD", "IRR", "ISK",
|
||||
"JOD", "JPY", "KGS", "KHR", "KRW", "KWD", "KZT", "LAK", "LBP", "LYD",
|
||||
"MAD", "MDL", "MMK", "MNT", "MXN", "MYR", "NOK", "NZD", "OMR", "PHP", "PKR", "PLN",
|
||||
|
||||
@@ -40,6 +40,9 @@ func InitializeExchangeRatesDataSource(config *settings.Config) error {
|
||||
} else if config.ExchangeRatesDataSource == settings.BankOfIsraelDataSource {
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(config, &BankOfIsraelDataSource{})
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.NationalBankOfKazakhstanDataSource {
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(config, &NationalBankOfKazakhstanDataSource{})
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.CentralBankOfMyanmarDataSource {
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(config, &CentralBankOfMyanmarDataSource{})
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package exchangerates
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"math"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/validators"
|
||||
)
|
||||
|
||||
const nationalBankOfKazakhstanExchangeRateUrl = "https://www.nationalbank.kz/rss/rates_all.xml"
|
||||
const nationalBankOfKazakhstanExchangeRateReferenceUrl = "https://nationalbank.kz/en/exchangerates/ezhednevnye-oficialnye-rynochnye-kursy-valyut"
|
||||
const nationalBankOfKazakhstanDataSource = "Қазақстан Республикасының Ұлттық Банкі"
|
||||
const nationalBankOfKazakhstanBaseCurrency = "KZT"
|
||||
|
||||
const nationalBankOfKazakhstanUpdateDateFormat = "02.01.2006"
|
||||
const nationalBankOfKazakhstanUpdateDateTimezone = "Asia/Almaty"
|
||||
|
||||
// NationalBankOfKazakhstanDataSource defines the structure of exchange rates data source of the national bank of Kazakhstan
|
||||
type NationalBankOfKazakhstanDataSource struct {
|
||||
HttpExchangeRatesDataSource
|
||||
}
|
||||
|
||||
// NationalBankOfKazakhstanExchangeRates represents the exchange rates data from the national bank of Kazakhstan
|
||||
type NationalBankOfKazakhstanExchangeRates struct {
|
||||
Channel struct {
|
||||
Items []*NationalBankOfKazakhstanExchangeRate `xml:"item"`
|
||||
} `xml:"channel"`
|
||||
}
|
||||
|
||||
// NationalBankOfKazakhstanExchangeRate represents the exchange rate data from the national bank of Kazakhstan
|
||||
type NationalBankOfKazakhstanExchangeRate struct {
|
||||
Currency string `xml:"title"`
|
||||
Rate string `xml:"description"`
|
||||
Unit string `xml:"quant"`
|
||||
Date string `xml:"pubDate"`
|
||||
}
|
||||
|
||||
// ToLatestExchangeRateResponse returns a view-object according to original data from the national bank of Kazakhstan
|
||||
func (e *NationalBankOfKazakhstanExchangeRates) ToLatestExchangeRateResponse(c core.Context) *models.LatestExchangeRateResponse {
|
||||
if e == nil || len(e.Channel.Items) < 1 {
|
||||
log.Errorf(c, "[national_bank_of_kazakhstan_datasource.ToLatestExchangeRateResponse] exchange rates is empty")
|
||||
return nil
|
||||
}
|
||||
|
||||
timezone, err := time.LoadLocation(nationalBankOfKazakhstanUpdateDateTimezone)
|
||||
if err != nil {
|
||||
log.Errorf(c, "[national_bank_of_kazakhstan_datasource.ToLatestExchangeRateResponse] failed to load timezone, timezone name is %s", nationalBankOfKazakhstanUpdateDateTimezone)
|
||||
return nil
|
||||
}
|
||||
|
||||
exchangeRates := make(models.LatestExchangeRateSlice, 0, len(e.Channel.Items))
|
||||
latestUpdateTime := int64(0)
|
||||
|
||||
for i := 0; i < len(e.Channel.Items); i++ {
|
||||
exchangeRate := e.Channel.Items[i]
|
||||
|
||||
if _, exists := validators.AllCurrencyNames[exchangeRate.Currency]; !exists {
|
||||
continue
|
||||
}
|
||||
|
||||
updateTime, err := time.ParseInLocation(nationalBankOfKazakhstanUpdateDateFormat, exchangeRate.Date, timezone)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[central_bank_of_kazakhstan_datasource.ToLatestExchangeRateResponse] failed to parse update date, datetime is %s", exchangeRate.Date)
|
||||
return nil
|
||||
}
|
||||
|
||||
if updateTime.Unix() > latestUpdateTime {
|
||||
latestUpdateTime = updateTime.Unix()
|
||||
}
|
||||
|
||||
finalRate := exchangeRate.ToLatestExchangeRate(c)
|
||||
if finalRate == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
exchangeRates = append(exchangeRates, finalRate)
|
||||
}
|
||||
|
||||
return &models.LatestExchangeRateResponse{
|
||||
DataSource: nationalBankOfKazakhstanDataSource,
|
||||
ReferenceUrl: nationalBankOfKazakhstanExchangeRateReferenceUrl,
|
||||
UpdateTime: latestUpdateTime,
|
||||
BaseCurrency: nationalBankOfKazakhstanBaseCurrency,
|
||||
ExchangeRates: exchangeRates,
|
||||
}
|
||||
}
|
||||
|
||||
// ToLatestExchangeRate returns a data pair according to original data from the national bank of Kazakhstan
|
||||
func (e *NationalBankOfKazakhstanExchangeRate) ToLatestExchangeRate(c core.Context) *models.LatestExchangeRate {
|
||||
rate, err := utils.StringToFloat64(e.Rate)
|
||||
if err != nil {
|
||||
log.Warnf(c, "[national_bank_of_kazakhstan_datasource.ToLatestExchangeRate] failed to parse rate, currency is %s, rate is %s", e.Currency, e.Rate)
|
||||
return nil
|
||||
}
|
||||
|
||||
if rate <= 0 {
|
||||
log.Warnf(c, "[national_bank_of_kazakhstan_datasource.ToLatestExchangeRate] rate is invalid, currency is %s, rate is %s", e.Currency, e.Rate)
|
||||
return nil
|
||||
}
|
||||
|
||||
unit, err := utils.StringToFloat64(e.Unit)
|
||||
if err != nil {
|
||||
log.Warnf(c, "[national_bank_of_kazakhstan_datasource.ToLatestExchangeRate] failed to parse unit, currency=%s, unit=%s", e.Currency, e.Unit)
|
||||
}
|
||||
|
||||
if unit <= 0 {
|
||||
log.Warnf(c, "[national_bank_of_kazakhstan_datasource.ToLatestExchangeRate] unit is less or equal zero, currency is %s, unit is %s", e.Currency, e.Unit)
|
||||
return nil
|
||||
}
|
||||
|
||||
finalRate := unit / rate
|
||||
if math.IsInf(finalRate, 0) {
|
||||
log.Warnf(c, "[national_bank_of_kazakhstan_datasource.ToLatestExchangeRate] final exchange rate calculation failed, currency is %s, unit is %s, rate is %s", e.Currency, e.Unit, e.Rate)
|
||||
return nil
|
||||
}
|
||||
|
||||
return &models.LatestExchangeRate{
|
||||
Currency: e.Currency,
|
||||
Rate: utils.Float64ToString(finalRate),
|
||||
}
|
||||
}
|
||||
|
||||
// BuildRequests returns the national bank of Kazakhstan exchange rates http requests
|
||||
func (e *NationalBankOfKazakhstanDataSource) BuildRequests() ([]*http.Request, error) {
|
||||
req, err := http.NewRequest("GET", nationalBankOfKazakhstanExchangeRateUrl, nil)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []*http.Request{req}, nil
|
||||
}
|
||||
|
||||
// Parse returns the common response entity according to the national bank of Kazakhstan data source raw response
|
||||
func (e *NationalBankOfKazakhstanDataSource) Parse(c core.Context, content []byte) (*models.LatestExchangeRateResponse, error) {
|
||||
nationalBankOfKazakhstanData := &NationalBankOfKazakhstanExchangeRates{}
|
||||
err := xml.Unmarshal(content, nationalBankOfKazakhstanData)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[national_bank_of_kazakhstan_datasource.Parse] failed to parse xml data, content is %s, because %s", string(content), err.Error())
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
latestExchangeRateResponse := nationalBankOfKazakhstanData.ToLatestExchangeRateResponse(c)
|
||||
|
||||
if latestExchangeRateResponse == nil {
|
||||
log.Errorf(c, "[national_bank_of_kazakhstan_datasource.Parse] failed to parse latest exchange rate data, content is %s", string(content))
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
return latestExchangeRateResponse, nil
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package exchangerates
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
)
|
||||
|
||||
const nationalBankOfKazakhstanMinimumRequiredContent = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n" +
|
||||
" <rss version=\"2.0\">\n" +
|
||||
" <channel>\n" +
|
||||
" <item>\n" +
|
||||
" <title>USD</title>\n" +
|
||||
" <pubDate>28.04.2026</pubDate>\n" +
|
||||
" <description>450.50</description>\n" +
|
||||
" <quant>1</quant>\n" +
|
||||
" </item>\n" +
|
||||
" <item>\n" +
|
||||
" <title>VND</title>\n" +
|
||||
" <pubDate>28.04.2026</pubDate>\n" +
|
||||
" <description>0.018</description>\n" +
|
||||
" <quant>10</quant>\n" +
|
||||
" </item>\n" +
|
||||
" </channel>\n" +
|
||||
"</rss>"
|
||||
|
||||
func TestNationalBankOfKazakhstanDataSource_StandardDataExtractBaseCurrency(t *testing.T) {
|
||||
dataSource := &NationalBankOfKazakhstanDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
resp, err := dataSource.Parse(context, []byte(nationalBankOfKazakhstanMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, "KZT", resp.BaseCurrency)
|
||||
}
|
||||
|
||||
func TestNationalBankOfKazakhstanDataSource_StandardDataExtractUpdateTime(t *testing.T) {
|
||||
dataSource := &NationalBankOfKazakhstanDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
resp, err := dataSource.Parse(context, []byte(nationalBankOfKazakhstanMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
|
||||
assert.Equal(t, int64(1777316400), resp.UpdateTime)
|
||||
}
|
||||
|
||||
func TestNationalBankOfKazakhstanDataSource_StandardDataExtractExchangeRates(t *testing.T) {
|
||||
dataSource := &NationalBankOfKazakhstanDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
resp, err := dataSource.Parse(context, []byte(nationalBankOfKazakhstanMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
|
||||
assert.Contains(t, resp.ExchangeRates, &models.LatestExchangeRate{
|
||||
Currency: "USD",
|
||||
Rate: "0.0022197558268590455",
|
||||
})
|
||||
|
||||
assert.Contains(t, resp.ExchangeRates, &models.LatestExchangeRate{
|
||||
Currency: "VND",
|
||||
Rate: "555.5555555555555",
|
||||
})
|
||||
}
|
||||
|
||||
func TestNationalBankOfKazakhstanDataSource_BlankContent(t *testing.T) {
|
||||
dataSource := &NationalBankOfKazakhstanDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte(""))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestNationalBankOfKazakhstanDataSource_EmptyData(t *testing.T) {
|
||||
dataSource := &NationalBankOfKazakhstanDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
content := "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n" +
|
||||
"<rss version=\"2.0\">\n" +
|
||||
"<channel>\n" +
|
||||
"</channel>\n" +
|
||||
"</rss>"
|
||||
|
||||
_, err := dataSource.Parse(context, []byte(content))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestNationalBankOfKazakhstanDataSource_InvalidCurrency(t *testing.T) {
|
||||
dataSource := &NationalBankOfKazakhstanDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
content := "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n" +
|
||||
" <rss version=\"2.0\">\n" +
|
||||
" <channel>\n" +
|
||||
" <item>\n" +
|
||||
" <title>XXX</title>\n" +
|
||||
" <pubDate>28.04.2026</pubDate>\n" +
|
||||
" <description>450.50</description>\n" +
|
||||
" <quant>1</quant>\n" +
|
||||
" </item>\n" +
|
||||
" </channel>\n" +
|
||||
"</rss>"
|
||||
|
||||
resp, err := dataSource.Parse(context, []byte(content))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, resp.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestNationalBankOfKazakhstanDataSource_InvalidUnit(t *testing.T) {
|
||||
dataSource := &NationalBankOfKazakhstanDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
content := "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n" +
|
||||
" <rss version=\"2.0\">\n" +
|
||||
" <channel>\n" +
|
||||
" <item>\n" +
|
||||
" <title>USD</title>\n" +
|
||||
" <pubDate>28.04.2026</pubDate>\n" +
|
||||
" <description>450.50</description>\n" +
|
||||
" <quant>null</quant>\n" +
|
||||
" </item>\n" +
|
||||
" </channel>\n" +
|
||||
"</rss>"
|
||||
|
||||
resp, err := dataSource.Parse(context, []byte(content))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, resp.ExchangeRates, 0)
|
||||
|
||||
content = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n" +
|
||||
" <rss version=\"2.0\">\n" +
|
||||
" <channel>\n" +
|
||||
" <item>\n" +
|
||||
" <title>USD</title>\n" +
|
||||
" <pubDate>28.04.2026</pubDate>\n" +
|
||||
" <description>450.50</description>\n" +
|
||||
" <quant>0</quant>\n" +
|
||||
" </item>\n" +
|
||||
" </channel>\n" +
|
||||
"</rss>"
|
||||
|
||||
resp, err = dataSource.Parse(context, []byte(content))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, resp.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestNationalBankOfKazakhstanDataSource_InvalidRate(t *testing.T) {
|
||||
dataSource := &NationalBankOfKazakhstanDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
content := "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n" +
|
||||
" <rss version=\"2.0\">\n" +
|
||||
" <channel>\n" +
|
||||
" <item>\n" +
|
||||
" <title>USD</title>\n" +
|
||||
" <pubDate>28.04.2026</pubDate>\n" +
|
||||
" <description>null</description>\n" +
|
||||
" <quant>1</quant>\n" +
|
||||
" </item>\n" +
|
||||
" </channel>\n" +
|
||||
"</rss>"
|
||||
|
||||
resp, err := dataSource.Parse(context, []byte(content))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, resp.ExchangeRates, 0)
|
||||
|
||||
content = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n" +
|
||||
" <rss version=\"2.0\">\n" +
|
||||
" <channel>\n" +
|
||||
" <item>\n" +
|
||||
" <title>USD</title>\n" +
|
||||
" <pubDate>28.04.2026</pubDate>\n" +
|
||||
" <description>0</description>\n" +
|
||||
" <quant>1</quant>\n" +
|
||||
" </item>\n" +
|
||||
" </channel>\n" +
|
||||
"</rss>"
|
||||
|
||||
resp, err = dataSource.Parse(context, []byte(content))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, resp.ExchangeRates, 0)
|
||||
}
|
||||
@@ -178,7 +178,7 @@ func (h *mcpAddTransactionToolHandler) Handle(c *core.WebContext, callToolReq *M
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
transactionEditable := user.CanEditTransactionByTransactionTime(transaction.TransactionTime, time.FixedZone("Transaction Timezone", int(transaction.TimezoneUtcOffset)*60))
|
||||
transactionEditable := user.CanEditTransactionByTransactionTime(transaction.TransactionTime, time.FixedZone("Transaction Timezone", int(transaction.TimezoneUtcOffset)*60), sourceAccount, destinationAccount)
|
||||
|
||||
if !transactionEditable {
|
||||
return nil, nil, errs.ErrCannotCreateTransactionWithThisTransactionTime
|
||||
|
||||
@@ -14,6 +14,8 @@ import (
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
)
|
||||
|
||||
const pageCountForLoadTransactions = 1000
|
||||
|
||||
// MCPQueryTransactionsRequest represents all parameters of the query transactions request
|
||||
type MCPQueryTransactionsRequest struct {
|
||||
StartTime string `json:"start_time" jsonschema:"format=date-time" jsonschema_description:"Start time for the query in RFC 3339 format (e.g. 2023-01-01T12:00:00Z)"`
|
||||
@@ -153,14 +155,14 @@ func (h *mcpQueryTransactionsToolHandler) Handle(c *core.WebContext, callToolReq
|
||||
}
|
||||
}
|
||||
|
||||
totalCount, err := services.GetTransactionService().GetTransactionCount(c, uid, maxTransactionTime, minTransactionTime, transactionType, filterCategoryIds, filterAccountIds, nil, false, "", queryTransactionsRequest.Keyword)
|
||||
totalCount, err := services.GetTransactionService().GetTransactionCount(c, uid, maxTransactionTime, minTransactionTime, transactionType, filterCategoryIds, filterAccountIds, nil, false, "", queryTransactionsRequest.Keyword, false)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionListHandler] failed to get transaction count for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
transactions, err := services.GetTransactionService().GetTransactionsByMaxTime(c, uid, maxTransactionTime, minTransactionTime, transactionType, filterCategoryIds, filterAccountIds, nil, false, "", queryTransactionsRequest.Keyword, queryTransactionsRequest.Page, queryTransactionsRequest.Count, false, true)
|
||||
transactions, err := services.GetTransactionService().GetTransactionsByMaxTimeUpToCount(c, uid, maxTransactionTime, minTransactionTime, transactionType, filterCategoryIds, filterAccountIds, nil, false, "", queryTransactionsRequest.Keyword, false, queryTransactionsRequest.Page, queryTransactionsRequest.Count, pageCountForLoadTransactions, false, true)
|
||||
structuredResponse, response, err := h.createNewMCPQueryTransactionsResponse(c, &queryTransactionsRequest, transactions, totalCount, services.GetAccountService().GetAccountMapByList(allAccounts), services.GetTransactionCategoryService().GetCategoryMapByList(allCategories))
|
||||
|
||||
if err != nil {
|
||||
|
||||
+25
-1
@@ -90,7 +90,8 @@ type Account struct {
|
||||
|
||||
// AccountExtend represents account extend data stored in database
|
||||
type AccountExtend struct {
|
||||
CreditCardStatementDate *int `json:"creditCardStatementDate"`
|
||||
LastReconciledTime *int64 `json:"lastReconciledTime"`
|
||||
CreditCardStatementDate *int `json:"creditCardStatementDate"`
|
||||
}
|
||||
|
||||
// AccountCreateRequest represents all parameters of account creation request
|
||||
@@ -119,6 +120,7 @@ type AccountModifyRequest struct {
|
||||
Currency *string `json:"currency" binding:"omitempty,len=3,validCurrency"`
|
||||
Balance *int64 `json:"balance" binding:"omitempty"`
|
||||
BalanceTime *int64 `json:"balanceTime" binding:"omitempty"`
|
||||
LastReconciledTime *int64 `json:"lastReconciledTime" binding:"omitempty"`
|
||||
Comment string `json:"comment" binding:"max=255"`
|
||||
CreditCardStatementDate int `json:"creditCardStatementDate" binding:"min=0,max=28"`
|
||||
Hidden bool `json:"hidden"`
|
||||
@@ -126,6 +128,12 @@ type AccountModifyRequest struct {
|
||||
ClientSessionId string `json:"clientSessionId"`
|
||||
}
|
||||
|
||||
// AccountUpdateLastReconciledTimeRequest represents all parameters of account updating last reconciled time request
|
||||
type AccountUpdateLastReconciledTimeRequest struct {
|
||||
Id int64 `json:"id,string" binding:"required,min=1"`
|
||||
LastReconciledTime int64 `json:"lastReconciledTime" binding:"required"`
|
||||
}
|
||||
|
||||
// AccountListRequest represents all parameters of account listing request
|
||||
type AccountListRequest struct {
|
||||
VisibleOnly bool `form:"visible_only"`
|
||||
@@ -169,6 +177,7 @@ type AccountInfoResponse struct {
|
||||
Color string `json:"color"`
|
||||
Currency string `json:"currency"`
|
||||
Balance int64 `json:"balance"`
|
||||
LastReconciledTime *int64 `json:"lastReconciledTime,omitempty"`
|
||||
Comment string `json:"comment"`
|
||||
CreditCardStatementDate *int `json:"creditCardStatementDate,omitempty"`
|
||||
DisplayOrder int32 `json:"displayOrder"`
|
||||
@@ -178,10 +187,24 @@ type AccountInfoResponse struct {
|
||||
SubAccounts AccountInfoResponseSlice `json:"subAccounts,omitempty"`
|
||||
}
|
||||
|
||||
// GetLastReconciledTime returns the last reconciled time of the account
|
||||
func (a *Account) GetLastReconciledTime() int64 {
|
||||
if a.Extend != nil && a.Extend.LastReconciledTime != nil {
|
||||
return *a.Extend.LastReconciledTime
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// ToAccountInfoResponse returns a view-object according to database model
|
||||
func (a *Account) ToAccountInfoResponse() *AccountInfoResponse {
|
||||
var lastReconciledTime *int64
|
||||
var creditCardStatementDate *int
|
||||
|
||||
if a.Extend != nil {
|
||||
lastReconciledTime = a.Extend.LastReconciledTime
|
||||
}
|
||||
|
||||
if a.ParentAccountId == LevelOneAccountParentId && a.Category == ACCOUNT_CATEGORY_CREDIT_CARD {
|
||||
if a.Extend != nil {
|
||||
creditCardStatementDate = a.Extend.CreditCardStatementDate
|
||||
@@ -201,6 +224,7 @@ func (a *Account) ToAccountInfoResponse() *AccountInfoResponse {
|
||||
Currency: a.Currency,
|
||||
Balance: a.Balance,
|
||||
Comment: a.Comment,
|
||||
LastReconciledTime: lastReconciledTime,
|
||||
CreditCardStatementDate: creditCardStatementDate,
|
||||
DisplayOrder: a.DisplayOrder,
|
||||
IsAsset: assetAccountCategory[a.Category],
|
||||
|
||||
+91
-51
@@ -210,65 +210,69 @@ type TransactionTagFilter struct {
|
||||
|
||||
// TransactionCountRequest represents transaction count request
|
||||
type TransactionCountRequest struct {
|
||||
Type TransactionType `form:"type" binding:"min=0,max=4"`
|
||||
CategoryIds string `form:"category_ids"`
|
||||
AccountIds string `form:"account_ids"`
|
||||
TagFilter string `form:"tag_filter" binding:"validTagFilter"`
|
||||
AmountFilter string `form:"amount_filter" binding:"validAmountFilter"`
|
||||
Keyword string `form:"keyword"`
|
||||
MaxTime int64 `form:"max_time" binding:"min=0"` // Transaction time sequence id
|
||||
MinTime int64 `form:"min_time" binding:"min=0"` // Transaction time sequence id
|
||||
Type TransactionType `form:"type" binding:"min=0,max=4"`
|
||||
CategoryIds string `form:"category_ids"`
|
||||
AccountIds string `form:"account_ids"`
|
||||
TagFilter string `form:"tag_filter" binding:"validTagFilter"`
|
||||
AmountFilter string `form:"amount_filter" binding:"validAmountFilter"`
|
||||
Keyword string `form:"keyword"`
|
||||
MustHavePictures bool `form:"must_have_pictures"`
|
||||
MaxTime int64 `form:"max_time" binding:"min=0"` // Transaction time sequence id
|
||||
MinTime int64 `form:"min_time" binding:"min=0"` // Transaction time sequence id
|
||||
}
|
||||
|
||||
// TransactionListByMaxTimeRequest represents all parameters of transaction listing by max time request
|
||||
type TransactionListByMaxTimeRequest struct {
|
||||
Type TransactionType `form:"type" binding:"min=0,max=4"`
|
||||
CategoryIds string `form:"category_ids"`
|
||||
AccountIds string `form:"account_ids"`
|
||||
TagFilter string `form:"tag_filter" binding:"validTagFilter"`
|
||||
AmountFilter string `form:"amount_filter" binding:"validAmountFilter"`
|
||||
Keyword string `form:"keyword"`
|
||||
MaxTime int64 `form:"max_time" binding:"min=0"` // Transaction time sequence id
|
||||
MinTime int64 `form:"min_time" binding:"min=0"` // Transaction time sequence id
|
||||
Page int32 `form:"page" binding:"min=0"`
|
||||
Count int32 `form:"count" binding:"required,min=1,max=50"`
|
||||
WithCount bool `form:"with_count"`
|
||||
WithPictures bool `form:"with_pictures"`
|
||||
TrimAccount bool `form:"trim_account"`
|
||||
TrimCategory bool `form:"trim_category"`
|
||||
TrimTag bool `form:"trim_tag"`
|
||||
Type TransactionType `form:"type" binding:"min=0,max=4"`
|
||||
CategoryIds string `form:"category_ids"`
|
||||
AccountIds string `form:"account_ids"`
|
||||
TagFilter string `form:"tag_filter" binding:"validTagFilter"`
|
||||
AmountFilter string `form:"amount_filter" binding:"validAmountFilter"`
|
||||
Keyword string `form:"keyword"`
|
||||
MustHavePictures bool `form:"must_have_pictures"`
|
||||
MaxTime int64 `form:"max_time" binding:"min=0"` // Transaction time sequence id
|
||||
MinTime int64 `form:"min_time" binding:"min=0"` // Transaction time sequence id
|
||||
Page int32 `form:"page" binding:"min=0"`
|
||||
Count int32 `form:"count" binding:"required,min=1,max=50"`
|
||||
WithCount bool `form:"with_count"`
|
||||
WithPictures bool `form:"with_pictures"`
|
||||
TrimAccount bool `form:"trim_account"`
|
||||
TrimCategory bool `form:"trim_category"`
|
||||
TrimTag bool `form:"trim_tag"`
|
||||
}
|
||||
|
||||
// TransactionListInMonthByPageRequest represents all parameters of transaction listing by month request
|
||||
type TransactionListInMonthByPageRequest struct {
|
||||
Year int32 `form:"year" binding:"required,min=1"`
|
||||
Month int32 `form:"month" binding:"required,min=1"`
|
||||
Type TransactionType `form:"type" binding:"min=0,max=4"`
|
||||
CategoryIds string `form:"category_ids"`
|
||||
AccountIds string `form:"account_ids"`
|
||||
TagFilter string `form:"tag_filter" binding:"validTagFilter"`
|
||||
AmountFilter string `form:"amount_filter" binding:"validAmountFilter"`
|
||||
Keyword string `form:"keyword"`
|
||||
WithPictures bool `form:"with_pictures"`
|
||||
TrimAccount bool `form:"trim_account"`
|
||||
TrimCategory bool `form:"trim_category"`
|
||||
TrimTag bool `form:"trim_tag"`
|
||||
Year int32 `form:"year" binding:"required,min=1"`
|
||||
Month int32 `form:"month" binding:"required,min=1"`
|
||||
Type TransactionType `form:"type" binding:"min=0,max=4"`
|
||||
CategoryIds string `form:"category_ids"`
|
||||
AccountIds string `form:"account_ids"`
|
||||
TagFilter string `form:"tag_filter" binding:"validTagFilter"`
|
||||
AmountFilter string `form:"amount_filter" binding:"validAmountFilter"`
|
||||
Keyword string `form:"keyword"`
|
||||
MustHavePictures bool `form:"must_have_pictures"`
|
||||
WithPictures bool `form:"with_pictures"`
|
||||
TrimAccount bool `form:"trim_account"`
|
||||
TrimCategory bool `form:"trim_category"`
|
||||
TrimTag bool `form:"trim_tag"`
|
||||
}
|
||||
|
||||
// TransactionAllListRequest represents all parameters of all transaction listing request
|
||||
type TransactionAllListRequest struct {
|
||||
Type TransactionType `form:"type" binding:"min=0,max=4"`
|
||||
CategoryIds string `form:"category_ids"`
|
||||
AccountIds string `form:"account_ids"`
|
||||
TagFilter string `form:"tag_filter" binding:"validTagFilter"`
|
||||
AmountFilter string `form:"amount_filter" binding:"validAmountFilter"`
|
||||
Keyword string `form:"keyword"`
|
||||
StartTime int64 `form:"start_time" binding:"min=0"`
|
||||
EndTime int64 `form:"end_time" binding:"min=0"`
|
||||
WithPictures bool `form:"with_pictures"`
|
||||
TrimAccount bool `form:"trim_account"`
|
||||
TrimCategory bool `form:"trim_category"`
|
||||
TrimTag bool `form:"trim_tag"`
|
||||
Type TransactionType `form:"type" binding:"min=0,max=4"`
|
||||
CategoryIds string `form:"category_ids"`
|
||||
AccountIds string `form:"account_ids"`
|
||||
TagFilter string `form:"tag_filter" binding:"validTagFilter"`
|
||||
AmountFilter string `form:"amount_filter" binding:"validAmountFilter"`
|
||||
Keyword string `form:"keyword"`
|
||||
MustHavePictures bool `form:"must_have_pictures"`
|
||||
StartTime int64 `form:"start_time" binding:"min=0"`
|
||||
EndTime int64 `form:"end_time" binding:"min=0"`
|
||||
WithPictures bool `form:"with_pictures"`
|
||||
TrimAccount bool `form:"trim_account"`
|
||||
TrimCategory bool `form:"trim_category"`
|
||||
TrimTag bool `form:"trim_tag"`
|
||||
}
|
||||
|
||||
// TransactionReconciliationStatementRequest represents all parameters of transaction reconciliation statement request
|
||||
@@ -325,6 +329,36 @@ type TransactionGetRequest struct {
|
||||
TrimTag bool `form:"trim_tag"`
|
||||
}
|
||||
|
||||
// TransactionBatchUpdateCategoryRequest represents all parameters of transaction batch update category request
|
||||
type TransactionBatchUpdateCategoryRequest struct {
|
||||
TransactionIds []string `json:"transactionIds" binding:"required"`
|
||||
CategoryId int64 `json:"categoryId,string" binding:"required"`
|
||||
}
|
||||
|
||||
// TransactionBatchUpdateAccountRequest represents all parameters of transaction batch update account request
|
||||
type TransactionBatchUpdateAccountRequest struct {
|
||||
TransactionIds []string `json:"transactionIds" binding:"required"`
|
||||
AccountId int64 `json:"accountId,string" binding:"required"`
|
||||
IsDestinationAccount bool `json:"isDestinationAccount"`
|
||||
}
|
||||
|
||||
// TransactionBatchAddTagsRequest represents all parameters of transaction batch add tags request
|
||||
type TransactionBatchAddTagsRequest struct {
|
||||
TransactionIds []string `json:"transactionIds" binding:"required"`
|
||||
TagIds []string `json:"tagIds" binding:"required"`
|
||||
}
|
||||
|
||||
// TransactionBatchRemoveTagsRequest represents all parameters of transaction batch remove tags request
|
||||
type TransactionBatchRemoveTagsRequest struct {
|
||||
TransactionIds []string `json:"transactionIds" binding:"required"`
|
||||
TagIds []string `json:"tagIds" binding:"required"`
|
||||
}
|
||||
|
||||
// TransactionBatchClearTagsRequest represents all parameters of transaction batch clear tags request
|
||||
type TransactionBatchClearTagsRequest struct {
|
||||
TransactionIds []string `json:"transactionIds" binding:"required"`
|
||||
}
|
||||
|
||||
// TransactionMoveBetweenAccountsRequest represents all parameters of moving all transactions between accounts request
|
||||
type TransactionMoveBetweenAccountsRequest struct {
|
||||
FromAccountId int64 `json:"fromAccountId,string" binding:"required,min=1"`
|
||||
@@ -336,6 +370,12 @@ type TransactionDeleteRequest struct {
|
||||
Id int64 `json:"id,string" binding:"required,min=1"`
|
||||
}
|
||||
|
||||
// TransactionBatchDeleteRequest represents all parameters of transaction batch deleting request
|
||||
type TransactionBatchDeleteRequest struct {
|
||||
Ids []string `json:"ids,string" binding:"required"`
|
||||
Password string `json:"password" binding:"omitempty,min=6,max=128"`
|
||||
}
|
||||
|
||||
// YearMonthRangeRequest represents all parameters of a request with year and month range
|
||||
type YearMonthRangeRequest struct {
|
||||
StartYearMonth string `form:"start_year_month"`
|
||||
@@ -513,10 +553,6 @@ func ParseTransactionTagFilter(tagFilterStr string) ([]*TransactionTagFilter, er
|
||||
|
||||
// IsEditable returns whether this transaction can be edited
|
||||
func (t *Transaction) IsEditable(currentUser *User, clientTimezone *time.Location, account *Account, relatedAccount *Account) bool {
|
||||
if currentUser == nil || !currentUser.CanEditTransactionByTransactionTime(t.TransactionTime, clientTimezone) {
|
||||
return false
|
||||
}
|
||||
|
||||
if account == nil || account.Hidden {
|
||||
return false
|
||||
}
|
||||
@@ -527,6 +563,10 @@ func (t *Transaction) IsEditable(currentUser *User, clientTimezone *time.Locatio
|
||||
}
|
||||
}
|
||||
|
||||
if currentUser == nil || !currentUser.CanEditTransactionByTransactionTime(t.TransactionTime, clientTimezone, account, relatedAccount) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ const (
|
||||
TRANSACTION_SCHEDULE_FREQUENCY_TYPE_DISABLED TransactionScheduleFrequencyType = 0
|
||||
TRANSACTION_SCHEDULE_FREQUENCY_TYPE_WEEKLY TransactionScheduleFrequencyType = 1
|
||||
TRANSACTION_SCHEDULE_FREQUENCY_TYPE_MONTHLY TransactionScheduleFrequencyType = 2
|
||||
TRANSACTION_SCHEDULE_FREQUENCY_TYPE_DAILY TransactionScheduleFrequencyType = 3
|
||||
TRANSACTION_SCHEDULE_FREQUENCY_TYPE_YEARLY TransactionScheduleFrequencyType = 4
|
||||
)
|
||||
|
||||
// TransactionTemplate represents transaction template stored in database
|
||||
|
||||
+38
-15
@@ -13,14 +13,15 @@ type TransactionEditScope byte
|
||||
|
||||
// Editable Transaction Ranges
|
||||
const (
|
||||
TRANSACTION_EDIT_SCOPE_NONE TransactionEditScope = 0
|
||||
TRANSACTION_EDIT_SCOPE_ALL TransactionEditScope = 1
|
||||
TRANSACTION_EDIT_SCOPE_TODAY_OR_LATER TransactionEditScope = 2
|
||||
TRANSACTION_EDIT_SCOPE_LAST_24H_OR_LATER TransactionEditScope = 3
|
||||
TRANSACTION_EDIT_SCOPE_THIS_WEEK_OR_LATER TransactionEditScope = 4
|
||||
TRANSACTION_EDIT_SCOPE_THIS_MONTH_OR_LATER TransactionEditScope = 5
|
||||
TRANSACTION_EDIT_SCOPE_THIS_YEAR_OR_LATER TransactionEditScope = 6
|
||||
TRANSACTION_EDIT_SCOPE_INVALID TransactionEditScope = 255
|
||||
TRANSACTION_EDIT_SCOPE_NONE TransactionEditScope = 0
|
||||
TRANSACTION_EDIT_SCOPE_ALL TransactionEditScope = 1
|
||||
TRANSACTION_EDIT_SCOPE_TODAY_OR_LATER TransactionEditScope = 2
|
||||
TRANSACTION_EDIT_SCOPE_LAST_24H_OR_LATER TransactionEditScope = 3
|
||||
TRANSACTION_EDIT_SCOPE_THIS_WEEK_OR_LATER TransactionEditScope = 4
|
||||
TRANSACTION_EDIT_SCOPE_THIS_MONTH_OR_LATER TransactionEditScope = 5
|
||||
TRANSACTION_EDIT_SCOPE_THIS_YEAR_OR_LATER TransactionEditScope = 6
|
||||
TRANSACTION_EDIT_SCOPE_LAST_RECONCILED_TIME_OR_LATER TransactionEditScope = 7
|
||||
TRANSACTION_EDIT_SCOPE_INVALID TransactionEditScope = 255
|
||||
)
|
||||
|
||||
// String returns a textual representation of the editable transaction ranges enum
|
||||
@@ -40,6 +41,8 @@ func (s TransactionEditScope) String() string {
|
||||
return "ThisMonthOrLater"
|
||||
case TRANSACTION_EDIT_SCOPE_THIS_YEAR_OR_LATER:
|
||||
return "ThisYearOrLater"
|
||||
case TRANSACTION_EDIT_SCOPE_LAST_RECONCILED_TIME_OR_LATER:
|
||||
return "LastReconciledTimeOrLater"
|
||||
case TRANSACTION_EDIT_SCOPE_INVALID:
|
||||
return "Invalid"
|
||||
default:
|
||||
@@ -90,6 +93,7 @@ type User struct {
|
||||
Salt string `xorm:"VARCHAR(10) NOT NULL"`
|
||||
CustomAvatarType string `xorm:"VARCHAR(10)"`
|
||||
DefaultAccountId int64
|
||||
UseLastReconciledTime bool
|
||||
TransactionEditScope TransactionEditScope `xorm:"TINYINT NOT NULL"`
|
||||
Language string `xorm:"VARCHAR(10)"`
|
||||
DefaultCurrency string `xorm:"VARCHAR(3) NOT NULL"`
|
||||
@@ -128,6 +132,7 @@ type UserBasicInfo struct {
|
||||
AvatarUrl string `json:"avatar"`
|
||||
AvatarProvider string `json:"avatarProvider,omitempty"`
|
||||
DefaultAccountId int64 `json:"defaultAccountId,string"`
|
||||
UseLastReconciledTime bool `json:"useLastReconciledTime"`
|
||||
TransactionEditScope TransactionEditScope `json:"transactionEditScope"`
|
||||
Language string `json:"language"`
|
||||
DefaultCurrency string `json:"defaultCurrency"`
|
||||
@@ -194,7 +199,8 @@ type UserProfileUpdateRequest struct {
|
||||
Password string `json:"password" binding:"omitempty,min=6,max=128"`
|
||||
OldPassword string `json:"oldPassword" binding:"omitempty,min=6,max=128"`
|
||||
DefaultAccountId int64 `json:"defaultAccountId,string" binding:"omitempty,min=1"`
|
||||
TransactionEditScope *TransactionEditScope `json:"transactionEditScope" binding:"omitempty,min=0,max=6"`
|
||||
UseLastReconciledTime *bool `json:"useLastReconciledTime" binding:"omitempty"`
|
||||
TransactionEditScope *TransactionEditScope `json:"transactionEditScope" binding:"omitempty,min=0,max=7"`
|
||||
Language string `json:"language" binding:"omitempty,min=2,max=16"`
|
||||
DefaultCurrency string `json:"defaultCurrency" binding:"omitempty,len=3,validCurrency"`
|
||||
FirstDayOfWeek *core.WeekDay `json:"firstDayOfWeek" binding:"omitempty,min=0,max=6"`
|
||||
@@ -230,7 +236,7 @@ type UserProfileResponse struct {
|
||||
}
|
||||
|
||||
// CanEditTransactionByTransactionTime returns whether this user can edit transaction with specified transaction time
|
||||
func (u *User) CanEditTransactionByTransactionTime(transactionTime int64, clientTimezone *time.Location) bool {
|
||||
func (u *User) CanEditTransactionByTransactionTime(transactionTime int64, clientTimezone *time.Location, account *Account, destinationAccount *Account) bool {
|
||||
if u.TransactionEditScope == TRANSACTION_EDIT_SCOPE_NONE {
|
||||
return false
|
||||
} else if u.TransactionEditScope == TRANSACTION_EDIT_SCOPE_ALL {
|
||||
@@ -242,14 +248,14 @@ func (u *User) CanEditTransactionByTransactionTime(transactionTime int64, client
|
||||
transactionUnixTime := utils.GetUnixTimeFromTransactionTime(transactionTime)
|
||||
|
||||
if u.TransactionEditScope == TRANSACTION_EDIT_SCOPE_LAST_24H_OR_LATER {
|
||||
return transactionUnixTime >= now.Add(-24*time.Hour).Unix()
|
||||
return transactionUnixTime > now.Add(-24*time.Hour).Unix()
|
||||
}
|
||||
|
||||
clientNow := now.In(clientTimezone)
|
||||
clientTodayStartTime := utils.GetStartOfDay(clientNow)
|
||||
|
||||
if u.TransactionEditScope == TRANSACTION_EDIT_SCOPE_TODAY_OR_LATER {
|
||||
return transactionUnixTime >= clientTodayStartTime.Unix()
|
||||
return transactionUnixTime > clientTodayStartTime.Unix()
|
||||
} else if u.TransactionEditScope == TRANSACTION_EDIT_SCOPE_THIS_WEEK_OR_LATER {
|
||||
dayOfWeek := int(now.Weekday()) - int(u.FirstDayOfWeek)
|
||||
|
||||
@@ -258,13 +264,29 @@ func (u *User) CanEditTransactionByTransactionTime(transactionTime int64, client
|
||||
}
|
||||
|
||||
clientWeekStartTime := clientTodayStartTime.AddDate(0, 0, -dayOfWeek)
|
||||
return transactionUnixTime >= clientWeekStartTime.Unix()
|
||||
return transactionUnixTime > clientWeekStartTime.Unix()
|
||||
} else if u.TransactionEditScope == TRANSACTION_EDIT_SCOPE_THIS_MONTH_OR_LATER {
|
||||
clientMonthStartTime := clientTodayStartTime.AddDate(0, 0, -(now.Day() - 1))
|
||||
return transactionUnixTime >= clientMonthStartTime.Unix()
|
||||
return transactionUnixTime > clientMonthStartTime.Unix()
|
||||
} else if u.TransactionEditScope == TRANSACTION_EDIT_SCOPE_THIS_YEAR_OR_LATER {
|
||||
clientYearStartTime := clientTodayStartTime.AddDate(0, 0, -(now.YearDay() - 1))
|
||||
return transactionUnixTime >= clientYearStartTime.Unix()
|
||||
return transactionUnixTime > clientYearStartTime.Unix()
|
||||
} else if u.TransactionEditScope == TRANSACTION_EDIT_SCOPE_LAST_RECONCILED_TIME_OR_LATER && u.UseLastReconciledTime {
|
||||
minAccountLastReconciledTime := int64(0)
|
||||
|
||||
if account != nil {
|
||||
minAccountLastReconciledTime = account.GetLastReconciledTime()
|
||||
}
|
||||
|
||||
if destinationAccount != nil {
|
||||
destinationAccountLastReconciledTime := destinationAccount.GetLastReconciledTime()
|
||||
|
||||
if destinationAccountLastReconciledTime > minAccountLastReconciledTime {
|
||||
minAccountLastReconciledTime = destinationAccountLastReconciledTime
|
||||
}
|
||||
}
|
||||
|
||||
return transactionUnixTime > minAccountLastReconciledTime
|
||||
}
|
||||
|
||||
return false
|
||||
@@ -285,6 +307,7 @@ func (u *User) ToUserBasicInfo(avatarProvider core.UserAvatarProviderType, avata
|
||||
AvatarUrl: avatarUrl,
|
||||
AvatarProvider: string(avatarProvider),
|
||||
DefaultAccountId: u.DefaultAccountId,
|
||||
UseLastReconciledTime: u.UseLastReconciledTime,
|
||||
TransactionEditScope: u.TransactionEditScope,
|
||||
Language: u.Language,
|
||||
DefaultCurrency: u.DefaultCurrency,
|
||||
|
||||
@@ -41,9 +41,11 @@ var ALL_ALLOWED_CLOUD_SYNC_APP_SETTING_KEY_TYPES = map[string]UserApplicationClo
|
||||
"insightsExplorerDefaultDateRangeType": USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER,
|
||||
"showTagInInsightsExplorerPage": USER_APPLICATION_CLOUD_SETTING_TYPE_BOOLEAN,
|
||||
// Account List Page
|
||||
"totalAmountExcludeAccountIds": USER_APPLICATION_CLOUD_SETTING_TYPE_STRING_BOOLEAN_MAP,
|
||||
"accountCategoryOrders": USER_APPLICATION_CLOUD_SETTING_TYPE_STRING,
|
||||
"hideCategoriesWithoutAccounts": USER_APPLICATION_CLOUD_SETTING_TYPE_BOOLEAN,
|
||||
"totalAmountExcludeAccountIds": USER_APPLICATION_CLOUD_SETTING_TYPE_STRING_BOOLEAN_MAP,
|
||||
"accountCategoryOrders": USER_APPLICATION_CLOUD_SETTING_TYPE_STRING,
|
||||
"hideCategoriesWithoutAccounts": USER_APPLICATION_CLOUD_SETTING_TYPE_BOOLEAN,
|
||||
"reconciliationStatementButtonDefaultDateRangeTypeInDesktop": USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER,
|
||||
"reconciliationStatementPageDefaultDateRangeTypeInMobile": USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER,
|
||||
// Exchange Rates Data Page
|
||||
"currencySortByInExchangeRatesPage": USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER,
|
||||
// Browser Cache Management
|
||||
|
||||
+85
-16
@@ -16,7 +16,7 @@ func TestUserCanEditTransactionByTransactionTime_ScopeIsNone(t *testing.T) {
|
||||
}
|
||||
|
||||
timezone := time.FixedZone("Timezone", int(utils.GetServerTimezoneOffsetMinutes())*60)
|
||||
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(time.Now().Unix()), timezone))
|
||||
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(time.Now().Unix()), timezone, nil, nil))
|
||||
}
|
||||
|
||||
func TestUserCanEditTransactionByTransactionTime_ScopeIsAll(t *testing.T) {
|
||||
@@ -25,7 +25,7 @@ func TestUserCanEditTransactionByTransactionTime_ScopeIsAll(t *testing.T) {
|
||||
}
|
||||
|
||||
timezone := time.FixedZone("Timezone", int(utils.GetServerTimezoneOffsetMinutes())*60)
|
||||
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(time.Now().Unix()), timezone))
|
||||
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(time.Now().Unix()), timezone, nil, nil))
|
||||
}
|
||||
|
||||
func TestUserCanEditTransactionByTransactionTime_ScopeIsTodayOrLater(t *testing.T) {
|
||||
@@ -39,9 +39,10 @@ func TestUserCanEditTransactionByTransactionTime_ScopeIsTodayOrLater(t *testing.
|
||||
yesterdayLastDatetime := todayFirstDatetime.Add(-1 * time.Second)
|
||||
todayLastDatetime := yesterdayLastDatetime.Add(24 * time.Hour)
|
||||
|
||||
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(todayFirstDatetime.Unix()), timezone))
|
||||
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(todayLastDatetime.Unix()), timezone))
|
||||
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(yesterdayLastDatetime.Unix()), timezone))
|
||||
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(todayFirstDatetime.Unix()), timezone, nil, nil))
|
||||
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(todayFirstDatetime.Add(1*time.Second).Unix()), timezone, nil, nil))
|
||||
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(todayLastDatetime.Unix()), timezone, nil, nil))
|
||||
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(yesterdayLastDatetime.Unix()), timezone, nil, nil))
|
||||
}
|
||||
|
||||
func TestUserCanEditTransactionByTransactionTime_ScopeIsLast24HourOrLater(t *testing.T) {
|
||||
@@ -53,8 +54,9 @@ func TestUserCanEditTransactionByTransactionTime_ScopeIsLast24HourOrLater(t *tes
|
||||
timezone := time.FixedZone("Timezone", int(utils.GetServerTimezoneOffsetMinutes())*60)
|
||||
twentyfourHourBeforeDatetime := now.Add(-24 * time.Hour).Add(-1 * time.Second)
|
||||
|
||||
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(twentyfourHourBeforeDatetime.Unix()), timezone))
|
||||
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(twentyfourHourBeforeDatetime.Add(1*time.Second).Unix()), timezone))
|
||||
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(twentyfourHourBeforeDatetime.Unix()), timezone, nil, nil))
|
||||
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(twentyfourHourBeforeDatetime.Add(1*time.Second).Unix()), timezone, nil, nil))
|
||||
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(twentyfourHourBeforeDatetime.Add(2*time.Second).Unix()), timezone, nil, nil))
|
||||
}
|
||||
|
||||
func TestUserCanEditTransactionByTransactionTime_ScopeIsThisWeekOrLater(t *testing.T) {
|
||||
@@ -76,9 +78,10 @@ func TestUserCanEditTransactionByTransactionTime_ScopeIsThisWeekOrLater(t *testi
|
||||
lastWeekLastDatetime := thisWeekFirstDatetime.Add(-1 * time.Second)
|
||||
thisWeekLastDatetime := lastWeekLastDatetime.Add(24 * time.Hour)
|
||||
|
||||
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(thisWeekFirstDatetime.Unix()), timezone))
|
||||
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(thisWeekLastDatetime.Unix()), timezone))
|
||||
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(lastWeekLastDatetime.Unix()), timezone))
|
||||
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(thisWeekFirstDatetime.Unix()), timezone, nil, nil))
|
||||
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(thisWeekFirstDatetime.Add(1*time.Second).Unix()), timezone, nil, nil))
|
||||
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(thisWeekLastDatetime.Unix()), timezone, nil, nil))
|
||||
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(lastWeekLastDatetime.Unix()), timezone, nil, nil))
|
||||
}
|
||||
|
||||
func TestUserCanEditTransactionByTransactionTime_ScopeIsThisMonthOrLater(t *testing.T) {
|
||||
@@ -92,9 +95,10 @@ func TestUserCanEditTransactionByTransactionTime_ScopeIsThisMonthOrLater(t *test
|
||||
lastMonthLastDatetime := thisMonthFirstDatetime.Add(-1 * time.Second)
|
||||
thisMonthLastDatetime := lastMonthLastDatetime.Add(24 * time.Hour)
|
||||
|
||||
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(thisMonthFirstDatetime.Unix()), timezone))
|
||||
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(thisMonthLastDatetime.Unix()), timezone))
|
||||
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(lastMonthLastDatetime.Unix()), timezone))
|
||||
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(thisMonthFirstDatetime.Unix()), timezone, nil, nil))
|
||||
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(thisMonthFirstDatetime.Add(1*time.Second).Unix()), timezone, nil, nil))
|
||||
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(thisMonthLastDatetime.Unix()), timezone, nil, nil))
|
||||
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(lastMonthLastDatetime.Unix()), timezone, nil, nil))
|
||||
}
|
||||
|
||||
func TestUserCanEditTransactionByTransactionTime_ScopeIsThisYearOrLater(t *testing.T) {
|
||||
@@ -108,7 +112,72 @@ func TestUserCanEditTransactionByTransactionTime_ScopeIsThisYearOrLater(t *testi
|
||||
lastYearLastDatetime := thisYearFirstDatetime.Add(-1 * time.Second)
|
||||
thisYearLastDatetime := lastYearLastDatetime.Add(24 * time.Hour)
|
||||
|
||||
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(thisYearFirstDatetime.Unix()), timezone))
|
||||
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(thisYearLastDatetime.Unix()), timezone))
|
||||
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(lastYearLastDatetime.Unix()), timezone))
|
||||
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(thisYearFirstDatetime.Unix()), timezone, nil, nil))
|
||||
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(thisYearFirstDatetime.Add(1*time.Second).Unix()), timezone, nil, nil))
|
||||
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(thisYearLastDatetime.Unix()), timezone, nil, nil))
|
||||
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(lastYearLastDatetime.Unix()), timezone, nil, nil))
|
||||
}
|
||||
|
||||
func TestUserCanEditTransactionByTransactionTime_ScopeIsLastReconciledTimeOrLater(t *testing.T) {
|
||||
user := &User{
|
||||
TransactionEditScope: TRANSACTION_EDIT_SCOPE_LAST_RECONCILED_TIME_OR_LATER,
|
||||
UseLastReconciledTime: true,
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
timezone := time.FixedZone("Timezone", int(utils.GetServerTimezoneOffsetMinutes())*60)
|
||||
sourceAccountLastReconciledTime := now.Add(-24 * time.Hour)
|
||||
sourceAccountLastRecondiledUnixTime := sourceAccountLastReconciledTime.Unix()
|
||||
sourceAccount := &Account{
|
||||
Extend: &AccountExtend{
|
||||
LastReconciledTime: &sourceAccountLastRecondiledUnixTime,
|
||||
},
|
||||
}
|
||||
destinationAccountLastReconciledTime := now.Add(-20 * time.Hour)
|
||||
destinationAccountLastReconciledUnixTime := destinationAccountLastReconciledTime.Unix()
|
||||
destinationAccount := &Account{
|
||||
Extend: &AccountExtend{
|
||||
LastReconciledTime: &destinationAccountLastReconciledUnixTime,
|
||||
},
|
||||
}
|
||||
|
||||
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(sourceAccountLastReconciledTime.Add(-1*time.Second).Unix()), timezone, sourceAccount, nil))
|
||||
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(sourceAccountLastReconciledTime.Unix()), timezone, sourceAccount, nil))
|
||||
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(sourceAccountLastReconciledTime.Add(1*time.Second).Unix()), timezone, sourceAccount, nil))
|
||||
|
||||
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(destinationAccountLastReconciledTime.Add(-1*time.Second).Unix()), timezone, sourceAccount, destinationAccount))
|
||||
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(destinationAccountLastReconciledTime.Unix()), timezone, sourceAccount, destinationAccount))
|
||||
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(destinationAccountLastReconciledTime.Add(1*time.Second).Unix()), timezone, sourceAccount, destinationAccount))
|
||||
}
|
||||
|
||||
func TestUserCanEditTransactionByTransactionTime_ScopeIsLastReconciledTimeOrLaterButUserDoesNotUseLastReconciledTime(t *testing.T) {
|
||||
user := &User{
|
||||
TransactionEditScope: TRANSACTION_EDIT_SCOPE_LAST_RECONCILED_TIME_OR_LATER,
|
||||
UseLastReconciledTime: false,
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
timezone := time.FixedZone("Timezone", int(utils.GetServerTimezoneOffsetMinutes())*60)
|
||||
sourceAccountLastReconciledTime := now.Add(-24 * time.Hour)
|
||||
sourceAccountLastRecondiledUnixTime := sourceAccountLastReconciledTime.Unix()
|
||||
sourceAccount := &Account{
|
||||
Extend: &AccountExtend{
|
||||
LastReconciledTime: &sourceAccountLastRecondiledUnixTime,
|
||||
},
|
||||
}
|
||||
destinationAccountLastReconciledTime := now.Add(-20 * time.Hour)
|
||||
destinationAccountLastReconciledUnixTime := destinationAccountLastReconciledTime.Unix()
|
||||
destinationAccount := &Account{
|
||||
Extend: &AccountExtend{
|
||||
LastReconciledTime: &destinationAccountLastReconciledUnixTime,
|
||||
},
|
||||
}
|
||||
|
||||
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(sourceAccountLastReconciledTime.Add(-1*time.Second).Unix()), timezone, sourceAccount, nil))
|
||||
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(sourceAccountLastReconciledTime.Unix()), timezone, sourceAccount, nil))
|
||||
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(sourceAccountLastReconciledTime.Add(1*time.Second).Unix()), timezone, sourceAccount, nil))
|
||||
|
||||
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(destinationAccountLastReconciledTime.Add(-1*time.Second).Unix()), timezone, sourceAccount, destinationAccount))
|
||||
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(destinationAccountLastReconciledTime.Unix()), timezone, sourceAccount, destinationAccount))
|
||||
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(destinationAccountLastReconciledTime.Add(1*time.Second).Unix()), timezone, sourceAccount, destinationAccount))
|
||||
}
|
||||
|
||||
@@ -592,6 +592,27 @@ func (s *AccountService) ModifyAccounts(c core.Context, mainAccount *models.Acco
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateAccountExtend updates extend field of given account
|
||||
func (s *AccountService) UpdateAccountExtend(c core.Context, uid int64, account *models.Account) error {
|
||||
if uid <= 0 {
|
||||
return errs.ErrUserIdInvalid
|
||||
}
|
||||
|
||||
account.UpdatedUnixTime = time.Now().Unix()
|
||||
|
||||
return s.UserDataDB(uid).DoTransaction(c, func(sess *xorm.Session) error {
|
||||
updatedRows, err := sess.ID(account.AccountId).Cols("extend", "updated_unix_time").Where("uid=? AND deleted=?", uid, false).Update(account)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
} else if updatedRows < 1 {
|
||||
return errs.ErrAccountNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// HideAccount updates hidden field of given accounts
|
||||
func (s *AccountService) HideAccount(c core.Context, uid int64, ids []int64, hidden bool) error {
|
||||
if uid <= 0 {
|
||||
|
||||
+362
-39
@@ -55,7 +55,7 @@ func (s *TransactionService) GetAllTransactions(c core.Context, uid int64, pageC
|
||||
var allTransactions []*models.Transaction
|
||||
|
||||
for maxTransactionTime > 0 {
|
||||
transactions, err := s.GetAllTransactionsByMaxTime(c, uid, maxTransactionTime, pageCount, noDuplicated)
|
||||
transactions, err := s.GetTransactionsByMaxTime(c, uid, maxTransactionTime, 0, 0, nil, nil, nil, false, "", "", false, 1, pageCount, false, noDuplicated)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -74,13 +74,8 @@ func (s *TransactionService) GetAllTransactions(c core.Context, uid int64, pageC
|
||||
return allTransactions, nil
|
||||
}
|
||||
|
||||
// GetAllTransactionsByMaxTime returns all transactions before given time
|
||||
func (s *TransactionService) GetAllTransactionsByMaxTime(c core.Context, uid int64, maxTransactionTime int64, count int32, noDuplicated bool) ([]*models.Transaction, error) {
|
||||
return s.GetTransactionsByMaxTime(c, uid, maxTransactionTime, 0, 0, nil, nil, nil, false, "", "", 1, count, false, noDuplicated)
|
||||
}
|
||||
|
||||
// GetAllSpecifiedTransactions returns all transactions that match given conditions
|
||||
func (s *TransactionService) GetAllSpecifiedTransactions(c core.Context, uid int64, maxTransactionTime int64, minTransactionTime int64, transactionType models.TransactionType, categoryIds []int64, accountIds []int64, tagFilters []*models.TransactionTagFilter, noTags bool, amountFilter string, keyword string, pageCount int32, noDuplicated bool) ([]*models.Transaction, error) {
|
||||
func (s *TransactionService) GetAllSpecifiedTransactions(c core.Context, uid int64, maxTransactionTime int64, minTransactionTime int64, transactionType models.TransactionType, categoryIds []int64, accountIds []int64, tagFilters []*models.TransactionTagFilter, noTags bool, amountFilter string, keyword string, mustHavePictures bool, pageCount int32, noDuplicated bool) ([]*models.Transaction, error) {
|
||||
if maxTransactionTime <= 0 {
|
||||
maxTransactionTime = utils.GetMaxTransactionTimeFromUnixTime(time.Now().Unix())
|
||||
}
|
||||
@@ -88,7 +83,7 @@ func (s *TransactionService) GetAllSpecifiedTransactions(c core.Context, uid int
|
||||
var allTransactions []*models.Transaction
|
||||
|
||||
for maxTransactionTime > 0 {
|
||||
transactions, err := s.GetTransactionsByMaxTime(c, uid, maxTransactionTime, minTransactionTime, transactionType, categoryIds, accountIds, tagFilters, noTags, amountFilter, keyword, 1, pageCount, false, noDuplicated)
|
||||
transactions, err := s.GetTransactionsByMaxTime(c, uid, maxTransactionTime, minTransactionTime, transactionType, categoryIds, accountIds, tagFilters, noTags, amountFilter, keyword, mustHavePictures, 1, pageCount, false, noDuplicated)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -116,7 +111,7 @@ func (s *TransactionService) GetAllTransactionsInOneAccountWithAccountBalanceByM
|
||||
var allTransactions []*models.Transaction
|
||||
|
||||
for maxTransactionTime > 0 {
|
||||
transactions, err := s.GetTransactionsByMaxTime(c, uid, maxTransactionTime, 0, 0, nil, []int64{accountId}, nil, false, "", "", 1, pageCount, false, true)
|
||||
transactions, err := s.GetTransactionsByMaxTime(c, uid, maxTransactionTime, 0, 0, nil, []int64{accountId}, nil, false, "", "", false, 1, pageCount, false, true)
|
||||
|
||||
if err != nil {
|
||||
return nil, 0, 0, 0, 0, err
|
||||
@@ -206,7 +201,7 @@ func (s *TransactionService) GetAllAccountsDailyOpeningAndClosingBalance(c core.
|
||||
var allTransactions []*models.Transaction
|
||||
|
||||
for maxTransactionTime > 0 {
|
||||
transactions, err := s.GetTransactionsByMaxTime(c, uid, maxTransactionTime, 0, 0, nil, nil, nil, false, "", "", 1, pageCountForLoadTransactionAmounts, false, false)
|
||||
transactions, err := s.GetTransactionsByMaxTime(c, uid, maxTransactionTime, 0, 0, nil, nil, nil, false, "", "", false, 1, pageCountForLoadTransactionAmounts, false, false)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -322,8 +317,103 @@ func (s *TransactionService) GetAllAccountsDailyOpeningAndClosingBalance(c core.
|
||||
return accountDailyBalances, nil
|
||||
}
|
||||
|
||||
// GetTransactionsByMaxTimeUpToCount returns transactions before given time and up to given count
|
||||
func (s *TransactionService) GetTransactionsByMaxTimeUpToCount(c core.Context, uid int64, maxTransactionTime int64, minTransactionTime int64, transactionType models.TransactionType, categoryIds []int64, accountIds []int64, tagFilters []*models.TransactionTagFilter, noTags bool, amountFilter string, keyword string, mustHavePictures bool, page int32, count int32, pageCount int32, needOneMoreItem bool, noDuplicated bool) ([]*models.Transaction, error) {
|
||||
if maxTransactionTime <= 0 {
|
||||
maxTransactionTime = utils.GetMaxTransactionTimeFromUnixTime(time.Now().Unix())
|
||||
}
|
||||
|
||||
if page < 0 {
|
||||
return nil, errs.ErrPageIndexInvalid
|
||||
} else if page == 0 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
if count < 1 {
|
||||
return nil, errs.ErrPageCountInvalid
|
||||
}
|
||||
|
||||
finalExpectedCount := int(count)
|
||||
|
||||
if needOneMoreItem {
|
||||
finalExpectedCount++
|
||||
}
|
||||
|
||||
var allTransactions []*models.Transaction
|
||||
startOffset := int((page - 1) * count)
|
||||
firstFetchCount := int(pageCount)
|
||||
|
||||
if finalExpectedCount < firstFetchCount {
|
||||
firstFetchCount = finalExpectedCount
|
||||
}
|
||||
|
||||
transactions, err := s.getTransactionsByMaxTimeWithOffset(c, uid, maxTransactionTime, minTransactionTime, transactionType, categoryIds, accountIds, tagFilters, noTags, amountFilter, keyword, mustHavePictures, startOffset, firstFetchCount, noDuplicated)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
allTransactions = append(allTransactions, transactions...)
|
||||
|
||||
if len(transactions) < firstFetchCount {
|
||||
return allTransactions, nil
|
||||
}
|
||||
|
||||
maxTransactionTime = transactions[len(transactions)-1].TransactionTime - 1
|
||||
|
||||
for len(allTransactions) < finalExpectedCount && maxTransactionTime > 0 {
|
||||
remainingCount := finalExpectedCount - len(allTransactions)
|
||||
fetchCount := int(pageCount)
|
||||
|
||||
if remainingCount < fetchCount {
|
||||
fetchCount = remainingCount
|
||||
}
|
||||
|
||||
transactions, err := s.GetTransactionsByMaxTime(c, uid, maxTransactionTime, minTransactionTime, transactionType, categoryIds, accountIds, tagFilters, noTags, amountFilter, keyword, mustHavePictures, 1, int32(fetchCount), false, noDuplicated)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
allTransactions = append(allTransactions, transactions...)
|
||||
|
||||
if len(transactions) < fetchCount {
|
||||
break
|
||||
}
|
||||
|
||||
maxTransactionTime = transactions[len(transactions)-1].TransactionTime - 1
|
||||
}
|
||||
|
||||
return allTransactions, nil
|
||||
}
|
||||
|
||||
// GetTransactionsByMaxTime returns transactions before given time
|
||||
func (s *TransactionService) GetTransactionsByMaxTime(c core.Context, uid int64, maxTransactionTime int64, minTransactionTime int64, transactionType models.TransactionType, categoryIds []int64, accountIds []int64, tagFilters []*models.TransactionTagFilter, noTags bool, amountFilter string, keyword string, page int32, count int32, needOneMoreItem bool, noDuplicated bool) ([]*models.Transaction, error) {
|
||||
func (s *TransactionService) GetTransactionsByMaxTime(c core.Context, uid int64, maxTransactionTime int64, minTransactionTime int64, transactionType models.TransactionType, categoryIds []int64, accountIds []int64, tagFilters []*models.TransactionTagFilter, noTags bool, amountFilter string, keyword string, mustHavePictures bool, page int32, count int32, needOneMoreItem bool, noDuplicated bool) ([]*models.Transaction, error) {
|
||||
if uid <= 0 {
|
||||
return nil, errs.ErrUserIdInvalid
|
||||
}
|
||||
|
||||
if page < 0 {
|
||||
return nil, errs.ErrPageIndexInvalid
|
||||
} else if page == 0 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
if count < 1 {
|
||||
return nil, errs.ErrPageCountInvalid
|
||||
}
|
||||
|
||||
finalCount := int(count)
|
||||
|
||||
if needOneMoreItem {
|
||||
finalCount++
|
||||
}
|
||||
|
||||
return s.getTransactionsByMaxTimeWithOffset(c, uid, maxTransactionTime, minTransactionTime, transactionType, categoryIds, accountIds, tagFilters, noTags, amountFilter, keyword, mustHavePictures, int(count*(page-1)), finalCount, noDuplicated)
|
||||
}
|
||||
|
||||
// getTransactionsByMaxTimeWithOffset returns transactions before given time with explicit offset and limit
|
||||
func (s *TransactionService) getTransactionsByMaxTimeWithOffset(c core.Context, uid int64, maxTransactionTime int64, minTransactionTime int64, transactionType models.TransactionType, categoryIds []int64, accountIds []int64, tagFilters []*models.TransactionTagFilter, noTags bool, amountFilter string, keyword string, mustHavePictures bool, offset int, limit int, noDuplicated bool) ([]*models.Transaction, error) {
|
||||
if uid <= 0 {
|
||||
return nil, errs.ErrUserIdInvalid
|
||||
}
|
||||
@@ -339,35 +429,20 @@ func (s *TransactionService) GetTransactionsByMaxTime(c core.Context, uid int64,
|
||||
}
|
||||
}
|
||||
|
||||
if page < 0 {
|
||||
return nil, errs.ErrPageIndexInvalid
|
||||
} else if page == 0 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
if count < 1 {
|
||||
return nil, errs.ErrPageCountInvalid
|
||||
}
|
||||
|
||||
var transactions []*models.Transaction
|
||||
|
||||
actualCount := count
|
||||
|
||||
if needOneMoreItem {
|
||||
actualCount++
|
||||
}
|
||||
|
||||
condition, conditionParams := s.buildTransactionQueryCondition(uid, maxTransactionTime, minTransactionTime, transactionDbType, categoryIds, accountIds, tagFilters, amountFilter, keyword, noDuplicated)
|
||||
sess := s.UserDataDB(uid).NewSession(c).Where(condition, conditionParams...)
|
||||
sess = s.appendFilterTagIdsConditionToQuery(sess, uid, maxTransactionTime, minTransactionTime, tagFilters, noTags)
|
||||
sess = s.appendFilterPicturesConditionToQuery(sess, uid, mustHavePictures)
|
||||
|
||||
err = sess.Limit(int(actualCount), int(count*(page-1))).OrderBy("transaction_time desc").Find(&transactions)
|
||||
err = sess.Limit(limit, offset).OrderBy("transaction_time desc").Find(&transactions)
|
||||
|
||||
return transactions, err
|
||||
}
|
||||
|
||||
// GetTransactionsInMonthByPage returns all transactions in given year and month
|
||||
func (s *TransactionService) GetTransactionsInMonthByPage(c core.Context, uid int64, year int32, month int32, transactionType models.TransactionType, categoryIds []int64, accountIds []int64, tagFilters []*models.TransactionTagFilter, noTags bool, amountFilter string, keyword string) ([]*models.Transaction, error) {
|
||||
func (s *TransactionService) GetTransactionsInMonthByPage(c core.Context, uid int64, year int32, month int32, transactionType models.TransactionType, categoryIds []int64, accountIds []int64, tagFilters []*models.TransactionTagFilter, noTags bool, amountFilter string, keyword string, mustHavePictures bool) ([]*models.Transaction, error) {
|
||||
if uid <= 0 {
|
||||
return nil, errs.ErrUserIdInvalid
|
||||
}
|
||||
@@ -394,6 +469,7 @@ func (s *TransactionService) GetTransactionsInMonthByPage(c core.Context, uid in
|
||||
condition, conditionParams := s.buildTransactionQueryCondition(uid, maxTransactionTime, minTransactionTime, transactionDbType, categoryIds, accountIds, tagFilters, amountFilter, keyword, true)
|
||||
sess := s.UserDataDB(uid).NewSession(c).Where(condition, conditionParams...)
|
||||
sess = s.appendFilterTagIdsConditionToQuery(sess, uid, maxTransactionTime, minTransactionTime, tagFilters, noTags)
|
||||
sess = s.appendFilterPicturesConditionToQuery(sess, uid, mustHavePictures)
|
||||
|
||||
err = sess.OrderBy("transaction_time desc").Find(&transactions)
|
||||
|
||||
@@ -434,13 +510,29 @@ func (s *TransactionService) GetTransactionByTransactionId(c core.Context, uid i
|
||||
return transaction, nil
|
||||
}
|
||||
|
||||
// GetTransactionsByTransactionIds returns transaction models according to transaction ids
|
||||
func (s *TransactionService) GetTransactionsByTransactionIds(c core.Context, uid int64, transactionIds []int64) ([]*models.Transaction, error) {
|
||||
if uid <= 0 {
|
||||
return nil, errs.ErrUserIdInvalid
|
||||
}
|
||||
|
||||
if len(transactionIds) <= 0 {
|
||||
return nil, errs.ErrTransactionIdInvalid
|
||||
}
|
||||
|
||||
var transactions []*models.Transaction
|
||||
err := s.UserDataDB(uid).NewSession(c).Where("uid=? AND deleted=?", uid, false).In("transaction_id", transactionIds).Find(&transactions)
|
||||
|
||||
return transactions, err
|
||||
}
|
||||
|
||||
// GetAllTransactionCount returns total count of transactions
|
||||
func (s *TransactionService) GetAllTransactionCount(c core.Context, uid int64) (int64, error) {
|
||||
return s.GetTransactionCount(c, uid, 0, 0, 0, nil, nil, nil, false, "", "")
|
||||
return s.GetTransactionCount(c, uid, 0, 0, 0, nil, nil, nil, false, "", "", false)
|
||||
}
|
||||
|
||||
// GetTransactionCount returns count of transactions
|
||||
func (s *TransactionService) GetTransactionCount(c core.Context, uid int64, maxTransactionTime int64, minTransactionTime int64, transactionType models.TransactionType, categoryIds []int64, accountIds []int64, tagFilters []*models.TransactionTagFilter, noTags bool, amountFilter string, keyword string) (int64, error) {
|
||||
func (s *TransactionService) GetTransactionCount(c core.Context, uid int64, maxTransactionTime int64, minTransactionTime int64, transactionType models.TransactionType, categoryIds []int64, accountIds []int64, tagFilters []*models.TransactionTagFilter, noTags bool, amountFilter string, keyword string, mustHavePictures bool) (int64, error) {
|
||||
if uid <= 0 {
|
||||
return 0, errs.ErrUserIdInvalid
|
||||
}
|
||||
@@ -459,6 +551,7 @@ func (s *TransactionService) GetTransactionCount(c core.Context, uid int64, maxT
|
||||
condition, conditionParams := s.buildTransactionQueryCondition(uid, maxTransactionTime, minTransactionTime, transactionDbType, categoryIds, accountIds, tagFilters, amountFilter, keyword, true)
|
||||
sess := s.UserDataDB(uid).NewSession(c).Where(condition, conditionParams...)
|
||||
sess = s.appendFilterTagIdsConditionToQuery(sess, uid, maxTransactionTime, minTransactionTime, tagFilters, noTags)
|
||||
sess = s.appendFilterPicturesConditionToQuery(sess, uid, mustHavePictures)
|
||||
|
||||
return sess.Count(&models.Transaction{})
|
||||
}
|
||||
@@ -683,7 +776,20 @@ func (s *TransactionService) CreateScheduledTransactions(c core.Context, current
|
||||
|
||||
for i := 0; i < s.UserDataDBCount(); i++ {
|
||||
var templates []*models.TransactionTemplate
|
||||
err := s.UserDataDBByIndex(i).NewSession(c).Where("deleted=? AND template_type=? AND (scheduled_frequency_type=? OR scheduled_frequency_type=?) AND (scheduled_start_time IS NULL OR scheduled_start_time<=?) AND (scheduled_end_time IS NULL OR scheduled_end_time>=?) AND scheduled_at>=? AND scheduled_at<?", false, models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE, models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_WEEKLY, models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_MONTHLY, startTime.Unix(), startTime.Unix(), minScheduledAt, maxScheduledAt).Find(&templates)
|
||||
err := s.UserDataDBByIndex(i).NewSession(c).Where("deleted=?"+
|
||||
" AND template_type=?"+
|
||||
" AND (scheduled_frequency_type=? OR scheduled_frequency_type=? OR scheduled_frequency_type=? OR scheduled_frequency_type=?)"+
|
||||
" AND (scheduled_start_time IS NULL OR scheduled_start_time<=?)"+
|
||||
" AND (scheduled_end_time IS NULL OR scheduled_end_time>=?)"+
|
||||
" AND scheduled_at>=?"+
|
||||
" AND scheduled_at<?",
|
||||
false,
|
||||
models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE,
|
||||
models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_WEEKLY, models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_MONTHLY, models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_DAILY, models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_YEARLY,
|
||||
startTime.Unix(),
|
||||
startTime.Unix(),
|
||||
minScheduledAt,
|
||||
maxScheduledAt).Find(&templates)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -712,7 +818,9 @@ func (s *TransactionService) CreateScheduledTransactions(c core.Context, current
|
||||
}
|
||||
|
||||
if (template.ScheduledFrequencyType != models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_WEEKLY &&
|
||||
template.ScheduledFrequencyType != models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_MONTHLY) ||
|
||||
template.ScheduledFrequencyType != models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_MONTHLY &&
|
||||
template.ScheduledFrequencyType != models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_DAILY &&
|
||||
template.ScheduledFrequencyType != models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_YEARLY) ||
|
||||
template.ScheduledFrequency == "" {
|
||||
skipCount++
|
||||
log.Warnf(c, "[transactions.CreateScheduledTransactions] transaction template \"id:%d\" has invalid scheduled transaction frequency", template.TemplateId)
|
||||
@@ -727,6 +835,16 @@ func (s *TransactionService) CreateScheduledTransactions(c core.Context, current
|
||||
continue
|
||||
}
|
||||
|
||||
if template.ScheduledFrequencyType == models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_MONTHLY {
|
||||
maxDayInMonth := utils.GetMaxDayOfMonth(currentTime.Year(), currentTime.Month())
|
||||
|
||||
for i := 0; i < len(frequencyValues); i++ {
|
||||
if frequencyValues[i] < 0 {
|
||||
frequencyValues[i] = int64(maxDayInMonth) + frequencyValues[i] + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
frequencyValueSet := utils.ToSet(frequencyValues)
|
||||
templateTimeZone := time.FixedZone("Template Timezone", int(template.ScheduledTimezoneUtcOffset)*60)
|
||||
transactionUnixTime := todayFirstUnixTimeInUTC + int64(template.ScheduledAt)*60
|
||||
@@ -740,6 +858,10 @@ func (s *TransactionService) CreateScheduledTransactions(c core.Context, current
|
||||
skipCount++
|
||||
log.Infof(c, "[transactions.CreateScheduledTransactions] transaction template \"id:%d\" does not need to create transaction, today is %d of month", template.TemplateId, startTimeInUTC.Day())
|
||||
continue
|
||||
} else if template.ScheduledFrequencyType == models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_YEARLY && !frequencyValueSet[int64(transactionTime.Month())*100+int64(transactionTime.Day())] {
|
||||
skipCount++
|
||||
log.Infof(c, "[transactions.CreateScheduledTransactions] transaction template \"id:%d\" does not need to create transaction, today is %d-%d of year", template.TemplateId, startTimeInUTC.Month(), startTimeInUTC.Day())
|
||||
continue
|
||||
}
|
||||
|
||||
if template.ScheduledStartTime != nil && *template.ScheduledStartTime > transactionUnixTime {
|
||||
@@ -778,7 +900,7 @@ func (s *TransactionService) CreateScheduledTransactions(c core.Context, current
|
||||
Amount: template.Amount,
|
||||
HideAmount: template.HideAmount,
|
||||
Comment: template.Comment,
|
||||
CreatedIp: "127.0.0.1",
|
||||
CreatedIp: c.ClientIP(),
|
||||
ScheduledCreated: true,
|
||||
}
|
||||
|
||||
@@ -988,7 +1110,7 @@ func (s *TransactionService) ModifyTransaction(c core.Context, transaction *mode
|
||||
}
|
||||
|
||||
// Get and verify tags
|
||||
err = s.isTagsValid(sess, transaction, transactionTagIndexes, addTagIds)
|
||||
err = s.isTagsValid(sess, transaction.Uid, transactionTagIndexes, addTagIds)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -1293,6 +1415,196 @@ func (s *TransactionService) ModifyTransaction(c core.Context, transaction *mode
|
||||
return nil
|
||||
}
|
||||
|
||||
// BatchUpdateTransactionsCategory batch updates the categories of transactions
|
||||
func (s *TransactionService) BatchUpdateTransactionsCategory(c core.Context, uid int64, transactionIds []int64, newCategoryId int64) error {
|
||||
if uid <= 0 {
|
||||
return errs.ErrUserIdInvalid
|
||||
}
|
||||
|
||||
if len(transactionIds) < 1 {
|
||||
return errs.ErrTransactionIdInvalid
|
||||
}
|
||||
|
||||
if newCategoryId <= 0 {
|
||||
return errs.ErrTransactionCategoryIdInvalid
|
||||
}
|
||||
|
||||
uniqueTransactionIds := utils.ToUniqueInt64Slice(transactionIds)
|
||||
now := time.Now().Unix()
|
||||
|
||||
updateModel := &models.Transaction{
|
||||
CategoryId: newCategoryId,
|
||||
UpdatedUnixTime: now,
|
||||
}
|
||||
|
||||
return s.UserDataDB(uid).DoTransaction(c, func(sess *xorm.Session) error {
|
||||
updatedRows, err := sess.Cols("category_id", "updated_unix_time").Where("uid=? AND deleted=?", uid, false).In("transaction_id", uniqueTransactionIds).Update(updateModel)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
} else if updatedRows < int64(len(uniqueTransactionIds)) {
|
||||
return errs.ErrTransactionNotFound
|
||||
}
|
||||
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// BatchAddTagsToTransactions batch adds tags to transactions
|
||||
func (s *TransactionService) BatchAddTagsToTransactions(c core.Context, uid int64, transactions []*models.Transaction, addTransactionTagIds map[int64][]int64) error {
|
||||
if uid <= 0 {
|
||||
return errs.ErrUserIdInvalid
|
||||
}
|
||||
|
||||
if len(addTransactionTagIds) < 1 {
|
||||
return errs.ErrTransactionIdInvalid
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
transactionTagIndexes := make([]*models.TransactionTagIndex, 0, len(addTransactionTagIds))
|
||||
transactionsMap := make(map[int64]*models.Transaction, len(transactions))
|
||||
transactionTagIdsMap := make(map[int64]bool, 0)
|
||||
|
||||
for i := 0; i < len(transactions); i++ {
|
||||
transaction := transactions[i]
|
||||
transactionsMap[transaction.TransactionId] = transaction
|
||||
}
|
||||
|
||||
for transactionId, tagIds := range addTransactionTagIds {
|
||||
if transactionId <= 0 {
|
||||
return errs.ErrTransactionIdInvalid
|
||||
}
|
||||
|
||||
transaction, exists := transactionsMap[transactionId]
|
||||
|
||||
if !exists || transaction == nil {
|
||||
return errs.ErrTransactionNotFound
|
||||
}
|
||||
|
||||
tagIds = utils.ToUniqueInt64Slice(tagIds)
|
||||
|
||||
for i := 0; i < len(tagIds); i++ {
|
||||
tagId := tagIds[i]
|
||||
|
||||
if tagId <= 0 {
|
||||
return errs.ErrTransactionTagIdInvalid
|
||||
}
|
||||
|
||||
transactionTagIndexes = append(transactionTagIndexes, &models.TransactionTagIndex{
|
||||
Uid: uid,
|
||||
Deleted: false,
|
||||
TransactionTime: transaction.TransactionTime,
|
||||
TagId: tagId,
|
||||
TransactionId: transactionId,
|
||||
CreatedUnixTime: now,
|
||||
UpdatedUnixTime: now,
|
||||
})
|
||||
|
||||
transactionTagIdsMap[tagId] = true
|
||||
}
|
||||
}
|
||||
|
||||
tagIndexUuids := s.GenerateUuids(uuid.UUID_TYPE_TAG_INDEX, uint16(len(transactionTagIndexes)))
|
||||
|
||||
if len(tagIndexUuids) < len(transactionTagIndexes) {
|
||||
return errs.ErrCannotAddTagsToTooManyTransactionsOneTime
|
||||
}
|
||||
|
||||
for i := 0; i < len(transactionTagIndexes); i++ {
|
||||
transactionTagIndexes[i].TagIndexId = tagIndexUuids[i]
|
||||
}
|
||||
|
||||
tagIds := make([]int64, 0, len(transactionTagIdsMap))
|
||||
|
||||
for tagId := range transactionTagIdsMap {
|
||||
tagIds = append(tagIds, tagId)
|
||||
}
|
||||
|
||||
return s.UserDataDB(uid).DoTransaction(c, func(sess *xorm.Session) error {
|
||||
// Get and verify tags
|
||||
err := s.isTagsValid(sess, uid, transactionTagIndexes, tagIds)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for i := 0; i < len(transactionTagIndexes); i++ {
|
||||
transactionTagIndex := transactionTagIndexes[i]
|
||||
_, err := sess.Insert(transactionTagIndex)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// BatchRemoveTagsFromTransactions batch removes tags from transactions
|
||||
func (s *TransactionService) BatchRemoveTagsFromTransactions(c core.Context, uid int64, transactionIds []int64, tagIds []int64) error {
|
||||
if uid <= 0 {
|
||||
return errs.ErrUserIdInvalid
|
||||
}
|
||||
|
||||
if len(transactionIds) < 1 {
|
||||
return errs.ErrTransactionIdInvalid
|
||||
}
|
||||
|
||||
uniqueTransactionIds := utils.ToUniqueInt64Slice(transactionIds)
|
||||
uniqueTagIds := utils.ToUniqueInt64Slice(tagIds)
|
||||
now := time.Now().Unix()
|
||||
|
||||
tagIndexUpdateModel := &models.TransactionTagIndex{
|
||||
Deleted: true,
|
||||
DeletedUnixTime: now,
|
||||
}
|
||||
|
||||
return s.UserDataDB(uid).DoTransaction(c, func(sess *xorm.Session) error {
|
||||
deletedRows, err := sess.Cols("deleted", "deleted_unix_time").Where("uid=? AND deleted=?", uid, false).In("transaction_id", uniqueTransactionIds).In("tag_id", uniqueTagIds).Update(tagIndexUpdateModel)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
} else if deletedRows < 1 {
|
||||
return errs.ErrTransactionTagNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// BatchClearAllTagsFromTransactions batch clears all tags from transactions
|
||||
func (s *TransactionService) BatchClearAllTagsFromTransactions(c core.Context, uid int64, transactionIds []int64) error {
|
||||
if uid <= 0 {
|
||||
return errs.ErrUserIdInvalid
|
||||
}
|
||||
|
||||
if len(transactionIds) < 1 {
|
||||
return errs.ErrTransactionIdInvalid
|
||||
}
|
||||
|
||||
uniqueTransactionIds := utils.ToUniqueInt64Slice(transactionIds)
|
||||
now := time.Now().Unix()
|
||||
|
||||
tagIndexUpdateModel := &models.TransactionTagIndex{
|
||||
Deleted: true,
|
||||
DeletedUnixTime: now,
|
||||
}
|
||||
|
||||
return s.UserDataDB(uid).DoTransaction(c, func(sess *xorm.Session) error {
|
||||
deletedRows, err := sess.Cols("deleted", "deleted_unix_time").Where("uid=? AND deleted=?", uid, false).In("transaction_id", uniqueTransactionIds).Update(tagIndexUpdateModel)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
} else if deletedRows < 1 {
|
||||
return errs.ErrTransactionTagNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// MoveAllTransactionsBetweenAccounts moves all transactions from one account to another account, and combine balance modification transactions if necessary
|
||||
func (s *TransactionService) MoveAllTransactionsBetweenAccounts(c core.Context, uid int64, fromAccountId int64, toAccountId int64) error {
|
||||
if uid <= 0 {
|
||||
return errs.ErrUserIdInvalid
|
||||
@@ -1729,7 +2041,7 @@ func (s *TransactionService) DeleteAllTransactionsOfAccount(c core.Context, uid
|
||||
return errs.ErrAccountIdInvalid
|
||||
}
|
||||
|
||||
transactions, err := s.GetAllSpecifiedTransactions(c, uid, 0, 0, 0, nil, []int64{accountId}, nil, false, "", "", pageCount, true)
|
||||
transactions, err := s.GetAllSpecifiedTransactions(c, uid, 0, 0, 0, nil, []int64{accountId}, nil, false, "", "", false, pageCount, true)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -2236,7 +2548,7 @@ func (s *TransactionService) doCreateTransaction(c core.Context, database *datas
|
||||
}
|
||||
|
||||
// Get and verify tags
|
||||
err = s.isTagsValid(sess, transaction, transactionTagIndexes, tagIds)
|
||||
err = s.isTagsValid(sess, transaction.Uid, transactionTagIndexes, tagIds)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -2662,6 +2974,17 @@ func (s *TransactionService) appendFilterTagIdsConditionToQuery(sess *xorm.Sessi
|
||||
return sess
|
||||
}
|
||||
|
||||
func (s *TransactionService) appendFilterPicturesConditionToQuery(sess *xorm.Session, uid int64, mustHavePictures bool) *xorm.Session {
|
||||
if !mustHavePictures {
|
||||
return sess
|
||||
}
|
||||
|
||||
subQuery := builder.Select("transaction_id").From("transaction_picture_info").Where(builder.And(builder.Eq{"uid": uid}, builder.Eq{"deleted": false}, builder.Neq{"transaction_id": models.TransactionPictureNewPictureTransactionId}))
|
||||
sess.And(builder.Or(builder.In("transaction_id", subQuery), builder.In("related_id", subQuery)))
|
||||
|
||||
return sess
|
||||
}
|
||||
|
||||
func (s *TransactionService) isAccountIdValid(transaction *models.Transaction) error {
|
||||
if transaction.Type == models.TRANSACTION_DB_TYPE_MODIFY_BALANCE {
|
||||
if transaction.RelatedAccountId != 0 && transaction.RelatedAccountId != transaction.AccountId {
|
||||
@@ -2873,10 +3196,10 @@ func (s *TransactionService) isCategoryValid(sess *xorm.Session, transaction *mo
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *TransactionService) isTagsValid(sess *xorm.Session, transaction *models.Transaction, transactionTagIndexes []*models.TransactionTagIndex, tagIds []int64) error {
|
||||
func (s *TransactionService) isTagsValid(sess *xorm.Session, uid int64, transactionTagIndexes []*models.TransactionTagIndex, tagIds []int64) error {
|
||||
if len(transactionTagIndexes) > 0 {
|
||||
var tags []*models.TransactionTag
|
||||
err := sess.Where("uid=? AND deleted=?", transaction.Uid, false).In("tag_id", tagIds).Find(&tags)
|
||||
err := sess.Where("uid=? AND deleted=?", uid, false).In("tag_id", tagIds).Find(&tags)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -234,7 +234,7 @@ func (s *UserService) CreateUser(c core.Context, user *models.User, noPassword b
|
||||
}
|
||||
|
||||
// UpdateUser saves an existed user model to database
|
||||
func (s *UserService) UpdateUser(c core.Context, user *models.User, modifyUserLanguage bool) (keyProfileUpdated bool, emailSetToUnverified bool, err error) {
|
||||
func (s *UserService) UpdateUser(c core.Context, user *models.User, modifyUserLanguage bool, modifyUseLastReconciledTime bool) (keyProfileUpdated bool, emailSetToUnverified bool, err error) {
|
||||
if user.Uid <= 0 {
|
||||
return false, false, errs.ErrUserIdInvalid
|
||||
}
|
||||
@@ -277,7 +277,11 @@ func (s *UserService) UpdateUser(c core.Context, user *models.User, modifyUserLa
|
||||
updateCols = append(updateCols, "default_account_id")
|
||||
}
|
||||
|
||||
if models.TRANSACTION_EDIT_SCOPE_NONE <= user.TransactionEditScope && user.TransactionEditScope <= models.TRANSACTION_EDIT_SCOPE_THIS_YEAR_OR_LATER {
|
||||
if modifyUseLastReconciledTime {
|
||||
updateCols = append(updateCols, "use_last_reconciled_time")
|
||||
}
|
||||
|
||||
if models.TRANSACTION_EDIT_SCOPE_NONE <= user.TransactionEditScope && user.TransactionEditScope <= models.TRANSACTION_EDIT_SCOPE_LAST_RECONCILED_TIME_OR_LATER {
|
||||
updateCols = append(updateCols, "transaction_edit_scope")
|
||||
}
|
||||
|
||||
|
||||
+18
-16
@@ -128,22 +128,23 @@ const (
|
||||
|
||||
// Exchange rates data source types
|
||||
const (
|
||||
BankOfCanadaDataSource string = "bank_of_canada"
|
||||
CzechNationalBankDataSource string = "czech_national_bank"
|
||||
DanmarksNationalbankDataSource string = "danmarks_national_bank"
|
||||
EuroCentralBankDataSource string = "euro_central_bank"
|
||||
NationalBankOfGeorgiaDataSource string = "national_bank_of_georgia"
|
||||
CentralBankOfHungaryDataSource string = "central_bank_of_hungary"
|
||||
BankOfIsraelDataSource string = "bank_of_israel"
|
||||
CentralBankOfMyanmarDataSource string = "central_bank_of_myanmar"
|
||||
NorgesBankDataSource string = "norges_bank"
|
||||
NationalBankOfPolandDataSource string = "national_bank_of_poland"
|
||||
NationalBankOfRomaniaDataSource string = "national_bank_of_romania"
|
||||
BankOfRussiaDataSource string = "bank_of_russia"
|
||||
SwissNationalBankDataSource string = "swiss_national_bank"
|
||||
NationalBankOfUkraineDataSource string = "national_bank_of_ukraine"
|
||||
CentralBankOfUzbekistanDataSource string = "central_bank_of_uzbekistan"
|
||||
UserCustomExchangeRatesDataSource string = "user_custom"
|
||||
BankOfCanadaDataSource string = "bank_of_canada"
|
||||
CzechNationalBankDataSource string = "czech_national_bank"
|
||||
DanmarksNationalbankDataSource string = "danmarks_national_bank"
|
||||
EuroCentralBankDataSource string = "euro_central_bank"
|
||||
NationalBankOfGeorgiaDataSource string = "national_bank_of_georgia"
|
||||
CentralBankOfHungaryDataSource string = "central_bank_of_hungary"
|
||||
BankOfIsraelDataSource string = "bank_of_israel"
|
||||
NationalBankOfKazakhstanDataSource string = "national_bank_of_kazakhstan"
|
||||
CentralBankOfMyanmarDataSource string = "central_bank_of_myanmar"
|
||||
NorgesBankDataSource string = "norges_bank"
|
||||
NationalBankOfPolandDataSource string = "national_bank_of_poland"
|
||||
NationalBankOfRomaniaDataSource string = "national_bank_of_romania"
|
||||
BankOfRussiaDataSource string = "bank_of_russia"
|
||||
SwissNationalBankDataSource string = "swiss_national_bank"
|
||||
NationalBankOfUkraineDataSource string = "national_bank_of_ukraine"
|
||||
CentralBankOfUzbekistanDataSource string = "central_bank_of_uzbekistan"
|
||||
UserCustomExchangeRatesDataSource string = "user_custom"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -1196,6 +1197,7 @@ func loadExchangeRatesConfiguration(config *Config, configFile *ini.File, sectio
|
||||
dataSource == NationalBankOfGeorgiaDataSource ||
|
||||
dataSource == CentralBankOfHungaryDataSource ||
|
||||
dataSource == BankOfIsraelDataSource ||
|
||||
dataSource == NationalBankOfKazakhstanDataSource ||
|
||||
dataSource == CentralBankOfMyanmarDataSource ||
|
||||
dataSource == NorgesBankDataSource ||
|
||||
dataSource == NationalBankOfPolandDataSource ||
|
||||
|
||||
@@ -286,6 +286,12 @@ func IsUnixTimeEqualsYearAndMonth(unixTime int64, timezone *time.Location, year
|
||||
return date.Year() == int(year) && int(date.Month()) == int(month)
|
||||
}
|
||||
|
||||
// GetMaxDayOfMonth returns the maximum day of the month for the specified year and month
|
||||
func GetMaxDayOfMonth(year int, month time.Month) int {
|
||||
t := time.Date(year, month+1, 0, 0, 0, 0, 0, time.UTC)
|
||||
return t.Day()
|
||||
}
|
||||
|
||||
// GetTimezoneOffsetMinutes returns offset minutes according specified timezone
|
||||
func GetTimezoneOffsetMinutes(unixTime int64, timezone *time.Location) int16 {
|
||||
_, tzOffset := parseFromUnixTime(unixTime).In(timezone).Zone()
|
||||
|
||||
@@ -333,6 +333,32 @@ func TestIsUnixTimeEqualsYearAndMonth(t *testing.T) {
|
||||
assert.Equal(t, false, actualValue)
|
||||
}
|
||||
|
||||
func TestGetMaxDayOfMonth(t *testing.T) {
|
||||
expectedValue := 31
|
||||
actualValue := GetMaxDayOfMonth(2023, 1)
|
||||
assert.Equal(t, expectedValue, actualValue)
|
||||
|
||||
expectedValue = 28
|
||||
actualValue = GetMaxDayOfMonth(2023, 2)
|
||||
assert.Equal(t, expectedValue, actualValue)
|
||||
|
||||
expectedValue = 29
|
||||
actualValue = GetMaxDayOfMonth(2024, 2)
|
||||
assert.Equal(t, expectedValue, actualValue)
|
||||
|
||||
expectedValue = 30
|
||||
actualValue = GetMaxDayOfMonth(2023, 4)
|
||||
assert.Equal(t, expectedValue, actualValue)
|
||||
|
||||
expectedValue = 31
|
||||
actualValue = GetMaxDayOfMonth(2023, 12)
|
||||
assert.Equal(t, expectedValue, actualValue)
|
||||
|
||||
expectedValue = 28
|
||||
actualValue = GetMaxDayOfMonth(2100, 2)
|
||||
assert.Equal(t, expectedValue, actualValue)
|
||||
}
|
||||
|
||||
func TestGetTimezoneOffsetMinutes_FixedTimezone(t *testing.T) {
|
||||
timezone := time.FixedZone("Test Timezone", 120*60)
|
||||
expectedValue := int16(120)
|
||||
|
||||
@@ -292,7 +292,7 @@ $API_CONFIGS = @(
|
||||
Path = "transactions/list.json"
|
||||
RequiresTimezone = $true
|
||||
RequiredParams = @("count")
|
||||
OptionalParams = @("type", "category_ids", "account_ids", "tag_filter", "amount_filter", "keyword", "max_time", "min_time", "page", "with_count", "with_pictures", "trim_account", "trim_category", "trim_tag")
|
||||
OptionalParams = @("type", "category_ids", "account_ids", "tag_filter", "amount_filter", "keyword", "must_have_pictures", "max_time", "min_time", "page", "with_count", "with_pictures", "trim_account", "trim_category", "trim_tag")
|
||||
ParamTypes = @{
|
||||
"count" = "integer"
|
||||
"type" = "integer"
|
||||
@@ -301,6 +301,7 @@ $API_CONFIGS = @(
|
||||
"tag_filter" = "string"
|
||||
"amount_filter" = "string"
|
||||
"keyword" = "string"
|
||||
"must_have_pictures" = "boolean"
|
||||
"max_time" = "integer"
|
||||
"min_time" = "integer"
|
||||
"page" = "integer"
|
||||
@@ -318,6 +319,7 @@ $API_CONFIGS = @(
|
||||
"tag_filter" = "string (Filter by tags)"
|
||||
"amount_filter" = "string (Filter by amount)"
|
||||
"keyword" = "string (Filter by keyword)"
|
||||
"must_have_pictures" = "boolean (Whether to only get transactions with pictures)"
|
||||
"max_time" = "integer (The maximum time sequence ID, Set to 0 for latest)"
|
||||
"min_time" = "integer (The minimum time sequence ID)"
|
||||
"page" = "integer (Specified page integer)"
|
||||
@@ -374,7 +376,7 @@ $API_CONFIGS = @(
|
||||
Path = "transactions/list/all.json"
|
||||
RequiresTimezone = $true
|
||||
RequiredParams = @()
|
||||
OptionalParams = @("type", "category_ids", "account_ids", "tag_filter", "amount_filter", "keyword", "start_time", "end_time", "with_pictures", "trim_account", "trim_category", "trim_tag")
|
||||
OptionalParams = @("type", "category_ids", "account_ids", "tag_filter", "amount_filter", "keyword", "must_have_pictures", "start_time", "end_time", "with_pictures", "trim_account", "trim_category", "trim_tag")
|
||||
ParamTypes = @{
|
||||
"type" = "integer"
|
||||
"category_ids" = "string"
|
||||
@@ -382,6 +384,7 @@ $API_CONFIGS = @(
|
||||
"tag_filter" = "string"
|
||||
"amount_filter" = "string"
|
||||
"keyword" = "string"
|
||||
"must_have_pictures" = "boolean"
|
||||
"start_time" = "integer"
|
||||
"end_time" = "integer"
|
||||
"with_pictures" = "boolean"
|
||||
@@ -396,6 +399,7 @@ $API_CONFIGS = @(
|
||||
"tag_filter" = "string (Filter by tags)"
|
||||
"amount_filter" = "string (Filter by amount)"
|
||||
"keyword" = "string (Filter by keyword)"
|
||||
"must_have_pictures" = "boolean (Whether to only get transactions with pictures)"
|
||||
"start_time" = "integer (Transaction list start unix time)"
|
||||
"end_time" = "integer (Transaction list end unix time)"
|
||||
"with_pictures" = "boolean (Whether to get picture IDs)"
|
||||
@@ -1305,7 +1309,7 @@ function Parse-CommandArgs {
|
||||
}
|
||||
"boolean" {
|
||||
if ($paramValue -match "^(true|false|1|0)$") {
|
||||
$params[$paramName] = ($paramValue -eq "true" -or $paramValue -eq "1")
|
||||
$params[$paramName] = ($paramValue -eq "true" -or $paramValue -eq "1").ToString().ToLower()
|
||||
} else {
|
||||
Write-Red "Error: Parameter '-$paramName' must be a boolean value (true/false or 1/0)"
|
||||
exit 1
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
API_CONFIGS='[
|
||||
{
|
||||
"Name": "tokens-list",
|
||||
"Description": "Retrieve all sessions for the current user",
|
||||
"Description": "Retrieve all sessions for the current user",
|
||||
"Method": "GET",
|
||||
"Path": "tokens/list.json",
|
||||
"RequiresTimezone": false,
|
||||
@@ -272,7 +272,7 @@ API_CONFIGS='[
|
||||
"Path": "transactions/list.json",
|
||||
"RequiresTimezone": true,
|
||||
"RequiredParams": ["count"],
|
||||
"OptionalParams": ["type", "category_ids", "account_ids", "tag_filter", "amount_filter", "keyword", "max_time", "min_time", "page", "with_count", "with_pictures", "trim_account", "trim_category", "trim_tag"],
|
||||
"OptionalParams": ["type", "category_ids", "account_ids", "tag_filter", "amount_filter", "keyword", "must_have_pictures", "max_time", "min_time", "page", "with_count", "with_pictures", "trim_account", "trim_category", "trim_tag"],
|
||||
"ParamTypes": {
|
||||
"count": "integer",
|
||||
"type": "integer",
|
||||
@@ -281,6 +281,7 @@ API_CONFIGS='[
|
||||
"tag_filter": "string",
|
||||
"amount_filter": "string",
|
||||
"keyword": "string",
|
||||
"must_have_pictures": "boolean",
|
||||
"max_time": "integer",
|
||||
"min_time": "integer",
|
||||
"page": "integer",
|
||||
@@ -298,6 +299,7 @@ API_CONFIGS='[
|
||||
"tag_filter": "string (Filter by tags)",
|
||||
"amount_filter": "string (Filter by amount)",
|
||||
"keyword": "string (Filter by keyword)",
|
||||
"must_have_pictures": "boolean (Whether to only get transactions with pictures)",
|
||||
"max_time": "integer (The maximum time sequence ID, Set to 0 for latest)",
|
||||
"min_time": "integer (The minimum time sequence ID)",
|
||||
"page": "integer (Specified page integer)",
|
||||
@@ -354,7 +356,7 @@ API_CONFIGS='[
|
||||
"Path": "transactions/list/all.json",
|
||||
"RequiresTimezone": true,
|
||||
"RequiredParams": [],
|
||||
"OptionalParams": ["type", "category_ids", "account_ids", "tag_filter", "amount_filter", "keyword", "start_time", "end_time", "with_pictures", "trim_account", "trim_category", "trim_tag"],
|
||||
"OptionalParams": ["type", "category_ids", "account_ids", "tag_filter", "amount_filter", "keyword", "must_have_pictures", "start_time", "end_time", "with_pictures", "trim_account", "trim_category", "trim_tag"],
|
||||
"ParamTypes": {
|
||||
"type": "integer",
|
||||
"category_ids": "string",
|
||||
@@ -362,6 +364,7 @@ API_CONFIGS='[
|
||||
"tag_filter": "string",
|
||||
"amount_filter": "string",
|
||||
"keyword": "string",
|
||||
"must_have_pictures": "boolean",
|
||||
"start_time": "integer",
|
||||
"end_time": "integer",
|
||||
"with_pictures": "boolean",
|
||||
@@ -376,6 +379,7 @@ API_CONFIGS='[
|
||||
"tag_filter": "string (Filter by tags)",
|
||||
"amount_filter": "string (Filter by amount)",
|
||||
"keyword": "string (Filter by keyword)",
|
||||
"must_have_pictures": "boolean (Whether to only get transactions with pictures)",
|
||||
"start_time": "integer (Transaction list start unix time)",
|
||||
"end_time": "integer (Transaction list end unix time)",
|
||||
"with_pictures": "boolean (Whether to get picture IDs)",
|
||||
@@ -578,8 +582,11 @@ load_env_file() {
|
||||
value="$(echo "$value" | sed -e 's/^["'"'"']//' -e 's/["'"'"']$//')"
|
||||
|
||||
case "$key" in
|
||||
EBKTOOL_SERVER_BASEURL|EBKTOOL_TOKEN)
|
||||
eval "$key=\"\$value\""
|
||||
EBKTOOL_SERVER_BASEURL)
|
||||
EBKTOOL_SERVER_BASEURL="$value"
|
||||
;;
|
||||
EBKTOOL_TOKEN)
|
||||
EBKTOOL_TOKEN="$value"
|
||||
;;
|
||||
esac
|
||||
done < "$env_file"
|
||||
@@ -1124,7 +1131,7 @@ call_api() {
|
||||
if [ "$json_params" != "{}" ]; then
|
||||
if [ -n "$timezone_headers" ]; then
|
||||
response="$(curl -s -X "POST" \
|
||||
-H "Authorization: Bearer $EBKTOOL_TOKEN" \
|
||||
-H "Authorization: Bearer $authToken" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "$timezone_headers" \
|
||||
-d "$json_params" \
|
||||
@@ -1132,7 +1139,7 @@ call_api() {
|
||||
curl_exit_code=$?
|
||||
else
|
||||
response="$(curl -s -X "POST" \
|
||||
-H "Authorization: Bearer $EBKTOOL_TOKEN" \
|
||||
-H "Authorization: Bearer $authToken" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$json_params" \
|
||||
"$url")"
|
||||
@@ -1141,13 +1148,13 @@ call_api() {
|
||||
else
|
||||
if [ -n "$timezone_headers" ]; then
|
||||
response="$(curl -s -X "POST" \
|
||||
-H "Authorization: Bearer $EBKTOOL_TOKEN" \
|
||||
-H "Authorization: Bearer $authToken" \
|
||||
-H "$timezone_headers" \
|
||||
"$url")"
|
||||
curl_exit_code=$?
|
||||
else
|
||||
response="$(curl -s -X "POST" \
|
||||
-H "Authorization: Bearer $EBKTOOL_TOKEN" \
|
||||
-H "Authorization: Bearer $authToken" \
|
||||
"$url")"
|
||||
curl_exit_code=$?
|
||||
fi
|
||||
@@ -1162,12 +1169,12 @@ call_api() {
|
||||
|
||||
if [ -n "$timezone_headers" ]; then
|
||||
response="$(curl -s -X "$method" \
|
||||
-H "Authorization: Bearer $EBKTOOL_TOKEN" \
|
||||
-H "Authorization: Bearer $authToken" \
|
||||
-H "$timezone_headers" \
|
||||
"$url")"
|
||||
else
|
||||
response="$(curl -s -X "$method" \
|
||||
-H "Authorization: Bearer $EBKTOOL_TOKEN" \
|
||||
-H "Authorization: Bearer $authToken" \
|
||||
"$url")"
|
||||
fi
|
||||
curl_exit_code=$?
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { computed } from 'vue';
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
import { useI18n } from '@/locales/helpers.ts';
|
||||
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
type YearUnixTime,
|
||||
type YearQuarterUnixTime,
|
||||
type YearMonthUnixTime,
|
||||
YearMonthDayUnixTime
|
||||
YearMonthDayUnixTime,
|
||||
DateRange
|
||||
} from '@/core/datetime.ts';
|
||||
import type { FiscalYearUnixTime } from '@/core/fiscalyear.ts';
|
||||
import { ChartDateAggregationType } from '@/core/statistics.ts';
|
||||
@@ -16,7 +17,11 @@ import type { AccountInfoResponse } from '@/models/account.ts';
|
||||
import type { TransactionReconciliationStatementResponseItem } from '@/models/transaction.ts';
|
||||
|
||||
import { isArray } from '@/lib/common.ts';
|
||||
import { sumAmounts } from '@/lib/numeral.ts';
|
||||
import {
|
||||
mean,
|
||||
median,
|
||||
percentile
|
||||
} from '@/lib/math.ts';
|
||||
import {
|
||||
parseDateTimeFromUnixTime,
|
||||
getGregorianCalendarYearAndMonthFromUnixTime,
|
||||
@@ -24,11 +29,16 @@ import {
|
||||
getQuarterFirstTimeTimeBySpecifiedUnixTime,
|
||||
getMonthFirstDateTimeBySpecifiedUnixTime,
|
||||
getDayFirstDateTimeBySpecifiedUnixTime,
|
||||
getBillingCycleLastUnixTimeBySpecifiedUnixTime,
|
||||
getAllDaysStartAndEndUnixTimes,
|
||||
getAllBillingCyclesStartAndEndUnixTimes,
|
||||
getFiscalYearStartDateTime
|
||||
} from '@/lib/datetime.ts';
|
||||
import { TimezoneTypeForStatistics } from '@/core/timezone.ts';
|
||||
import { getAllDateRangesByYearMonthRange } from '@/lib/statistics.ts';
|
||||
import {
|
||||
getAllDateRangesByYearMonthRange,
|
||||
getDateRangeKeyWithYearOffset
|
||||
} from '@/lib/statistics.ts';
|
||||
|
||||
export interface AccountBalanceUnixTimeAndBalanceRange extends UnixTimeRange {
|
||||
minUnixTimeOpeningBalance: number;
|
||||
@@ -37,7 +47,10 @@ export interface AccountBalanceUnixTimeAndBalanceRange extends UnixTimeRange {
|
||||
}
|
||||
|
||||
export interface AccountBalanceTrendsChartItem {
|
||||
dateRangeKey: string;
|
||||
lastYearDateRangeKey: string;
|
||||
displayDate: string;
|
||||
alternativeDisplayDate: string;
|
||||
openingBalance: number;
|
||||
closingBalance: number;
|
||||
minimumBalance: number;
|
||||
@@ -54,6 +67,7 @@ export interface CommonAccountBalanceTrendsChartProps {
|
||||
timezoneUsedForDateRange: number;
|
||||
fiscalYearStart: number;
|
||||
account: AccountInfoResponse;
|
||||
statementDate: number | undefined;
|
||||
}
|
||||
|
||||
export function useAccountBalanceTrendsChartBase(props: CommonAccountBalanceTrendsChartProps) {
|
||||
@@ -62,9 +76,13 @@ export function useAccountBalanceTrendsChartBase(props: CommonAccountBalanceTren
|
||||
formatDateTimeToGregorianLikeShortYear,
|
||||
formatDateTimeToGregorianLikeShortYearMonth,
|
||||
formatDateTimeToGregorianLikeYearQuarter,
|
||||
formatDateTimeToGregorianLikeFiscalYear
|
||||
formatDateTimeToGregorianLikeFiscalYear,
|
||||
formatDateRange
|
||||
} = useI18n();
|
||||
|
||||
const showYearOverYearOnTooltip = ref<boolean>(true);
|
||||
const showPeriodOverPeriodOnTooltip = computed<boolean>(() => props.dateAggregationType === ChartDateAggregationType.Day.type || props.dateAggregationType === ChartDateAggregationType.Month.type || props.dateAggregationType === ChartDateAggregationType.Quarter.type || props.dateAggregationType === ChartDateAggregationType.BillingCycle.type);
|
||||
|
||||
const dataDateRange = computed<AccountBalanceUnixTimeAndBalanceRange | null>(() => {
|
||||
if (!props.items || props.items.length < 1) {
|
||||
return null;
|
||||
@@ -108,6 +126,8 @@ export function useAccountBalanceTrendsChartBase(props: CommonAccountBalanceTren
|
||||
|
||||
if (props.dateAggregationType === ChartDateAggregationType.Day.type) {
|
||||
return getAllDaysStartAndEndUnixTimes(dataDateRange.value.minUnixTime, dataDateRange.value.maxUnixTime);
|
||||
} else if (props.dateAggregationType === ChartDateAggregationType.BillingCycle.type) {
|
||||
return getAllBillingCyclesStartAndEndUnixTimes(dataDateRange.value.minUnixTime, dataDateRange.value.maxUnixTime, props.statementDate ?? 1);
|
||||
} else {
|
||||
const startYearMonth = getGregorianCalendarYearAndMonthFromUnixTime(dataDateRange.value.minUnixTime);
|
||||
const endYearMonth = getGregorianCalendarYearAndMonthFromUnixTime(dataDateRange.value.maxUnixTime);
|
||||
@@ -148,6 +168,9 @@ export function useAccountBalanceTrendsChartBase(props: CommonAccountBalanceTren
|
||||
} else if (props.dateAggregationType === ChartDateAggregationType.Day.type) {
|
||||
minDateTime = getDayFirstDateTimeBySpecifiedUnixTime(dateItem.time, transactionTimeUtfOffset);
|
||||
displayDate = formatDateTimeToShortDate(minDateTime);
|
||||
} else if (props.dateAggregationType === ChartDateAggregationType.BillingCycle.type) {
|
||||
minDateTime = getBillingCycleLastUnixTimeBySpecifiedUnixTime(dateItem.time, props.statementDate ?? 1, transactionTimeUtfOffset);
|
||||
displayDate = formatDateTimeToGregorianLikeShortYearMonth(minDateTime);
|
||||
} else {
|
||||
return ret;
|
||||
}
|
||||
@@ -171,6 +194,7 @@ export function useAccountBalanceTrendsChartBase(props: CommonAccountBalanceTren
|
||||
const minDateTime = parseDateTimeFromUnixTime(dateRange.minUnixTime);
|
||||
|
||||
let displayDate = '';
|
||||
let alternativeDisplayDate = '';
|
||||
|
||||
if (props.dateAggregationType === ChartDateAggregationType.Year.type) {
|
||||
displayDate = formatDateTimeToGregorianLikeShortYear(minDateTime);
|
||||
@@ -182,11 +206,17 @@ export function useAccountBalanceTrendsChartBase(props: CommonAccountBalanceTren
|
||||
displayDate = formatDateTimeToGregorianLikeShortYearMonth(minDateTime);
|
||||
} else if (props.dateAggregationType === ChartDateAggregationType.Day.type) {
|
||||
displayDate = formatDateTimeToShortDate(minDateTime);
|
||||
} else if (props.dateAggregationType === ChartDateAggregationType.BillingCycle.type) {
|
||||
const maxDateTime = parseDateTimeFromUnixTime(dateRange.maxUnixTime);
|
||||
displayDate = formatDateTimeToGregorianLikeShortYearMonth(maxDateTime);
|
||||
alternativeDisplayDate = formatDateRange(DateRange.Custom.type, dateRange.minUnixTime, dateRange.maxUnixTime);
|
||||
} else {
|
||||
return ret;
|
||||
}
|
||||
|
||||
const dataItems = dayDataItemsMap[displayDate];
|
||||
const dateRangeKey = getDateRangeKeyWithYearOffset(dateRange, props.dateAggregationType) ?? '';
|
||||
const lastYearDateRangeKey = getDateRangeKeyWithYearOffset(dateRange, props.dateAggregationType, -1) ?? '';
|
||||
|
||||
if (isArray(dataItems)) {
|
||||
if (dataItems.length < 1) {
|
||||
@@ -205,12 +235,12 @@ export function useAccountBalanceTrendsChartBase(props: CommonAccountBalanceTren
|
||||
|
||||
const openingBalance = dataItems[0]!.accountOpeningBalance;
|
||||
const closingBalance = dataItems[dataItems.length - 1]!.accountClosingBalance;
|
||||
const minimumBalance = Math.min(...dataItems.map(item => item.accountClosingBalance));
|
||||
const maximumBalance = Math.max(...dataItems.map(item => item.accountClosingBalance));
|
||||
const medianBalance = allDataItemsSortedByClosingBalance[Math.floor(allDataItemsSortedByClosingBalance.length / 2)]!.accountClosingBalance;
|
||||
const averageBalance = Math.trunc(sumAmounts(dataItems.map(item => item.accountClosingBalance)) / dataItems.length);
|
||||
const q1Balance = allDataItemsSortedByClosingBalance[Math.floor(allDataItemsSortedByClosingBalance.length / 4)]!.accountClosingBalance;
|
||||
const q3Balance = allDataItemsSortedByClosingBalance[Math.floor(allDataItemsSortedByClosingBalance.length * 3 / 4)]!.accountClosingBalance;
|
||||
const minimumBalance = allDataItemsSortedByClosingBalance[0]!.accountClosingBalance;
|
||||
const maximumBalance = allDataItemsSortedByClosingBalance[allDataItemsSortedByClosingBalance.length - 1]!.accountClosingBalance;
|
||||
const medianBalance = Math.trunc(median(allDataItemsSortedByClosingBalance, item => item.accountClosingBalance));
|
||||
const averageBalance = Math.trunc(mean(dataItems, item => item.accountClosingBalance));
|
||||
const q1Balance = Math.trunc(percentile(allDataItemsSortedByClosingBalance, 0.25, item => item.accountClosingBalance));
|
||||
const q3Balance = Math.trunc(percentile(allDataItemsSortedByClosingBalance, 0.75, item => item.accountClosingBalance));
|
||||
|
||||
if (props.account.isAsset) {
|
||||
lastOpeningBalance = openingBalance;
|
||||
@@ -243,7 +273,10 @@ export function useAccountBalanceTrendsChartBase(props: CommonAccountBalanceTren
|
||||
}
|
||||
|
||||
ret.push({
|
||||
dateRangeKey: dateRangeKey,
|
||||
lastYearDateRangeKey: lastYearDateRangeKey,
|
||||
displayDate: displayDate,
|
||||
alternativeDisplayDate: alternativeDisplayDate,
|
||||
openingBalance: lastOpeningBalance,
|
||||
closingBalance: lastClosingBalance,
|
||||
minimumBalance: lastMinimumBalance,
|
||||
@@ -260,6 +293,18 @@ export function useAccountBalanceTrendsChartBase(props: CommonAccountBalanceTren
|
||||
return ret;
|
||||
});
|
||||
|
||||
const allDataItemsMap = computed<Record<string, AccountBalanceTrendsChartItem>>(() => {
|
||||
const ret: Record<string, AccountBalanceTrendsChartItem> = {};
|
||||
|
||||
for (const item of allDataItems.value) {
|
||||
if (item.dateRangeKey) {
|
||||
ret[item.dateRangeKey] = item;
|
||||
}
|
||||
}
|
||||
|
||||
return ret;
|
||||
});
|
||||
|
||||
const allDisplayDateRanges = computed<string[]>(() => {
|
||||
if (!allDataItems.value || allDataItems.value.length < 1) {
|
||||
return [];
|
||||
@@ -269,9 +314,13 @@ export function useAccountBalanceTrendsChartBase(props: CommonAccountBalanceTren
|
||||
});
|
||||
|
||||
return {
|
||||
// states
|
||||
showYearOverYearOnTooltip,
|
||||
showPeriodOverPeriodOnTooltip,
|
||||
// computed states
|
||||
allDateRanges,
|
||||
allDataItems,
|
||||
allDataItemsMap,
|
||||
allDisplayDateRanges
|
||||
};
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface CommonPieChartProps {
|
||||
colorField?: string;
|
||||
hiddenField?: string;
|
||||
amountValue?: boolean;
|
||||
percentValue?: boolean;
|
||||
defaultCurrency?: string;
|
||||
showValue?: boolean;
|
||||
showPercent?: boolean;
|
||||
@@ -81,7 +82,7 @@ export function usePieChartBase(props: CommonPieChartProps) {
|
||||
|
||||
accumulatedPaintPercent += finalItem.paintPercent;
|
||||
finalItem.displayPercent = formatPercentToLocalizedNumerals(finalItem.percent, 2, '<0.01');
|
||||
finalItem.displayValue = props.amountValue ? formatAmountToLocalizedNumeralsWithCurrency(value, props.defaultCurrency) : formatNumberToLocalizedNumerals(value, 2);
|
||||
finalItem.displayValue = getDisplayValue(value);
|
||||
|
||||
validItems.push(finalItem);
|
||||
}
|
||||
@@ -94,6 +95,18 @@ export function usePieChartBase(props: CommonPieChartProps) {
|
||||
return validItems;
|
||||
});
|
||||
|
||||
function getDisplayValue(value: number): string {
|
||||
if (props.percentValue) {
|
||||
return formatPercentToLocalizedNumerals(value, 2, '<0.01');
|
||||
}
|
||||
|
||||
if (props.amountValue) {
|
||||
return formatAmountToLocalizedNumeralsWithCurrency(value, props.defaultCurrency);
|
||||
}
|
||||
|
||||
return formatNumberToLocalizedNumerals(value, 2);
|
||||
}
|
||||
|
||||
watch(() => props.items, () => {
|
||||
selectedIndex.value = 0;
|
||||
});
|
||||
|
||||
@@ -4,8 +4,11 @@ import { useI18n } from '@/locales/helpers.ts';
|
||||
|
||||
import { useUserStore } from '@/stores/user.ts';
|
||||
|
||||
import type { TypeAndDisplayName } from '@/core/base.ts';
|
||||
import { type TypeAndDisplayName, itemAndIndex } from '@/core/base.ts';
|
||||
import { type DateTime } from '@/core/datetime.ts';
|
||||
|
||||
import { sortNumbersArray } from '@/lib/common.ts';
|
||||
import { getCurrentDateTime } from '@/lib/datetime.ts';
|
||||
|
||||
export interface CommonScheduleFrequencySelectionProps {
|
||||
type: number;
|
||||
@@ -15,29 +18,45 @@ export interface CommonScheduleFrequencySelectionProps {
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface AvailableMonthDay {
|
||||
day: number;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export function useScheduleFrequencySelectionBase() {
|
||||
const { getAllWeekDays, getAllTransactionScheduledFrequencyTypes, getMonthdayShortName } = useI18n();
|
||||
const {
|
||||
getAllWeekDays,
|
||||
getAvailableMonthDays,
|
||||
getAllTransactionScheduledFrequencyTypes,
|
||||
formatDateTimeToLongMonthDay
|
||||
} = useI18n();
|
||||
const userStore = useUserStore();
|
||||
|
||||
const allTransactionScheduledFrequencyTypes = computed<TypeAndDisplayName[]>(() => getAllTransactionScheduledFrequencyTypes());
|
||||
const allWeekDays = computed<TypeAndDisplayName[]>(() => getAllWeekDays(userStore.currentUserFirstDayOfWeek));
|
||||
|
||||
const allAvailableMonthDays = computed<AvailableMonthDay[]>(() => {
|
||||
const allAvailableDays = [];
|
||||
const allAvailableMonthDays = computed<TypeAndDisplayName[]>(() => getAvailableMonthDays(28, 3));
|
||||
const allAvailableMonthAndDays = computed<TypeAndDisplayName[]>(() => {
|
||||
const ret: TypeAndDisplayName[] = [];
|
||||
const now: DateTime = getCurrentDateTime();
|
||||
const maxDaysOfMonth: number[] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||||
|
||||
for (let i = 1; i <= 28; i++) {
|
||||
allAvailableDays.push({
|
||||
day: i,
|
||||
displayName: getMonthdayShortName(i),
|
||||
});
|
||||
for (const [days, index] of itemAndIndex(maxDaysOfMonth)) {
|
||||
const month = index + 1;
|
||||
|
||||
for (let day = 1; day <= days; day++) {
|
||||
const dateTime = now.set({
|
||||
month: month,
|
||||
dayOfMonth: day,
|
||||
hour: 0,
|
||||
minute: 0,
|
||||
second: 0,
|
||||
millisecond: 0
|
||||
});
|
||||
|
||||
ret.push({
|
||||
type: month * 100 + day,
|
||||
displayName: formatDateTimeToLongMonthDay(dateTime)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return allAvailableDays;
|
||||
return ret;
|
||||
});
|
||||
|
||||
function getFrequencyValues(value: string): number[] {
|
||||
@@ -58,6 +77,7 @@ export function useScheduleFrequencySelectionBase() {
|
||||
allTransactionScheduledFrequencyTypes,
|
||||
allWeekDays,
|
||||
allAvailableMonthDays,
|
||||
allAvailableMonthAndDays,
|
||||
// functions
|
||||
getFrequencyValues
|
||||
};
|
||||
|
||||
@@ -16,11 +16,11 @@ import {
|
||||
|
||||
import { useUserStore } from '@/stores/user.ts';
|
||||
|
||||
import { type NameValue, itemAndIndex } from '@/core/base.ts';
|
||||
import { type NameNumeralValue, itemAndIndex } from '@/core/base.ts';
|
||||
import { TextDirection } from '@/core/text.ts';
|
||||
import type { ColorStyleValue } from '@/core/color.ts';
|
||||
import { ThemeType } from '@/core/theme.ts';
|
||||
import { AccountBalanceTrendChartType } from '@/core/statistics.ts';
|
||||
import { AccountBalanceTrendChartType, ChartDateAggregationType } from '@/core/statistics.ts';
|
||||
import { DEFAULT_CHART_COLORS } from '@/consts/color.ts';
|
||||
|
||||
import { isArray } from '@/lib/common.ts';
|
||||
@@ -52,8 +52,19 @@ interface AccountBalanceTrendsChartDataItem {
|
||||
const props = defineProps<DesktopAccountBalanceTrendsChartProps>();
|
||||
|
||||
const theme = useTheme();
|
||||
const { tt, getCurrentLanguageTextDirection, formatAmountToLocalizedNumeralsWithCurrency } = useI18n();
|
||||
const { allDataItems, allDisplayDateRanges } = useAccountBalanceTrendsChartBase(props);
|
||||
const {
|
||||
tt,
|
||||
getCurrentLanguageTextDirection,
|
||||
formatAmountToLocalizedNumeralsWithCurrency,
|
||||
formatPercentToLocalizedNumerals
|
||||
} = useI18n();
|
||||
const {
|
||||
showYearOverYearOnTooltip,
|
||||
showPeriodOverPeriodOnTooltip,
|
||||
allDataItems,
|
||||
allDataItemsMap,
|
||||
allDisplayDateRanges
|
||||
} = useAccountBalanceTrendsChartBase(props);
|
||||
|
||||
const userStore = useUserStore();
|
||||
|
||||
@@ -189,105 +200,88 @@ const chartOptions = computed<object>(() => {
|
||||
color: isDarkMode.value ? '#eee' : '#333'
|
||||
},
|
||||
formatter: (params: CallbackDataParams[]) => {
|
||||
if (props.type === AccountBalanceTrendChartType.Boxplot.type) {
|
||||
const dataIndex = params[0]!.dataIndex;
|
||||
const dataItem = allDataItems.value[dataIndex] as AccountBalanceTrendsChartItem;
|
||||
const displayItems: NameValue[] = [
|
||||
{
|
||||
name: tt('Minimum Balance'),
|
||||
value: formatAmountToLocalizedNumeralsWithCurrency(dataItem.minimumBalance, props.account.currency)
|
||||
},
|
||||
{
|
||||
name: tt('Q1 Balance (First Quartile)'),
|
||||
value: formatAmountToLocalizedNumeralsWithCurrency(dataItem.q1Balance, props.account.currency)
|
||||
},
|
||||
{
|
||||
name: tt('Median Balance'),
|
||||
value: formatAmountToLocalizedNumeralsWithCurrency(dataItem.medianBalance, props.account.currency)
|
||||
},
|
||||
{
|
||||
name: tt('Q3 Balance (Third Quartile)'),
|
||||
value: formatAmountToLocalizedNumeralsWithCurrency(dataItem.q3Balance, props.account.currency)
|
||||
},
|
||||
{
|
||||
name: tt('Maximum Balance'),
|
||||
value: formatAmountToLocalizedNumeralsWithCurrency(dataItem.maximumBalance, props.account.currency)
|
||||
},
|
||||
{
|
||||
name: tt('Opening Balance'),
|
||||
value: formatAmountToLocalizedNumeralsWithCurrency(dataItem.openingBalance, props.account.currency)
|
||||
},
|
||||
{
|
||||
name: tt('Closing Balance'),
|
||||
value: formatAmountToLocalizedNumeralsWithCurrency(dataItem.closingBalance, props.account.currency)
|
||||
}
|
||||
];
|
||||
const dataIndex = params[0]!.dataIndex;
|
||||
const dataItem: AccountBalanceTrendsChartItem = allDataItems.value[dataIndex] as AccountBalanceTrendsChartItem;
|
||||
const yearOverYearDataItem: AccountBalanceTrendsChartItem | undefined = showYearOverYearOnTooltip.value ? allDataItemsMap.value[dataItem.lastYearDateRangeKey] : undefined;
|
||||
const periodOverPeriodDataItem: AccountBalanceTrendsChartItem | undefined = showPeriodOverPeriodOnTooltip.value ? allDataItems.value[dataIndex - 1] : undefined;
|
||||
|
||||
let tooltip = `${params[0]!.name} ${props.legendName}<br/>`;
|
||||
let header: string = params[0]!.name;
|
||||
let displayItems: NameNumeralValue[] = [];
|
||||
let yearOverYearDataItemDisplayItems: NameNumeralValue[] | undefined = undefined;
|
||||
let periodOverPeriodDataItemDisplayItems: NameNumeralValue[] | undefined = undefined;
|
||||
let separatorLineIndex: number | undefined = undefined;
|
||||
|
||||
for (const [displayItem, index] of itemAndIndex(displayItems)) {
|
||||
if (index === 5) {
|
||||
tooltip += '<div style="border-bottom: ' + (isDarkMode.value ? '#eee' : '#333') + ' dashed 1px"></div>';
|
||||
}
|
||||
|
||||
tooltip += `<div><span class="chart-pointer" style="background-color: #${DEFAULT_CHART_COLORS[index]}"></span>`
|
||||
+ `<span>${displayItem.name}</span><span class="ms-5" style="float: inline-end">${displayItem.value}</span>`
|
||||
+ `</div>`;
|
||||
}
|
||||
|
||||
return tooltip;
|
||||
} else if (props.type === AccountBalanceTrendChartType.Candlestick.type) {
|
||||
const dataIndex = params[0]!.dataIndex;
|
||||
const dataItem = allDataItems.value[dataIndex] as AccountBalanceTrendsChartItem;
|
||||
const displayItems: NameValue[] = [
|
||||
{
|
||||
name: tt('Opening Balance'),
|
||||
value: formatAmountToLocalizedNumeralsWithCurrency(dataItem.openingBalance, props.account.currency)
|
||||
},
|
||||
{
|
||||
name: tt('Closing Balance'),
|
||||
value: formatAmountToLocalizedNumeralsWithCurrency(dataItem.closingBalance, props.account.currency)
|
||||
},
|
||||
{
|
||||
name: tt('Minimum Balance'),
|
||||
value: formatAmountToLocalizedNumeralsWithCurrency(dataItem.minimumBalance, props.account.currency)
|
||||
},
|
||||
{
|
||||
name: tt('Maximum Balance'),
|
||||
value: formatAmountToLocalizedNumeralsWithCurrency(dataItem.maximumBalance, props.account.currency)
|
||||
},
|
||||
{
|
||||
name: tt('Median Balance'),
|
||||
value: formatAmountToLocalizedNumeralsWithCurrency(dataItem.medianBalance, props.account.currency)
|
||||
},
|
||||
{
|
||||
name: tt('Average Balance'),
|
||||
value: formatAmountToLocalizedNumeralsWithCurrency(dataItem.averageBalance, props.account.currency)
|
||||
}
|
||||
];
|
||||
|
||||
let tooltip = `${params[0]!.name} ${props.legendName}<br/>`;
|
||||
|
||||
for (const [displayItem, index] of itemAndIndex(displayItems)) {
|
||||
if (index === 4) {
|
||||
tooltip += '<div style="border-bottom: ' + (isDarkMode.value ? '#eee' : '#333') + ' dashed 1px"></div>';
|
||||
}
|
||||
|
||||
tooltip += `<div><span class="chart-pointer" style="background-color: #${DEFAULT_CHART_COLORS[index]}"></span>`
|
||||
+ `<span>${displayItem.name}</span><span class="ms-5" style="float: inline-end">${displayItem.value}</span>`
|
||||
+ `</div>`;
|
||||
}
|
||||
|
||||
return tooltip;
|
||||
} else {
|
||||
const amount = params[0]!.data as number;
|
||||
const value = formatAmountToLocalizedNumeralsWithCurrency(amount, props.account.currency);
|
||||
|
||||
return `${params[0]!.name}<br/>`
|
||||
+ '<div><span class="chart-pointer" style="background-color: #' + DEFAULT_CHART_COLORS[0] + '"></span>'
|
||||
+ `<span>${props.legendName}</span><span class="ms-5" style="float: inline-end">${value}</span>`
|
||||
+ '</div>';
|
||||
if (dataItem.alternativeDisplayDate) {
|
||||
header = dataItem.alternativeDisplayDate;
|
||||
}
|
||||
|
||||
if (props.type === AccountBalanceTrendChartType.Boxplot.type) {
|
||||
header += ` ${props.legendName}`;
|
||||
displayItems = getBoxplotChartTooltip(dataItem);
|
||||
yearOverYearDataItemDisplayItems = yearOverYearDataItem ? getBoxplotChartTooltip(yearOverYearDataItem) : undefined;
|
||||
periodOverPeriodDataItemDisplayItems = periodOverPeriodDataItem ? getBoxplotChartTooltip(periodOverPeriodDataItem) : undefined;
|
||||
separatorLineIndex = 5;
|
||||
} else if (props.type === AccountBalanceTrendChartType.Candlestick.type) {
|
||||
header += ` ${props.legendName}`;
|
||||
displayItems = getCandlestickChartTooltip(dataItem);
|
||||
yearOverYearDataItemDisplayItems = yearOverYearDataItem ? getCandlestickChartTooltip(yearOverYearDataItem) : undefined;
|
||||
periodOverPeriodDataItemDisplayItems = periodOverPeriodDataItem ? getCandlestickChartTooltip(periodOverPeriodDataItem) : undefined;
|
||||
separatorLineIndex = 4;
|
||||
} else {
|
||||
displayItems = getDefaultChartTooltip(dataItem);
|
||||
yearOverYearDataItemDisplayItems = yearOverYearDataItem ? getDefaultChartTooltip(yearOverYearDataItem) : undefined;
|
||||
periodOverPeriodDataItemDisplayItems = periodOverPeriodDataItem ? getDefaultChartTooltip(periodOverPeriodDataItem) : undefined;
|
||||
}
|
||||
|
||||
const totalColumnCount = 2 + (yearOverYearDataItemDisplayItems && yearOverYearDataItemDisplayItems.length ? 1 : 0) + (periodOverPeriodDataItemDisplayItems && periodOverPeriodDataItemDisplayItems.length ? 1 : 0);
|
||||
let tooltip = `<table class="chart-tooltip-table"><tbody><tr><td colspan="2">${header}</td>`;
|
||||
|
||||
if (yearOverYearDataItemDisplayItems && yearOverYearDataItemDisplayItems.length) {
|
||||
tooltip += `<td><span class="ms-5" style="float: inline-end">${tt('Year-over-Year')}</span></td>`;
|
||||
}
|
||||
|
||||
if (periodOverPeriodDataItemDisplayItems && periodOverPeriodDataItemDisplayItems.length) {
|
||||
let periodOverPeriodText = tt('Period-over-Period');
|
||||
|
||||
if (props.dateAggregationType === ChartDateAggregationType.Quarter.type) {
|
||||
periodOverPeriodText = tt('Quarter-over-Quarter');
|
||||
} else if (props.dateAggregationType === ChartDateAggregationType.Month.type) {
|
||||
periodOverPeriodText = tt('Month-over-Month');
|
||||
} else if (props.dateAggregationType === ChartDateAggregationType.Day.type) {
|
||||
periodOverPeriodText = tt('Day-over-Day');
|
||||
}
|
||||
|
||||
tooltip += `<td><span class="ms-5" style="float: inline-end">${periodOverPeriodText}</span></td>`;
|
||||
}
|
||||
|
||||
tooltip += '</tr>';
|
||||
|
||||
for (const [displayItem, index] of itemAndIndex(displayItems)) {
|
||||
const displayValue = formatAmountToLocalizedNumeralsWithCurrency(displayItem.value, props.account.currency);
|
||||
tooltip += `<tr><td><span class="chart-pointer" style="background-color: #${DEFAULT_CHART_COLORS[index]}"></span>`
|
||||
+ `<span>${displayItem.name}</span></td><td><span class="ms-5" style="float: inline-end">${displayValue}</span></td>`;
|
||||
|
||||
if (yearOverYearDataItemDisplayItems && yearOverYearDataItemDisplayItems.length && yearOverYearDataItemDisplayItems[index]) {
|
||||
const yearOverYearDisplayItem = yearOverYearDataItemDisplayItems[index];
|
||||
const displayGrowthRate = formatDisplayChangeRate(displayItem.value, yearOverYearDisplayItem.value);
|
||||
tooltip += `<td><span class="ms-5" style="float: inline-end">${displayGrowthRate}</span></td>`;
|
||||
}
|
||||
|
||||
if (periodOverPeriodDataItemDisplayItems && periodOverPeriodDataItemDisplayItems.length && periodOverPeriodDataItemDisplayItems[index]) {
|
||||
const periodOverPeriodDisplayItem = periodOverPeriodDataItemDisplayItems[index];
|
||||
const displayGrowthRate = formatDisplayChangeRate(displayItem.value, periodOverPeriodDisplayItem.value);
|
||||
tooltip += `<td><span class="ms-5" style="float: inline-end">${displayGrowthRate}</span></td>`;
|
||||
}
|
||||
|
||||
tooltip += '</tr>';
|
||||
|
||||
if (separatorLineIndex !== undefined && index === separatorLineIndex - 1) {
|
||||
tooltip += `<tr><td colspan="${totalColumnCount}" style="border-bottom: ${(isDarkMode.value ? '#eee' : '#333')} dashed 1px"></td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
tooltip += `</tbody></table>`;
|
||||
return tooltip;
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
@@ -332,6 +326,91 @@ const chartOptions = computed<object>(() => {
|
||||
series: allSeries.value
|
||||
};
|
||||
});
|
||||
|
||||
function getBoxplotChartTooltip(dataItem: AccountBalanceTrendsChartItem): NameNumeralValue[] {
|
||||
return [
|
||||
{
|
||||
name: tt('Minimum Balance'),
|
||||
value: dataItem.minimumBalance
|
||||
},
|
||||
{
|
||||
name: tt('Q1 Balance (First Quartile)'),
|
||||
value: dataItem.q1Balance
|
||||
},
|
||||
{
|
||||
name: tt('Median Balance'),
|
||||
value: dataItem.medianBalance
|
||||
},
|
||||
{
|
||||
name: tt('Q3 Balance (Third Quartile)'),
|
||||
value: dataItem.q3Balance
|
||||
},
|
||||
{
|
||||
name: tt('Maximum Balance'),
|
||||
value: dataItem.maximumBalance
|
||||
},
|
||||
{
|
||||
name: tt('Opening Balance'),
|
||||
value: dataItem.openingBalance
|
||||
},
|
||||
{
|
||||
name: tt('Closing Balance'),
|
||||
value: dataItem.closingBalance
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function getCandlestickChartTooltip(dataItem: AccountBalanceTrendsChartItem): NameNumeralValue[] {
|
||||
return [
|
||||
{
|
||||
name: tt('Opening Balance'),
|
||||
value: dataItem.openingBalance
|
||||
},
|
||||
{
|
||||
name: tt('Closing Balance'),
|
||||
value: dataItem.closingBalance
|
||||
},
|
||||
{
|
||||
name: tt('Minimum Balance'),
|
||||
value: dataItem.minimumBalance
|
||||
},
|
||||
{
|
||||
name: tt('Maximum Balance'),
|
||||
value: dataItem.maximumBalance
|
||||
},
|
||||
{
|
||||
name: tt('Median Balance'),
|
||||
value: dataItem.medianBalance
|
||||
},
|
||||
{
|
||||
name: tt('Average Balance'),
|
||||
value: dataItem.averageBalance
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function getDefaultChartTooltip(dataItem: AccountBalanceTrendsChartItem): NameNumeralValue[] {
|
||||
return [
|
||||
{
|
||||
name: props.legendName,
|
||||
value: dataItem.closingBalance
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
function formatDisplayChangeRate(current: number, reference: number): string {
|
||||
if (reference === 0 && current === 0) {
|
||||
return formatPercentToLocalizedNumerals(0, 2, '<0.01');
|
||||
}
|
||||
|
||||
if (reference === 0) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
const rate = (current - reference) / reference * 100;
|
||||
return formatPercentToLocalizedNumerals(rate, 2, '<0.01');
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -81,8 +81,9 @@ import {
|
||||
|
||||
import { NumeralSystem, DecimalSeparator } from '@/core/numeral.ts';
|
||||
import type { CurrencyPrependAndAppendText } from '@/core/currency.ts';
|
||||
import { DEFAULT_DECIMAL_NUMBER_COUNT } from '@/consts/numeral.ts';
|
||||
import { DEFAULT_DECIMAL_NUMBER_COUNT, AMOUNT_FACTOR } from '@/consts/numeral.ts';
|
||||
import { TRANSACTION_MIN_AMOUNT, TRANSACTION_MAX_AMOUNT } from '@/consts/transaction.ts';
|
||||
|
||||
import { isNumber, replaceAll } from '@/lib/common.ts';
|
||||
import { evaluateExpressionToAmount } from '@/lib/evaluator.ts';
|
||||
import type { ComponentDensity, InputVariant } from '@/lib/ui/desktop.ts';
|
||||
@@ -297,7 +298,7 @@ function getFormattedValue(value: number): string {
|
||||
|
||||
function getDisplayCurrencyPrependAndAppendText(): CurrencyPrependAndAppendText | null {
|
||||
const numericCurrentValue = parseAmountFromLocalizedNumerals(currentValue.value);
|
||||
const isPlural = numericCurrentValue !== 100 && numericCurrentValue !== -100;
|
||||
const isPlural = numericCurrentValue !== AMOUNT_FACTOR && numericCurrentValue !== -AMOUNT_FACTOR;
|
||||
|
||||
return getAmountPrependAndAppendText(props.currency, isPlural);
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ const props = defineProps<{
|
||||
displayOrdersField?: string;
|
||||
translateName?: boolean;
|
||||
amountValue?: boolean;
|
||||
percentValue?: boolean;
|
||||
defaultCurrency?: string;
|
||||
enableClickItem?: boolean;
|
||||
tooltipExtraColumnNames?: string[];
|
||||
@@ -89,7 +90,7 @@ const {
|
||||
formatAmountToWesternArabicNumeralsWithoutDigitGrouping,
|
||||
formatAmountToLocalizedNumeralsWithCurrency,
|
||||
formatNumberToLocalizedNumerals,
|
||||
formatNumberToWesternArabicNumerals,
|
||||
formatNumberToWesternArabicNumeralsWithoutDigitGrouping,
|
||||
formatPercentToLocalizedNumerals
|
||||
} = useI18n();
|
||||
|
||||
@@ -477,7 +478,7 @@ function getItemName(name: string): string {
|
||||
}
|
||||
|
||||
function getDisplayValue(value: number): string {
|
||||
if (props.oneHundredPercentStacked) {
|
||||
if (props.oneHundredPercentStacked || props.percentValue) {
|
||||
return formatPercentToLocalizedNumerals(value, 2, '<0.01');
|
||||
}
|
||||
|
||||
@@ -522,9 +523,9 @@ function exportData(): { headers: string[], data: string[][] } {
|
||||
row.push(categoryName);
|
||||
row.push(...allSeries.value.map(item => {
|
||||
if (props.oneHundredPercentStacked) {
|
||||
return formatNumberToWesternArabicNumerals(item.data[index] ?? 0);
|
||||
return formatNumberToWesternArabicNumeralsWithoutDigitGrouping(item.data[index] ?? 0);
|
||||
} else {
|
||||
return formatAmountToWesternArabicNumeralsWithoutDigitGrouping(item.data[index] ?? 0);
|
||||
return formatAmountToWesternArabicNumeralsWithoutDigitGrouping(item.data[index] ?? 0, props.defaultCurrency);
|
||||
}
|
||||
}));
|
||||
data.push(row);
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
<template>
|
||||
<v-chart autoresize :class="finalClass" :style="finalStyle" :option="chartOptions" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useTheme } from 'vuetify';
|
||||
import type { CallbackDataParams } from 'echarts/types/dist/shared';
|
||||
|
||||
import { useI18n } from '@/locales/helpers.ts';
|
||||
|
||||
import { useUserStore } from '@/stores/user.ts';
|
||||
|
||||
import { type WeekDayValue, KnownDateTimeFormat } from '@/core/datetime.ts';
|
||||
import { ThemeType } from '@/core/theme.ts';
|
||||
|
||||
import {
|
||||
isNumber,
|
||||
getObjectOwnFieldCount,
|
||||
mapObjectToArray
|
||||
} from '@/lib/common.ts';
|
||||
import { parseDateTimeFromKnownDateTimeFormat } from '@/lib/datetime.ts';
|
||||
|
||||
interface HeatMapData {
|
||||
data: Record<number, YearlyHeatmapData>;
|
||||
minValue: number;
|
||||
maxValue: number;
|
||||
}
|
||||
|
||||
interface YearlyHeatmapData {
|
||||
gregorianYear: number;
|
||||
displayYear: string;
|
||||
data: [string, number][];
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
class?: string;
|
||||
skeleton?: boolean;
|
||||
showValue?: boolean;
|
||||
items: Record<string, unknown>[];
|
||||
idField: string;
|
||||
valueField: string;
|
||||
hiddenField?: string;
|
||||
translateName?: boolean;
|
||||
valueTypeName: string;
|
||||
amountValue?: boolean;
|
||||
percentValue?: boolean;
|
||||
defaultCurrency?: string;
|
||||
}>();
|
||||
|
||||
const theme = useTheme();
|
||||
|
||||
const {
|
||||
tt,
|
||||
getAllShortMonthNames,
|
||||
getAllMinWeekdayNames,
|
||||
formatDateTimeToLongDate,
|
||||
getCalendarDisplayLongYearFromDateTime,
|
||||
formatAmountToLocalizedNumeralsWithCurrency,
|
||||
formatNumberToLocalizedNumerals,
|
||||
formatPercentToLocalizedNumerals
|
||||
} = useI18n();
|
||||
|
||||
const userStore = useUserStore();
|
||||
|
||||
const visualMapHeight: number = 100;
|
||||
const calendarHeight: number = 180;
|
||||
const calendarBottomMargin: number = 10;
|
||||
|
||||
const firstDayOfWeek = computed<WeekDayValue>(() => userStore.currentUserFirstDayOfWeek);
|
||||
const dayNames = computed<string[]>(() => getAllMinWeekdayNames());
|
||||
const monthNames = computed<string[]>(() => getAllShortMonthNames());
|
||||
|
||||
const isDarkMode = computed<boolean>(() => theme.global.name.value === ThemeType.Dark);
|
||||
const finalClass = computed<string>(() => {
|
||||
let finalClass = '';
|
||||
|
||||
if (props.skeleton) {
|
||||
finalClass += 'transition-in';
|
||||
}
|
||||
|
||||
if (props.class) {
|
||||
finalClass += ` ${props.class}`;
|
||||
} else {
|
||||
finalClass += ' calendar-heatmap-chart-container';
|
||||
}
|
||||
|
||||
return finalClass;
|
||||
});
|
||||
const finalStyle = computed<Record<string, string>>(() => {
|
||||
const style: Record<string, string> = {};
|
||||
|
||||
if (heatMapData.value.data) {
|
||||
const calendarCount = getObjectOwnFieldCount(heatMapData.value.data);
|
||||
style['height'] = `${visualMapHeight + calendarCount * calendarHeight + (calendarCount - 1) * calendarBottomMargin}px`;
|
||||
}
|
||||
|
||||
return style;
|
||||
});
|
||||
|
||||
const heatMapData = computed<HeatMapData>(() => {
|
||||
const allData: Record<number, YearlyHeatmapData> = {};
|
||||
let minValue: number = Number.POSITIVE_INFINITY;
|
||||
let maxValue: number = 0;
|
||||
|
||||
for (const item of props.items) {
|
||||
const id = getItemName(item[props.idField] as string);
|
||||
const dateTime = parseDateTimeFromKnownDateTimeFormat(id, KnownDateTimeFormat.DefaultDate);
|
||||
const value = item[props.valueField];
|
||||
|
||||
if (dateTime && isNumber(value) && (!props.hiddenField || !item[props.hiddenField])) {
|
||||
if (value > maxValue) {
|
||||
maxValue = value;
|
||||
}
|
||||
|
||||
if (value < minValue) {
|
||||
minValue = value;
|
||||
}
|
||||
|
||||
const year: number = dateTime.getGregorianCalendarYear();
|
||||
let data: YearlyHeatmapData | undefined = allData[year];
|
||||
|
||||
if (!data) {
|
||||
data = {
|
||||
gregorianYear: year,
|
||||
displayYear: getCalendarDisplayLongYearFromDateTime(dateTime),
|
||||
data: []
|
||||
};
|
||||
allData[year] = data;
|
||||
}
|
||||
|
||||
data.data.push([dateTime.getGregorianCalendarYearDashMonthDashDay(), value]);
|
||||
}
|
||||
}
|
||||
|
||||
const ret: HeatMapData = {
|
||||
data: allData,
|
||||
minValue: minValue === Number.POSITIVE_INFINITY ? 0 : minValue,
|
||||
maxValue: maxValue
|
||||
};
|
||||
|
||||
return ret;
|
||||
});
|
||||
|
||||
const chartOptions = computed<object>(() => {
|
||||
return {
|
||||
tooltip: {
|
||||
backgroundColor: isDarkMode.value ? '#333' : '#fff',
|
||||
borderColor: isDarkMode.value ? '#333' : '#fff',
|
||||
textStyle: {
|
||||
color: isDarkMode.value ? '#eee' : '#333'
|
||||
},
|
||||
formatter: (params: CallbackDataParams) => {
|
||||
if (!props.showValue) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const dataItem = params.data as [string, number];
|
||||
const dateTime = dataItem && dataItem[0] ? parseDateTimeFromKnownDateTimeFormat(dataItem[0], KnownDateTimeFormat.DefaultDate) : '';
|
||||
const name = props.valueTypeName;
|
||||
const value = dataItem && isNumber(dataItem[1]) ? getDisplayValue(dataItem[1]) : '';
|
||||
|
||||
return (dateTime ? `<div class="d-inline-flex">${formatDateTimeToLongDate(dateTime)}</div><br/>` : '')
|
||||
+ `<div><span class="chart-pointer" style="background-color: ${params.color}"></span>`
|
||||
+ `<span>${name}</span>`
|
||||
+ `<span class="ms-5">${value}</span>`
|
||||
+ '</div>';
|
||||
}
|
||||
},
|
||||
visualMap: [
|
||||
{
|
||||
type: 'continuous',
|
||||
orient: 'horizontal',
|
||||
top: 0,
|
||||
left: 'center',
|
||||
itemHeight: 320,
|
||||
min: heatMapData.value.minValue,
|
||||
max: heatMapData.value.maxValue,
|
||||
calculable: true,
|
||||
inRange: {
|
||||
color: isDarkMode.value ? [ '#1a1a1a', '#c67e48' ] : [ '#faf8f4', '#c67e48' ]
|
||||
},
|
||||
textStyle: {
|
||||
color: isDarkMode.value ? '#888' : '#666'
|
||||
},
|
||||
formatter: (value: string) => {
|
||||
if (!props.showValue) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return getDisplayValue(parseInt(value));
|
||||
}
|
||||
}
|
||||
],
|
||||
calendar: mapObjectToArray(heatMapData.value.data, (item, _, index) => {
|
||||
return {
|
||||
range: item.gregorianYear,
|
||||
orient: 'horizontal',
|
||||
left: 70,
|
||||
top: visualMapHeight + index * (calendarHeight + calendarBottomMargin),
|
||||
right: 20,
|
||||
cellSize: ['auto', 20],
|
||||
itemStyle: {
|
||||
color: isDarkMode.value ? '#060504' : '#ffffff',
|
||||
borderColor: isDarkMode.value ? '#4f4f4f' : '#e1e6f2'
|
||||
},
|
||||
splitLine: {
|
||||
show: false
|
||||
},
|
||||
dayLabel: {
|
||||
firstDay: firstDayOfWeek.value,
|
||||
nameMap: dayNames.value,
|
||||
color: isDarkMode.value ? '#888' : '#666'
|
||||
},
|
||||
monthLabel: {
|
||||
nameMap: monthNames.value,
|
||||
color: isDarkMode.value ? '#888' : '#666'
|
||||
},
|
||||
yearLabel: {
|
||||
formatter: item.displayYear,
|
||||
color: isDarkMode.value ? '#888' : '#666'
|
||||
}
|
||||
};
|
||||
}),
|
||||
series: mapObjectToArray(heatMapData.value.data, (item, _, index) => {
|
||||
return {
|
||||
type: 'heatmap',
|
||||
animation: !props.skeleton,
|
||||
coordinateSystem: 'calendar',
|
||||
calendarIndex: index,
|
||||
data: item.data,
|
||||
label: {
|
||||
show: false
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
shadowBlur: 6,
|
||||
shadowColor: isDarkMode.value ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.5)'
|
||||
}
|
||||
}
|
||||
};
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
function getItemName(name: string): string {
|
||||
return props.translateName ? tt(name) : name;
|
||||
}
|
||||
|
||||
function getDisplayValue(value: number): string {
|
||||
if (props.percentValue) {
|
||||
return formatPercentToLocalizedNumerals(value, 2, '<0.01');
|
||||
}
|
||||
|
||||
if (props.amountValue) {
|
||||
return formatAmountToLocalizedNumeralsWithCurrency(value, props.defaultCurrency);
|
||||
}
|
||||
|
||||
return formatNumberToLocalizedNumerals(value, 2);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.calendar-heatmap-chart-container {
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<v-dialog persistent min-width="320" width="auto" v-model="showState">
|
||||
<v-dialog persistent min-width="320" max-width="500" width="auto" v-model="showState">
|
||||
<v-card>
|
||||
<v-toolbar :color="finalColor">
|
||||
<v-toolbar-title>{{ titleContent }}</v-toolbar-title>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
persistent-placeholder
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:clearable="!emptyValue ? clearable : false"
|
||||
:label="label"
|
||||
:menu-props="{ contentClass: 'date-time-select-menu' }"
|
||||
v-model="dateTime"
|
||||
@@ -107,13 +108,16 @@ import { setChildInputFocus } from '@/lib/ui/desktop.ts';
|
||||
const props = defineProps<{
|
||||
modelValue: number;
|
||||
timezoneUtcOffset: number;
|
||||
emptyValue?: boolean;
|
||||
disabled?: boolean;
|
||||
readonly?: boolean;
|
||||
clearable?: boolean;
|
||||
label?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: number): void;
|
||||
(e: 'clear:modelValue'): void;
|
||||
(e: 'error', message: string): void;
|
||||
}>();
|
||||
|
||||
@@ -154,7 +158,12 @@ const dateTime = computed<Date>({
|
||||
get: () => {
|
||||
return getLocalDatetimeFromSameDateTimeOfUnixTime(props.modelValue, props.timezoneUtcOffset);
|
||||
},
|
||||
set: (value: Date) => {
|
||||
set: (value: Date | null) => {
|
||||
if (!value) {
|
||||
emit('clear:modelValue');
|
||||
return;
|
||||
}
|
||||
|
||||
const unixTime = getUnixTimeFromSameDateTimeOfLocalDatetime(value, props.timezoneUtcOffset);
|
||||
|
||||
if (unixTime < 0) {
|
||||
@@ -166,7 +175,7 @@ const dateTime = computed<Date>({
|
||||
}
|
||||
});
|
||||
|
||||
const displayTime = computed<string>(() => formatDateTimeToLongDateTime(parseDateTimeFromUnixTimeWithTimezoneOffset(props.modelValue, props.timezoneUtcOffset)));
|
||||
const displayTime = computed<string>(() => props.emptyValue ? tt('None') : formatDateTimeToLongDateTime(parseDateTimeFromUnixTimeWithTimezoneOffset(props.modelValue, props.timezoneUtcOffset)));
|
||||
|
||||
const hourItems = computed<TimePickerValue[]>(() => generateAllHours(1, isHourTwoDigits.value));
|
||||
const minuteItems = computed<TimePickerValue[]>(() => generateAllMinutesOrSeconds(1, isMinuteTwoDigits.value));
|
||||
@@ -347,12 +356,20 @@ function onKeyDown(type: string, e: KeyboardEvent): void {
|
||||
setChildInputFocus(minuteInput.value?.$el, 'input');
|
||||
}, 50);
|
||||
});
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
} else if (type === 'minute') {
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
setChildInputFocus(secondInput.value?.$el, 'input');
|
||||
}, 50);
|
||||
});
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
<template>
|
||||
<v-chart autoresize :class="finalClass" :style="finalStyle" :option="chartOptions" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useTheme } from 'vuetify';
|
||||
import type { CallbackDataParams } from 'echarts/types/dist/shared';
|
||||
|
||||
import { useI18n } from '@/locales/helpers.ts';
|
||||
|
||||
import { itemAndIndex } from '@/core/base.ts';
|
||||
import { TextDirection } from '@/core/text.ts';
|
||||
import { ThemeType } from '@/core/theme.ts';
|
||||
|
||||
import { isArray, isNumber } from '@/lib/common.ts';
|
||||
|
||||
interface HeatMapData {
|
||||
allSeriesNames: string[];
|
||||
data: [number, number, number][];
|
||||
minValue: number;
|
||||
maxValue: number;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
class?: string;
|
||||
skeleton?: boolean;
|
||||
showValue?: boolean;
|
||||
categoryTypeName: string;
|
||||
allCategoryNames: string[];
|
||||
items: Record<string, unknown>[];
|
||||
nameField: string;
|
||||
valuesField: string;
|
||||
hiddenField?: string;
|
||||
translateName?: boolean;
|
||||
valueTypeName: string;
|
||||
amountValue?: boolean;
|
||||
percentValue?: boolean;
|
||||
defaultCurrency?: string;
|
||||
}>();
|
||||
|
||||
const theme = useTheme();
|
||||
|
||||
const {
|
||||
tt,
|
||||
getCurrentLanguageTextDirection,
|
||||
formatAmountToLocalizedNumeralsWithCurrency,
|
||||
formatAmountToWesternArabicNumeralsWithoutDigitGrouping,
|
||||
formatNumberToLocalizedNumerals,
|
||||
formatPercentToLocalizedNumerals
|
||||
} = useI18n();
|
||||
|
||||
const textDirection = computed<TextDirection>(() => getCurrentLanguageTextDirection());
|
||||
const isDarkMode = computed<boolean>(() => theme.global.name.value === ThemeType.Dark);
|
||||
const finalClass = computed<string>(() => {
|
||||
let finalClass = '';
|
||||
|
||||
if (props.skeleton) {
|
||||
finalClass += 'transition-in';
|
||||
}
|
||||
|
||||
if (props.class) {
|
||||
finalClass += ` ${props.class}`;
|
||||
} else {
|
||||
finalClass += ' heatmap-chart-container';
|
||||
}
|
||||
|
||||
return finalClass;
|
||||
});
|
||||
const finalStyle = computed<Record<string, string>>(() => {
|
||||
const style: Record<string, string> = {};
|
||||
|
||||
if (heatMapData.value.allSeriesNames && heatMapData.value.allSeriesNames.length > 15) {
|
||||
style['height'] = `${heatMapData.value.allSeriesNames.length * 40}px`;
|
||||
}
|
||||
|
||||
return style;
|
||||
});
|
||||
|
||||
const heatMapData = computed<HeatMapData>(() => {
|
||||
const allData: [number, number, number][] = [];
|
||||
const allSeriesNames: string[] = [];
|
||||
let minValue: number = Number.POSITIVE_INFINITY;
|
||||
let maxValue: number = 0;
|
||||
|
||||
for (const [item, seriesIndex] of itemAndIndex(props.items)) {
|
||||
if (props.hiddenField && item[props.hiddenField]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isArray(item[props.valuesField])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
allSeriesNames.push(getItemName(item[props.nameField] as string));
|
||||
|
||||
const allAmounts: number[] = item[props.valuesField] as number[];
|
||||
|
||||
for (const [amount, categoryIndex] of itemAndIndex(allAmounts)) {
|
||||
if (amount > maxValue) {
|
||||
maxValue = amount;
|
||||
}
|
||||
|
||||
if (amount < minValue) {
|
||||
minValue = amount;
|
||||
}
|
||||
|
||||
allData.push([categoryIndex, seriesIndex, amount]);
|
||||
}
|
||||
}
|
||||
|
||||
const ret: HeatMapData = {
|
||||
allSeriesNames: allSeriesNames,
|
||||
data: allData,
|
||||
minValue: minValue === Number.POSITIVE_INFINITY ? 0 : minValue,
|
||||
maxValue: maxValue
|
||||
};
|
||||
|
||||
return ret;
|
||||
});
|
||||
|
||||
const yAxisWidth = computed<number>(() => {
|
||||
let width: number = 60;
|
||||
|
||||
if (!heatMapData.value || !heatMapData.value.allSeriesNames) {
|
||||
return width;
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
|
||||
if (context) {
|
||||
context.font = '12px Arial';
|
||||
|
||||
for (const seriesName of heatMapData.value.allSeriesNames) {
|
||||
const textMetrics = context.measureText(seriesName);
|
||||
const actualWidth = Math.round(textMetrics.width) + 20;
|
||||
|
||||
if (actualWidth > width) {
|
||||
width = actualWidth;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (width >= 200) {
|
||||
width = 200;
|
||||
}
|
||||
|
||||
return width;
|
||||
});
|
||||
|
||||
const chartOptions = computed<object>(() => {
|
||||
return {
|
||||
tooltip: {
|
||||
backgroundColor: isDarkMode.value ? '#333' : '#fff',
|
||||
borderColor: isDarkMode.value ? '#333' : '#fff',
|
||||
textStyle: {
|
||||
color: isDarkMode.value ? '#eee' : '#333'
|
||||
},
|
||||
formatter: (params: CallbackDataParams) => {
|
||||
if (!props.showValue) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const dataItem = params.data as [number, number, number];
|
||||
const name = props.valueTypeName;
|
||||
const value = dataItem && isNumber(dataItem[2]) ? getDisplayValue(dataItem[2]) : '';
|
||||
|
||||
return `<div class="d-inline-flex">${params.name}</div><br/>`
|
||||
+ `<div><span class="chart-pointer" style="background-color: ${params.color}"></span>`
|
||||
+ `<span>${name}</span>`
|
||||
+ `<span class="ms-5">${value}</span>`
|
||||
+ '</div>';
|
||||
}
|
||||
},
|
||||
visualMap: [
|
||||
{
|
||||
type: 'continuous',
|
||||
orient: 'horizontal',
|
||||
top: 0,
|
||||
left: 'center',
|
||||
itemHeight: 320,
|
||||
min: heatMapData.value.minValue,
|
||||
max: heatMapData.value.maxValue,
|
||||
calculable: true,
|
||||
inRange: {
|
||||
color: isDarkMode.value ? [ '#1a1a1a', '#c67e48' ] : [ '#faf8f4', '#c67e48' ]
|
||||
},
|
||||
textStyle: {
|
||||
color: isDarkMode.value ? '#888' : '#666'
|
||||
},
|
||||
formatter: (value: string) => {
|
||||
if (!props.showValue) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return getDisplayValue(parseInt(value));
|
||||
}
|
||||
}
|
||||
],
|
||||
grid: {
|
||||
left: yAxisWidth.value,
|
||||
right: 20,
|
||||
bottom: 40
|
||||
},
|
||||
xAxis: [
|
||||
{
|
||||
type: 'category',
|
||||
data: props.allCategoryNames,
|
||||
inverse: textDirection.value === TextDirection.RTL,
|
||||
axisLabel: {
|
||||
color: isDarkMode.value ? '#888' : '#666'
|
||||
}
|
||||
}
|
||||
],
|
||||
yAxis: [
|
||||
{
|
||||
type: 'category',
|
||||
data: heatMapData.value.allSeriesNames,
|
||||
inverse: true,
|
||||
axisLabel: {
|
||||
color: isDarkMode.value ? '#888' : '#666'
|
||||
}
|
||||
}
|
||||
],
|
||||
series: [
|
||||
{
|
||||
type: 'heatmap',
|
||||
animation: !props.skeleton,
|
||||
data: heatMapData.value.data,
|
||||
label: {
|
||||
show: props.showValue ?? false,
|
||||
color: isDarkMode.value ? '#eee' : '#333',
|
||||
formatter: (params: CallbackDataParams) => {
|
||||
if (!props.showValue) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const data: [number, number, number] = params.data as [number, number, number];
|
||||
const value: number = data && isNumber(data[2]) ? data[2] : 0;
|
||||
return getDisplayValue(value);
|
||||
}
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
shadowBlur: 6,
|
||||
shadowColor: isDarkMode.value ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.5)'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
});
|
||||
|
||||
function getItemName(name: string): string {
|
||||
return props.translateName ? tt(name) : name;
|
||||
}
|
||||
|
||||
function getDisplayValue(value: number): string {
|
||||
if (props.percentValue) {
|
||||
return formatPercentToLocalizedNumerals(value, 2, '<0.01');
|
||||
}
|
||||
|
||||
if (props.amountValue) {
|
||||
return formatAmountToLocalizedNumeralsWithCurrency(value, props.defaultCurrency);
|
||||
}
|
||||
|
||||
return formatNumberToLocalizedNumerals(value, 2);
|
||||
}
|
||||
|
||||
function exportData(): { headers: string[], data: string[][] } {
|
||||
const headers: string[] = [];
|
||||
const data: string[][] = [];
|
||||
|
||||
headers.push(props.categoryTypeName);
|
||||
|
||||
for (const categoryName of props.allCategoryNames) {
|
||||
headers.push(categoryName);
|
||||
}
|
||||
|
||||
const allData: Record<string, number> = {};
|
||||
|
||||
for (const item of heatMapData.value.data) {
|
||||
allData[`${item[0]}-${item[1]}`] = item[2];
|
||||
}
|
||||
|
||||
for (const [seriesName, seriesKey] of itemAndIndex(heatMapData.value.allSeriesNames)) {
|
||||
const row: string[] = [];
|
||||
row.push(seriesName);
|
||||
for (let categoryIndex = 0; categoryIndex < props.allCategoryNames.length; categoryIndex++) {
|
||||
const value = allData[`${categoryIndex}-${seriesKey}`];
|
||||
row.push(formatAmountToWesternArabicNumeralsWithoutDigitGrouping(value ?? 0, props.defaultCurrency));
|
||||
}
|
||||
data.push(row);
|
||||
}
|
||||
|
||||
return {
|
||||
headers: headers,
|
||||
data: data
|
||||
};
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
exportData
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.heatmap-chart-container {
|
||||
width: 100%;
|
||||
height: 560px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
@media (min-width: 600px) {
|
||||
.heatmap-chart-container {
|
||||
height: 630px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,259 @@
|
||||
<template>
|
||||
<v-chart autoresize :class="finalClass" :option="chartOptions" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useTheme } from 'vuetify';
|
||||
import type { CallbackDataParams } from 'echarts/types/dist/shared';
|
||||
|
||||
import { useI18n } from '@/locales/helpers.ts';
|
||||
|
||||
import { itemAndIndex } from '@/core/base.ts';
|
||||
import type { ColorValue, ColorStyleValue } from '@/core/color.ts';
|
||||
import { ThemeType } from '@/core/theme.ts';
|
||||
import { DEFAULT_CHART_COLORS } from '@/consts/color.ts';
|
||||
|
||||
import { isArray, isString, isNumber } from '@/lib/common.ts';
|
||||
import { getDisplayColor } from '@/lib/color.ts';
|
||||
|
||||
export type HierarchyChartDisplayType = 'treemap' | 'sunburst';
|
||||
|
||||
interface HierarchyDataItem {
|
||||
name: string;
|
||||
value: number;
|
||||
children?: HierarchyDataItem[];
|
||||
itemStyle: {
|
||||
color: ColorStyleValue;
|
||||
};
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
class?: string;
|
||||
skeleton?: boolean;
|
||||
type: HierarchyChartDisplayType;
|
||||
showValue?: boolean;
|
||||
categoryTypeName: string;
|
||||
allCategoryNames: string[];
|
||||
items: Record<string, unknown>[];
|
||||
nameField: string;
|
||||
valuesField: string;
|
||||
colorField?: string;
|
||||
hiddenField?: string;
|
||||
translateName?: boolean;
|
||||
amountValue?: boolean;
|
||||
percentValue?: boolean;
|
||||
defaultCurrency?: string;
|
||||
}>();
|
||||
|
||||
const theme = useTheme();
|
||||
|
||||
const {
|
||||
tt,
|
||||
formatAmountToLocalizedNumeralsWithCurrency,
|
||||
formatAmountToWesternArabicNumeralsWithoutDigitGrouping,
|
||||
formatNumberToLocalizedNumerals,
|
||||
formatPercentToLocalizedNumerals
|
||||
} = useI18n();
|
||||
|
||||
const isDarkMode = computed<boolean>(() => theme.global.name.value === ThemeType.Dark);
|
||||
const finalClass = computed<string>(() => {
|
||||
let finalClass = '';
|
||||
|
||||
if (props.skeleton) {
|
||||
finalClass += 'transition-in';
|
||||
}
|
||||
|
||||
if (props.class) {
|
||||
finalClass += ` ${props.class}`;
|
||||
} else {
|
||||
finalClass += ' hierarchy-chart-container';
|
||||
}
|
||||
|
||||
return finalClass;
|
||||
});
|
||||
|
||||
const hierarchyData = computed<HierarchyDataItem[]>(() => {
|
||||
const ret: HierarchyDataItem[] = [];
|
||||
|
||||
for (const [item, seriesIndex] of itemAndIndex(props.items)) {
|
||||
if (props.hiddenField && item[props.hiddenField]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isArray(item[props.valuesField])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const color: ColorStyleValue = getDisplayColor((props.colorField && item[props.colorField]) ? item[props.colorField] as ColorValue : DEFAULT_CHART_COLORS[seriesIndex % DEFAULT_CHART_COLORS.length]);
|
||||
|
||||
const hierarchyItem: HierarchyDataItem = {
|
||||
name: getItemName(item[props.nameField] as string),
|
||||
value: 0,
|
||||
children: [],
|
||||
itemStyle: {
|
||||
color: color
|
||||
}
|
||||
};
|
||||
|
||||
const allAmounts: number[] = item[props.valuesField] as number[];
|
||||
|
||||
for (const [amount, categoryIndex] of itemAndIndex(allAmounts)) {
|
||||
hierarchyItem.value += amount;
|
||||
hierarchyItem.children?.push({
|
||||
name: props.allCategoryNames[categoryIndex] ?? '',
|
||||
value: amount,
|
||||
itemStyle: {
|
||||
color: color
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ret.push(hierarchyItem);
|
||||
}
|
||||
|
||||
return ret;
|
||||
});
|
||||
|
||||
const chartOptions = computed<object>(() => {
|
||||
const seriesOptions: Record<string, unknown> = {
|
||||
type: props.type,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
right: 20,
|
||||
top: 0,
|
||||
bottom: 20,
|
||||
data: hierarchyData.value,
|
||||
levels: [
|
||||
{
|
||||
itemStyle: {
|
||||
gapWidth: 2
|
||||
}
|
||||
},
|
||||
{
|
||||
itemStyle: {
|
||||
gapWidth: 1
|
||||
}
|
||||
}
|
||||
],
|
||||
animation: !props.skeleton,
|
||||
nodeClick: false
|
||||
};
|
||||
|
||||
if (props.type === 'treemap') {
|
||||
seriesOptions['breadcrumb'] = {
|
||||
show: false
|
||||
};
|
||||
} if (props.type === 'sunburst') {
|
||||
seriesOptions['radius'] = [60, '95%'];
|
||||
seriesOptions['itemStyle'] = {
|
||||
borderRadius: 7,
|
||||
borderWidth: 2
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
tooltip: {
|
||||
backgroundColor: isDarkMode.value ? '#333' : '#fff',
|
||||
borderColor: isDarkMode.value ? '#333' : '#fff',
|
||||
textStyle: {
|
||||
color: isDarkMode.value ? '#eee' : '#333'
|
||||
},
|
||||
formatter: (params: CallbackDataParams & { treePathInfo: { name: string, value: number }[] }) => {
|
||||
if (!props.showValue || !params.name) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const rootValue = params.treePathInfo.length > 0 ? params.treePathInfo[0]?.value : 0;
|
||||
const parentName = params.treePathInfo.length > 1 ? params.treePathInfo[params.treePathInfo.length - 2]?.name : undefined;
|
||||
const parentValue = params.treePathInfo.length > 1 ? params.treePathInfo[params.treePathInfo.length - 2]?.value : undefined;
|
||||
const parentDisplayValue = isNumber(parentValue) ? getDisplayValue(parentValue) : undefined;
|
||||
const parentPercent = isNumber(parentValue) && isNumber(rootValue) && rootValue > 0 ? formatPercentToLocalizedNumerals(100.0 * parentValue / rootValue, 2, '<0.01') : undefined;
|
||||
|
||||
const name = params.name;
|
||||
const displayValue = isNumber(params.value) ? getDisplayValue(params.value) : '';
|
||||
const percent = isNumber(params.value) && isNumber(parentValue) && parentValue > 0 ? formatPercentToLocalizedNumerals(100.0 * params.value / parentValue, 2, '<0.01') : undefined;
|
||||
|
||||
|
||||
let tooltip = `<tr><td><span class="chart-pointer" style="background-color: ${params.color}"></span><span>${name}</span></td>`
|
||||
+ `<td><span class="ms-5">${displayValue}</span>`
|
||||
+ (isString(percent) ? `<span class="ms-1">(${percent})</span>` : '')
|
||||
+ `</td></tr>`;
|
||||
|
||||
if (isString(parentName) && isString(parentDisplayValue) && parentValue !== rootValue) {
|
||||
tooltip = `<tr><td><span class="chart-pointer" style="background-color: ${params.color}"></span><span>${parentName}</span></td>`
|
||||
+ `<td><span class="ms-5">${parentDisplayValue}</span>`
|
||||
+ (isString(parentPercent) ? `<span class="ms-1">(${parentPercent})</span>` : '')
|
||||
+ `</td></tr>`
|
||||
+ tooltip;
|
||||
}
|
||||
|
||||
tooltip = `<table class="chart-tooltip-table"><tbody>` + tooltip + `</tbody></table>`;
|
||||
return tooltip;
|
||||
}
|
||||
},
|
||||
series: [ seriesOptions ]
|
||||
};
|
||||
});
|
||||
|
||||
function getItemName(name: string): string {
|
||||
return props.translateName ? tt(name) : name;
|
||||
}
|
||||
|
||||
function getDisplayValue(value: number): string {
|
||||
if (props.percentValue) {
|
||||
return formatPercentToLocalizedNumerals(value, 2, '<0.01');
|
||||
}
|
||||
|
||||
if (props.amountValue) {
|
||||
return formatAmountToLocalizedNumeralsWithCurrency(value, props.defaultCurrency);
|
||||
}
|
||||
|
||||
return formatNumberToLocalizedNumerals(value, 2);
|
||||
}
|
||||
|
||||
function exportData(): { headers: string[], data: string[][] } {
|
||||
const headers: string[] = [];
|
||||
const data: string[][] = [];
|
||||
|
||||
headers.push(props.categoryTypeName);
|
||||
|
||||
for (const categoryName of props.allCategoryNames) {
|
||||
headers.push(categoryName);
|
||||
}
|
||||
|
||||
for (const item of hierarchyData.value) {
|
||||
const row: string[] = [];
|
||||
row.push(item.name);
|
||||
|
||||
for (const child of item.children ?? []) {
|
||||
row.push(formatAmountToWesternArabicNumeralsWithoutDigitGrouping(child.value));
|
||||
}
|
||||
|
||||
data.push(row);
|
||||
}
|
||||
|
||||
return {
|
||||
headers: headers,
|
||||
data: data
|
||||
};
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
exportData
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hierarchy-chart-container {
|
||||
width: 100%;
|
||||
height: 560px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
@media (min-width: 600px) {
|
||||
.hierarchy-chart-container {
|
||||
height: 630px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -10,17 +10,17 @@
|
||||
:disabled="disabled"
|
||||
:icon="true"
|
||||
:color="isActive ? 'primary' : 'default'"
|
||||
@click="currentPage = parseInt(page)"
|
||||
v-if="page !== '...'"
|
||||
@click="currentPage = key;"
|
||||
v-if="isNumber(key)"
|
||||
>
|
||||
<span>{{ getDisplayPage(page) }}</span>
|
||||
<span>{{ formatNumberToLocalizedNumerals(key) }}</span>
|
||||
</v-btn>
|
||||
<v-btn variant="text"
|
||||
color="default"
|
||||
:density="density"
|
||||
:disabled="disabled"
|
||||
:icon="true"
|
||||
v-if="page === '...'"
|
||||
v-if="!isNumber(key)"
|
||||
>
|
||||
<span>{{ page }}</span>
|
||||
<v-menu activator="parent"
|
||||
@@ -30,12 +30,13 @@
|
||||
<v-list>
|
||||
<v-list-item class="text-sm" :density="density">
|
||||
<v-list-item-title class="cursor-pointer">
|
||||
<v-autocomplete width="100"
|
||||
<v-autocomplete width="110"
|
||||
item-title="name"
|
||||
item-value="value"
|
||||
auto-select-first="exact"
|
||||
auto-select-first
|
||||
:density="density"
|
||||
:items="allPages"
|
||||
:custom-filter="customFilter"
|
||||
:no-data-text="tt('No results')"
|
||||
v-model="currentPage"/>
|
||||
</v-list-item-title>
|
||||
@@ -49,11 +50,13 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import type { InternalItem, FilterMatch } from 'vuetify/lib/composables/filter.d.ts';
|
||||
|
||||
import { useI18n } from '@/locales/helpers.ts';
|
||||
|
||||
import { type NameNumeralValue, keys } from '@/core/base.ts';
|
||||
import type { NumeralSystem } from '@/core/numeral.ts';
|
||||
|
||||
import { isNumber } from '@/lib/common.ts';
|
||||
import type { ComponentDensity } from '@/lib/ui/desktop.ts';
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -68,21 +71,15 @@ const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: number): void;
|
||||
}>();
|
||||
|
||||
const { tt, getCurrentNumeralSystemType } = useI18n();
|
||||
const { tt, formatNumberToLocalizedNumerals } = useI18n();
|
||||
|
||||
const showMenus = ref<Record<string, boolean>>({});
|
||||
|
||||
const numeralSystem = computed<NumeralSystem>(() => getCurrentNumeralSystemType());
|
||||
|
||||
function getDisplayPage(page: number | string): string {
|
||||
return numeralSystem.value.replaceWesternArabicDigitsToLocalizedDigits(page.toString());
|
||||
}
|
||||
|
||||
const allPages = computed<NameNumeralValue[]>(() => {
|
||||
const pages: NameNumeralValue[] = [];
|
||||
|
||||
for (let i = 1; i <= props.totalPageCount; i++) {
|
||||
pages.push({ value: i, name: getDisplayPage(i) });
|
||||
pages.push({ value: i, name: formatNumberToLocalizedNumerals(i) });
|
||||
}
|
||||
|
||||
return pages;
|
||||
@@ -100,4 +97,13 @@ const currentPage = computed<number>({
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function customFilter(value: string, query: string, item?: InternalItem): FilterMatch {
|
||||
if (!item) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const page = item.value as number;
|
||||
return page.toString(10).includes(query);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -38,6 +38,7 @@ const props = defineProps<{
|
||||
colorField?: string;
|
||||
hiddenField?: string;
|
||||
amountValue?: boolean;
|
||||
percentValue?: boolean;
|
||||
defaultCurrency?: string;
|
||||
showValue?: boolean;
|
||||
showPercent?: boolean;
|
||||
@@ -84,7 +85,7 @@ const radarData = computed<RadarChartData>(() => {
|
||||
|
||||
const finalPercent = (isNumber(percent) && percent >= 0) ? percent : (value > 0 ? value / totalValidValue * 100 : 0);
|
||||
const displayPercent = formatPercentToLocalizedNumerals(finalPercent, 2, '<0.01');
|
||||
const displayValue = props.amountValue ? formatAmountToLocalizedNumeralsWithCurrency(value, props.defaultCurrency) : formatNumberToLocalizedNumerals(value, 2);
|
||||
const displayValue = getDisplayValue(value);
|
||||
|
||||
indicators.push({
|
||||
name: name,
|
||||
@@ -189,6 +190,18 @@ const chartOptions = computed<object>(() => {
|
||||
] : []
|
||||
};
|
||||
});
|
||||
|
||||
function getDisplayValue(value: number): string {
|
||||
if (props.percentValue) {
|
||||
return formatPercentToLocalizedNumerals(value, 2, '<0.01');
|
||||
}
|
||||
|
||||
if (props.amountValue) {
|
||||
return formatAmountToLocalizedNumeralsWithCurrency(value, props.defaultCurrency);
|
||||
}
|
||||
|
||||
return formatNumberToLocalizedNumerals(value, 2);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -28,25 +28,42 @@
|
||||
<v-list v-if="frequencyType === ScheduledTemplateFrequencyType.Disabled.type">
|
||||
<v-list-item :title="tt('None')"></v-list-item>
|
||||
</v-list>
|
||||
<v-list v-if="frequencyType === ScheduledTemplateFrequencyType.Daily.type">
|
||||
<v-list-item :title="tt('Daily')"></v-list-item>
|
||||
</v-list>
|
||||
<v-list select-strategy="classic" v-model:selected="frequencyValue"
|
||||
v-else-if="frequencyType === ScheduledTemplateFrequencyType.Weekly.type">
|
||||
<v-list-item :key="weekDay.type" :value="weekDay.type" :title="weekDay.displayName"
|
||||
:class="{ 'frequency-value-selected v-list-item--active text-primary': isFrequencyValueSelected(weekDay.type) }"
|
||||
v-for="weekDay in allWeekDays">
|
||||
<template #prepend="{ isActive }">
|
||||
<v-checkbox density="compact" class="me-1" :model-value="isActive"
|
||||
@update:model-value="updateFrequencyValue(weekDay.type, $event)"></v-checkbox>
|
||||
<template #prepend="{ isSelected, select }">
|
||||
<v-list-item-action start>
|
||||
<v-checkbox-btn density="compact" :model-value="isSelected" @update:model-value="select"></v-checkbox-btn>
|
||||
</v-list-item-action>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
<v-list select-strategy="classic" v-model:selected="frequencyValue"
|
||||
v-else-if="frequencyType === ScheduledTemplateFrequencyType.Monthly.type">
|
||||
<v-list-item :key="monthDay.day" :value="monthDay.day" :title="monthDay.displayName"
|
||||
:class="{ 'frequency-value-selected v-list-item--active text-primary': isFrequencyValueSelected(monthDay.day) }"
|
||||
<v-list-item :key="monthDay.type" :value="monthDay.type" :title="monthDay.displayName"
|
||||
:class="{ 'frequency-value-selected v-list-item--active text-primary': isFrequencyValueSelected(monthDay.type) }"
|
||||
v-for="monthDay in allAvailableMonthDays">
|
||||
<template #prepend="{ isActive }">
|
||||
<v-checkbox density="compact" class="me-1" :model-value="isActive"
|
||||
@update:model-value="updateFrequencyValue(monthDay.day, $event)"></v-checkbox>
|
||||
<template #prepend="{ isSelected, select }">
|
||||
<v-list-item-action start>
|
||||
<v-checkbox-btn density="compact" :model-value="isSelected" @update:model-value="select"></v-checkbox-btn>
|
||||
</v-list-item-action>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
<v-list select-strategy="classic" v-model:selected="frequencyValue"
|
||||
v-else-if="frequencyType === ScheduledTemplateFrequencyType.Yearly.type">
|
||||
<v-list-item :key="monthAndDay.type" :value="monthAndDay.type" :title="monthAndDay.displayName"
|
||||
:class="{ 'frequency-value-selected v-list-item--active text-primary': isFrequencyValueSelected(monthAndDay.type) }"
|
||||
v-for="monthAndDay in allAvailableMonthAndDays">
|
||||
<template #prepend="{ isSelected, select }">
|
||||
<v-list-item-action start>
|
||||
<v-checkbox-btn density="compact" :model-value="isSelected" @update:model-value="select"></v-checkbox-btn>
|
||||
</v-list-item-action>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
@@ -75,8 +92,19 @@ const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string): void;
|
||||
}>();
|
||||
|
||||
const { tt, getMultiMonthdayShortNames, getMultiWeekdayLongNames } = useI18n();
|
||||
const { allTransactionScheduledFrequencyTypes, allWeekDays, allAvailableMonthDays, getFrequencyValues } = useScheduleFrequencySelectionBase();
|
||||
const {
|
||||
tt,
|
||||
getMultiMonthAndDayLongNames,
|
||||
getMultiMonthdayShortNames,
|
||||
getMultiWeekdayLongNames
|
||||
} = useI18n();
|
||||
const {
|
||||
allTransactionScheduledFrequencyTypes,
|
||||
allWeekDays,
|
||||
allAvailableMonthDays,
|
||||
allAvailableMonthAndDays,
|
||||
getFrequencyValues
|
||||
} = useScheduleFrequencySelectionBase();
|
||||
|
||||
const userStore = useUserStore();
|
||||
|
||||
@@ -92,10 +120,14 @@ const frequencyType = computed<number>({
|
||||
if (props.type !== value) {
|
||||
emit('update:type', value);
|
||||
|
||||
if (value === ScheduledTemplateFrequencyType.Weekly.type) {
|
||||
if (value === ScheduledTemplateFrequencyType.Daily.type) {
|
||||
frequencyValue.value = [0];
|
||||
} else if (value === ScheduledTemplateFrequencyType.Weekly.type) {
|
||||
frequencyValue.value = [firstDayOfWeek.value];
|
||||
} else if (value === ScheduledTemplateFrequencyType.Monthly.type) {
|
||||
frequencyValue.value = [1];
|
||||
} else if (value === ScheduledTemplateFrequencyType.Yearly.type) {
|
||||
frequencyValue.value = [101];
|
||||
} else {
|
||||
frequencyValue.value = [];
|
||||
}
|
||||
@@ -113,6 +145,8 @@ const frequencyValue = computed<number[]>({
|
||||
const displayFrequency = computed<string>(() => {
|
||||
if (frequencyType.value === ScheduledTemplateFrequencyType.Disabled.type) {
|
||||
return tt('Disabled');
|
||||
} else if (frequencyType.value === ScheduledTemplateFrequencyType.Daily.type) {
|
||||
return tt('Daily');
|
||||
} else if (frequencyType.value === ScheduledTemplateFrequencyType.Weekly.type) {
|
||||
if (frequencyValue.value.length) {
|
||||
return tt('format.misc.everyMultiDaysOfWeek', {
|
||||
@@ -129,28 +163,19 @@ const displayFrequency = computed<string>(() => {
|
||||
} else {
|
||||
return tt('Monthly');
|
||||
}
|
||||
} else if (frequencyType.value === ScheduledTemplateFrequencyType.Yearly.type) {
|
||||
if (frequencyValue.value.length) {
|
||||
return tt('format.misc.everyMultiDaysOfYear', {
|
||||
days: getMultiMonthAndDayLongNames(frequencyValue.value)
|
||||
});
|
||||
} else {
|
||||
return tt('Yearly');
|
||||
}
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
});
|
||||
|
||||
function updateFrequencyValue(value: number, selected: boolean | null): void {
|
||||
const currentFrequencyValues = frequencyValue.value;
|
||||
const newFrequencyValues: number[] = [];
|
||||
|
||||
for (const currentFrequencyValue of currentFrequencyValues) {
|
||||
if (currentFrequencyValue !== value || selected) {
|
||||
newFrequencyValues.push(currentFrequencyValue);
|
||||
}
|
||||
}
|
||||
|
||||
if (selected) {
|
||||
newFrequencyValues.push(value);
|
||||
}
|
||||
|
||||
frequencyValue.value = sortNumbersArray(newFrequencyValues);
|
||||
}
|
||||
|
||||
function isFrequencyValueSelected(currentValue: number): boolean {
|
||||
return frequencyValue.value.indexOf(currentValue) >= 0;
|
||||
}
|
||||
|
||||
@@ -64,6 +64,9 @@ import {
|
||||
getDateTypeByDateRange,
|
||||
getFiscalYearFromUnixTime
|
||||
} from '@/lib/datetime.ts';
|
||||
import {
|
||||
getDateRangeKeyWithYearOffset
|
||||
} from '@/lib/statistics.ts';
|
||||
|
||||
type AxisChartType = InstanceType<typeof AxisChart>;
|
||||
|
||||
@@ -118,7 +121,15 @@ const allTooltipExtraColumnNames = computed<string[]>(() => {
|
||||
}
|
||||
|
||||
if (props.showPeriodOverPeriod) {
|
||||
extraColumnNames.push(tt('Period-over-Period'));
|
||||
if (props.dateAggregationType === ChartDateAggregationType.Quarter.type) {
|
||||
extraColumnNames.push(tt('Quarter-over-Quarter'));
|
||||
} else if (props.dateAggregationType === ChartDateAggregationType.Month.type) {
|
||||
extraColumnNames.push(tt('Month-over-Month'));
|
||||
} else if (props.dateAggregationType === ChartDateAggregationType.Day.type && props.chartMode === 'daily') {
|
||||
extraColumnNames.push(tt('Day-over-Day'));
|
||||
} else {
|
||||
extraColumnNames.push(tt('Period-over-Period'));
|
||||
}
|
||||
}
|
||||
|
||||
return extraColumnNames;
|
||||
@@ -297,19 +308,11 @@ function getSeriesId(item: Record<string, unknown>): string {
|
||||
}
|
||||
|
||||
function getDateRangeKey(dateRange: YearUnixTime | FiscalYearUnixTime | YearQuarterUnixTime | YearMonthUnixTime | YearMonthDayUnixTime, yearOffset?: number): string | undefined {
|
||||
if (props.dateAggregationType === ChartDateAggregationType.Year.type) {
|
||||
return (dateRange.year + (yearOffset ?? 0)).toString();
|
||||
} else if (props.dateAggregationType === ChartDateAggregationType.FiscalYear.type && 'year' in dateRange) {
|
||||
return (dateRange.year + (yearOffset ?? 0)).toString();
|
||||
} else if (props.dateAggregationType === ChartDateAggregationType.Quarter.type && 'quarter' in dateRange) {
|
||||
return `${dateRange.year + (yearOffset ?? 0)}-${dateRange.quarter}`;
|
||||
} else if (props.dateAggregationType === ChartDateAggregationType.Month.type && 'month0base' in dateRange) {
|
||||
return `${dateRange.year + (yearOffset ?? 0)}-${dateRange.month0base + 1}`;
|
||||
} else if (props.dateAggregationType === ChartDateAggregationType.Day.type && 'day' in dateRange && props.chartMode === 'daily') {
|
||||
return `${dateRange.year + (yearOffset ?? 0)}-${dateRange.month}-${dateRange.day}`;
|
||||
} else {
|
||||
if (props.dateAggregationType === ChartDateAggregationType.Day.type && props.chartMode !== 'daily') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return getDateRangeKeyWithYearOffset(dateRange, props.dateAggregationType, yearOffset);
|
||||
}
|
||||
|
||||
function formatDisplayChangeRate(current: number, reference: number): string {
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
:key="item.index"
|
||||
:style="`top: ${virtualDataItems.topPosition}px`"
|
||||
:virtual-list-index="item.index"
|
||||
:title="item.displayDate"
|
||||
:title="item.alternativeDisplayDate || item.displayDate"
|
||||
:after="formatAmountToLocalizedNumeralsWithCurrency(item.closingBalance, account.currency)"
|
||||
v-for="item in virtualDataItems.items"
|
||||
>
|
||||
@@ -96,7 +96,10 @@ const allVirtualListItems = computed<MobileAccountBalanceTrendsChartItem[]>(() =
|
||||
|
||||
const finalDataItem: MobileAccountBalanceTrendsChartItem = {
|
||||
index: index,
|
||||
dateRangeKey: dataItem.dateRangeKey,
|
||||
lastYearDateRangeKey: dataItem.lastYearDateRangeKey,
|
||||
displayDate: dataItem.displayDate,
|
||||
alternativeDisplayDate: dataItem.alternativeDisplayDate,
|
||||
openingBalance: dataItem.openingBalance,
|
||||
closingBalance: dataItem.closingBalance,
|
||||
medianBalance: dataItem.medianBalance,
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
<f7-toolbar class="toolbar-with-swipe-handler">
|
||||
<div class="swipe-handler"></div>
|
||||
<div class="left">
|
||||
<f7-link :text="tt('Now')" @click="setCurrentTime"></f7-link>
|
||||
<f7-link :text="tt('Clear')" @click="clear" v-if="clearable"></f7-link>
|
||||
<f7-link :text="tt('Now')" @click="setCurrentTime" v-if="!clearable"></f7-link>
|
||||
</div>
|
||||
<div class="right">
|
||||
<f7-link :icon-f7="mode === 'time' ? 'calendar' : 'clock'" @click="switchMode"></f7-link>
|
||||
@@ -122,11 +123,13 @@ const props = defineProps<{
|
||||
modelValue: number;
|
||||
timezoneUtcOffset: number;
|
||||
initMode?: string;
|
||||
clearable?: boolean;
|
||||
show: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: number): void;
|
||||
(e: 'clear:modelValue'): void;
|
||||
(e: 'update:show', value: boolean): void;
|
||||
}>();
|
||||
|
||||
@@ -221,6 +224,11 @@ function setCurrentTime(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function clear(): void {
|
||||
emit('clear:modelValue');
|
||||
emit('update:show', false);
|
||||
}
|
||||
|
||||
function confirm(): void {
|
||||
if (!dateTime.value) {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div :class="imageBoxClass" :style="style">
|
||||
<img class="image-with-placeholder" :class="{ 'image-loading': loading }"
|
||||
:src="src" :alt="alt" v-if="!link && !loadError"
|
||||
@load="onLoad" @error="onError"/>
|
||||
<f7-link class="image-link" :class="{ 'image-loading': loading }"
|
||||
:href="link" v-if="link && !loadError">
|
||||
<img class="image-with-placeholder" :src="src" :alt="alt"
|
||||
@load="onLoad" @error="onError"/>
|
||||
</f7-link>
|
||||
<div class="image-loading-hint" v-if="loading && !loadError">
|
||||
<f7-preloader size="28" />
|
||||
</div>
|
||||
<div class="image-error-hint" v-if="!link && !loading && loadError">
|
||||
<slot name="error"></slot>
|
||||
</div>
|
||||
<f7-link class="image-error-hint" :href="link" v-if="link && !loading && loadError">
|
||||
<slot name="error"></slot>
|
||||
</f7-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
src: string;
|
||||
class?: string;
|
||||
style?: string
|
||||
alt?: string;
|
||||
link?: string;
|
||||
}>();
|
||||
|
||||
const loading = ref<boolean>(true);
|
||||
const loadError = ref<boolean>(false);
|
||||
|
||||
const imageBoxClass = computed<string>(() => {
|
||||
let classes = 'image-box';
|
||||
|
||||
if (props.class) {
|
||||
classes += ` ${props.class}`;
|
||||
}
|
||||
|
||||
return classes;
|
||||
});
|
||||
|
||||
function onLoad(): void {
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
function onError(): void {
|
||||
loading.value = false;
|
||||
loadError.value = true;
|
||||
}
|
||||
|
||||
watch(() => props.src, () => {
|
||||
loading.value = true;
|
||||
loadError.value = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.image-box > .image-with-placeholder,
|
||||
.image-box > .image-link,
|
||||
.image-box > .image-link > .image-with-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.image-box > .image-with-placeholder.image-loading,
|
||||
.image-box > .image-link.image-loading {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.image-box > .image-loading-hint {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.image-box > .image-error-hint {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
font-size: var(--f7-list-item-footer-font-size);
|
||||
color: var(--f7-list-item-footer-text-color);
|
||||
padding: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -18,55 +18,57 @@
|
||||
</f7-popover>
|
||||
|
||||
<div class="numpad-buttons">
|
||||
<f7-button class="numpad-button numpad-button-num" @click="inputNum(7)">
|
||||
<f7-button class="numpad-button numpad-button-num" @pointerup="inputNum(7)">
|
||||
<span class="numpad-button-text numpad-button-text-normal">{{ digits[7] }}</span>
|
||||
</f7-button>
|
||||
<f7-button class="numpad-button numpad-button-num" @click="inputNum(8)">
|
||||
<f7-button class="numpad-button numpad-button-num" @pointerup="inputNum(8)">
|
||||
<span class="numpad-button-text numpad-button-text-normal">{{ digits[8] }}</span>
|
||||
</f7-button>
|
||||
<f7-button class="numpad-button numpad-button-num" @click="inputNum(9)">
|
||||
<f7-button class="numpad-button numpad-button-num" @pointerup="inputNum(9)">
|
||||
<span class="numpad-button-text numpad-button-text-normal">{{ digits[9] }}</span>
|
||||
</f7-button>
|
||||
<f7-button class="numpad-button numpad-button-function no-right-border" @click="setSymbol('×')">
|
||||
<f7-button class="numpad-button numpad-button-function no-right-border" @pointerup="setSymbol('×')">
|
||||
<span class="numpad-button-text numpad-button-text-normal">×</span>
|
||||
</f7-button>
|
||||
<f7-button class="numpad-button numpad-button-num" @click="inputNum(4)">
|
||||
<f7-button class="numpad-button numpad-button-num" @pointerup="inputNum(4)">
|
||||
<span class="numpad-button-text numpad-button-text-normal">{{ digits[4] }}</span>
|
||||
</f7-button>
|
||||
<f7-button class="numpad-button numpad-button-num" @click="inputNum(5)">
|
||||
<f7-button class="numpad-button numpad-button-num" @pointerup="inputNum(5)">
|
||||
<span class="numpad-button-text numpad-button-text-normal">{{ digits[5] }}</span>
|
||||
</f7-button>
|
||||
<f7-button class="numpad-button numpad-button-num" @click="inputNum(6)">
|
||||
<f7-button class="numpad-button numpad-button-num" @pointerup="inputNum(6)">
|
||||
<span class="numpad-button-text numpad-button-text-normal">{{ digits[6] }}</span>
|
||||
</f7-button>
|
||||
<f7-button class="numpad-button numpad-button-function no-right-border" @click="setSymbol('−')">
|
||||
<f7-button class="numpad-button numpad-button-function no-right-border" @pointerup="setSymbol('−')">
|
||||
<span class="numpad-button-text numpad-button-text-normal">−</span>
|
||||
</f7-button>
|
||||
<f7-button class="numpad-button numpad-button-num" @click="inputNum(1)">
|
||||
<f7-button class="numpad-button numpad-button-num" @pointerup="inputNum(1)">
|
||||
<span class="numpad-button-text numpad-button-text-normal">{{ digits[1] }}</span>
|
||||
</f7-button>
|
||||
<f7-button class="numpad-button numpad-button-num" @click="inputNum(2)">
|
||||
<f7-button class="numpad-button numpad-button-num" @pointerup="inputNum(2)">
|
||||
<span class="numpad-button-text numpad-button-text-normal">{{ digits[2] }}</span>
|
||||
</f7-button>
|
||||
<f7-button class="numpad-button numpad-button-num" @click="inputNum(3)">
|
||||
<f7-button class="numpad-button numpad-button-num" @pointerup="inputNum(3)">
|
||||
<span class="numpad-button-text numpad-button-text-normal">{{ digits[3] }}</span>
|
||||
</f7-button>
|
||||
<f7-button class="numpad-button numpad-button-function no-right-border" @click="setSymbol('+')">
|
||||
<f7-button class="numpad-button numpad-button-function no-right-border" @pointerup="setSymbol('+')">
|
||||
<span class="numpad-button-text numpad-button-text-normal">+</span>
|
||||
</f7-button>
|
||||
<f7-button class="numpad-button numpad-button-num" v-if="supportDecimalSeparator" @click="inputDecimalSeparator()">
|
||||
<f7-button class="numpad-button numpad-button-num" @pointerup="inputDecimalSeparator()"
|
||||
v-if="supportDecimalSeparator">
|
||||
<span class="numpad-button-text numpad-button-text-normal">{{ decimalSeparator }}</span>
|
||||
</f7-button>
|
||||
<f7-button class="numpad-button numpad-button-num" v-if="!supportDecimalSeparator" @click="inputDoubleNum(0)">
|
||||
<f7-button class="numpad-button numpad-button-num" @pointerup="inputDoubleNum(0)"
|
||||
v-if="!supportDecimalSeparator">
|
||||
<span class="numpad-button-text numpad-button-text-normal">{{ `${digits[0]}${digits[0]}` }}</span>
|
||||
</f7-button>
|
||||
<f7-button class="numpad-button numpad-button-num" @click="inputNum(0)">
|
||||
<f7-button class="numpad-button numpad-button-num" @pointerup="inputNum(0)">
|
||||
<span class="numpad-button-text numpad-button-text-normal">{{ digits[0] }}</span>
|
||||
</f7-button>
|
||||
<f7-button class="numpad-button numpad-button-num" @click="backspace" @taphold="clear()">
|
||||
<span class="numpad-button-text numpad-button-text-normal">
|
||||
<f7-icon class="icon-with-direction" f7="delete_left"></f7-icon>
|
||||
</span>
|
||||
<f7-button class="numpad-button numpad-button-num" @pointerup="backspace" @taphold="clear()">
|
||||
<span class="numpad-button-text numpad-button-text-normal">
|
||||
<f7-icon class="icon-with-direction" f7="delete_left"></f7-icon>
|
||||
</span>
|
||||
</f7-button>
|
||||
<f7-button class="numpad-button numpad-button-confirm no-right-border no-bottom-border" fill @click="confirm()">
|
||||
<span :class="{ 'numpad-button-text': true, 'numpad-button-text-confirm': !currentSymbol }">{{ confirmText }}</span>
|
||||
@@ -83,6 +85,7 @@ import { useI18n } from '@/locales/helpers.ts';
|
||||
import { useI18nUIComponents, isiOS } from '@/lib/ui/mobile.ts';
|
||||
|
||||
import { NumeralSystem } from '@/core/numeral.ts';
|
||||
import { AMOUNT_FACTOR } from '@/consts/numeral.ts';
|
||||
import { ALL_CURRENCIES } from '@/consts/currency.ts';
|
||||
import { isNumber } from '@/lib/common.ts';
|
||||
import logger from '@/lib/logger.ts';
|
||||
@@ -385,7 +388,7 @@ function confirm(): boolean {
|
||||
finalValue = previous - current;
|
||||
break;
|
||||
case '×':
|
||||
finalValue = Math.trunc(previous * current / 100);
|
||||
finalValue = Math.trunc(previous * current / AMOUNT_FACTOR);
|
||||
break;
|
||||
default:
|
||||
finalValue = previous;
|
||||
@@ -511,13 +514,19 @@ watch(() => props.flipNegative, (newValue) => {
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
touch-action: manipulation;
|
||||
transition: transform 0.01s ease;
|
||||
}
|
||||
|
||||
.numpad-button-num {
|
||||
width: calc(80% / 3);
|
||||
}
|
||||
|
||||
.numpad-button-num:active,
|
||||
.numpad-button-function:active {
|
||||
background-color: var(--f7-button-pressed-bg-color, rgba(var(--f7-theme-color-rgb), 0.15));
|
||||
}
|
||||
|
||||
.numpad-button-function, .numpad-button-confirm {
|
||||
width: 20%;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,10 @@
|
||||
v-if="currentFrequencyType === ScheduledTemplateFrequencyType.Disabled.type">
|
||||
<f7-list-item :title="tt('None')"></f7-list-item>
|
||||
</f7-list>
|
||||
<f7-list dividers class="schedule-frequency-value-list no-margin-vertical"
|
||||
v-if="currentFrequencyType === ScheduledTemplateFrequencyType.Daily.type">
|
||||
<f7-list-item :title="tt('Daily')"></f7-list-item>
|
||||
</f7-list>
|
||||
<f7-list dividers class="schedule-frequency-value-list no-margin-vertical"
|
||||
v-if="currentFrequencyType === ScheduledTemplateFrequencyType.Weekly.type">
|
||||
<f7-list-item checkbox
|
||||
@@ -48,15 +52,27 @@
|
||||
<f7-list dividers class="schedule-frequency-value-list no-margin-vertical"
|
||||
v-if="currentFrequencyType === ScheduledTemplateFrequencyType.Monthly.type">
|
||||
<f7-list-item checkbox
|
||||
:class="isChecked(monthDay.day) ? 'list-item-selected' : ''"
|
||||
:key="monthDay.day"
|
||||
:value="monthDay.day"
|
||||
:checked="isChecked(monthDay.day)"
|
||||
:class="isChecked(monthDay.type) ? 'list-item-selected' : ''"
|
||||
:key="monthDay.type"
|
||||
:value="monthDay.type"
|
||||
:checked="isChecked(monthDay.type)"
|
||||
:title="monthDay.displayName"
|
||||
v-for="monthDay in allAvailableMonthDays"
|
||||
@change="changeFrequencyValue">
|
||||
</f7-list-item>
|
||||
</f7-list>
|
||||
<f7-list dividers class="schedule-frequency-value-list no-margin-vertical"
|
||||
v-if="currentFrequencyType === ScheduledTemplateFrequencyType.Yearly.type">
|
||||
<f7-list-item checkbox
|
||||
:class="isChecked(monthAndDay.type) ? 'list-item-selected' : ''"
|
||||
:key="monthAndDay.type"
|
||||
:value="monthAndDay.type"
|
||||
:checked="isChecked(monthAndDay.type)"
|
||||
:title="monthAndDay.displayName"
|
||||
v-for="monthAndDay in allAvailableMonthAndDays"
|
||||
@change="changeFrequencyValue">
|
||||
</f7-list-item>
|
||||
</f7-list>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -91,7 +107,13 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
const { tt } = useI18n();
|
||||
const { allTransactionScheduledFrequencyTypes, allWeekDays, allAvailableMonthDays, getFrequencyValues } = useScheduleFrequencySelectionBase();
|
||||
const {
|
||||
allTransactionScheduledFrequencyTypes,
|
||||
allWeekDays,
|
||||
allAvailableMonthDays,
|
||||
allAvailableMonthAndDays,
|
||||
getFrequencyValues
|
||||
} = useScheduleFrequencySelectionBase();
|
||||
|
||||
const userStore = useUserStore();
|
||||
|
||||
@@ -108,10 +130,14 @@ function changeFrequencyType(value: number): void {
|
||||
if (currentFrequencyType.value !== value) {
|
||||
currentFrequencyType.value = value;
|
||||
|
||||
if (value === ScheduledTemplateFrequencyType.Weekly.type) {
|
||||
if (value === ScheduledTemplateFrequencyType.Daily.type) {
|
||||
currentFrequencyValue.value = [0];
|
||||
} else if (value === ScheduledTemplateFrequencyType.Weekly.type) {
|
||||
currentFrequencyValue.value = [firstDayOfWeek.value];
|
||||
} else if (value === ScheduledTemplateFrequencyType.Monthly.type) {
|
||||
currentFrequencyValue.value = [1];
|
||||
} else if (value === ScheduledTemplateFrequencyType.Yearly.type) {
|
||||
currentFrequencyValue.value = [101];
|
||||
} else {
|
||||
currentFrequencyValue.value = [];
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ export const DEFAULT_API_TIMEOUT: number = 10000; // 10s
|
||||
export const DEFAULT_UPLOAD_API_TIMEOUT: number = 30000; // 30s
|
||||
export const DEFAULT_EXPORT_API_TIMEOUT: number = 180000; // 180s
|
||||
export const DEFAULT_IMPORT_API_TIMEOUT: number = 1800000; // 1800s
|
||||
export const DEFAULT_BATCH_UPDATE_TRANSACTIONS_API_TIMEOUT: number = 1800000; // 1800s
|
||||
export const DEFAULT_CLEAR_ALL_TRANSACTIONS_API_TIMEOUT: number = 1800000; // 1800s
|
||||
export const DEFAULT_LLM_API_TIMEOUT: number = 600000; // 600s
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { HiddenAmount } from '@/core/numeral.ts';
|
||||
|
||||
export const DEFAULT_DECIMAL_NUMBER_COUNT: number = 2;
|
||||
export const MAX_SUPPORTED_DECIMAL_NUMBER_COUNT: number = 2;
|
||||
export const AMOUNT_FACTOR: number = 10 ** MAX_SUPPORTED_DECIMAL_NUMBER_COUNT;
|
||||
|
||||
export const DISPLAY_HIDDEN_AMOUNT: HiddenAmount = '***';
|
||||
export const INCOMPLETE_AMOUNT_SUFFIX: string = '+';
|
||||
|
||||
@@ -600,3 +600,6 @@ export const ALL_TIMEZONES: TimezoneInfo[] = [
|
||||
timezoneName: 'Pacific/Kiritimati'
|
||||
}
|
||||
];
|
||||
|
||||
export const WESTERNMOST_TIMEZONE_UTC_OFFSET: number = -720; // Etc/GMT+12 (UTC-12:00)
|
||||
export const EASTERNMOST_TIMEZONE_UTC_OFFSET: number = 840; // Pacific/Kiritimati (UTC+14:00)
|
||||
|
||||
+34
-23
@@ -31,6 +31,7 @@ export interface DateTime {
|
||||
isLocalizedCalendarFirstDayOfMonth(options: DateTimeFormatOptions): boolean;
|
||||
getGregorianCalendarYearDashMonthDashDay(): TextualYearMonthDay;
|
||||
getGregorianCalendarYearDashMonth(): TextualYearMonth;
|
||||
getMaxDayOfGregorianCalendarMonth(): number;
|
||||
getWeekDay(): WeekDay;
|
||||
getWeekDayDisplayName(options: DateTimeFormatOptions): string
|
||||
getWeekDayDisplayShortName(options: DateTimeFormatOptions): string;
|
||||
@@ -700,49 +701,54 @@ export class DateRange implements TypeAndName {
|
||||
private static readonly allInstancesByType: Record<number, DateRange> = {};
|
||||
|
||||
// All date range
|
||||
public static readonly All = new DateRange(0, 'All', false, false, DateRangeScene.Normal, DateRangeScene.TrendAnalysis, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
|
||||
public static readonly All = new DateRange(0, 'All', false, false, false, DateRangeScene.Normal, DateRangeScene.TrendAnalysis, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
|
||||
|
||||
// Date ranges for normal scene only
|
||||
public static readonly Today = new DateRange(1, 'Today', false, false, DateRangeScene.Normal, DateRangeScene.InsightsExplorer);
|
||||
public static readonly Yesterday = new DateRange(2, 'Yesterday', false, false, DateRangeScene.Normal, DateRangeScene.InsightsExplorer);
|
||||
public static readonly LastSevenDays = new DateRange(3, 'Recent 7 days', false, false, DateRangeScene.Normal, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
|
||||
public static readonly LastThirtyDays = new DateRange(4, 'Recent 30 days', false, false, DateRangeScene.Normal, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
|
||||
public static readonly ThisWeek = new DateRange(5, 'This week', false, false, DateRangeScene.Normal, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
|
||||
public static readonly LastWeek = new DateRange(6, 'Last week', false, false, DateRangeScene.Normal, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
|
||||
public static readonly ThisMonth = new DateRange(7, 'This month', false, false, DateRangeScene.Normal, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
|
||||
public static readonly LastMonth = new DateRange(8, 'Last month', false, false, DateRangeScene.Normal, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
|
||||
public static readonly Today = new DateRange(1, 'Today', false, false, false, DateRangeScene.Normal, DateRangeScene.InsightsExplorer);
|
||||
public static readonly Yesterday = new DateRange(2, 'Yesterday', false, false, false, DateRangeScene.Normal, DateRangeScene.InsightsExplorer);
|
||||
public static readonly LastSevenDays = new DateRange(3, 'Recent 7 days', false, false, false, DateRangeScene.Normal, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
|
||||
public static readonly LastThirtyDays = new DateRange(4, 'Recent 30 days', false, false, false, DateRangeScene.Normal, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
|
||||
public static readonly ThisWeek = new DateRange(5, 'This week', false, false, false, DateRangeScene.Normal, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
|
||||
public static readonly LastWeek = new DateRange(6, 'Last week', false, false, false, DateRangeScene.Normal, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
|
||||
public static readonly ThisMonth = new DateRange(7, 'This month', false, false, false, DateRangeScene.Normal, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
|
||||
public static readonly LastMonth = new DateRange(8, 'Last month', false, false, false, DateRangeScene.Normal, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
|
||||
|
||||
// Date ranges for normal and trend analysis scene
|
||||
public static readonly ThisYear = new DateRange(9, 'This year', false, false, DateRangeScene.Normal, DateRangeScene.TrendAnalysis, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
|
||||
public static readonly LastYear = new DateRange(10, 'Last year', false, false, DateRangeScene.Normal, DateRangeScene.TrendAnalysis, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
|
||||
public static readonly ThisFiscalYear = new DateRange(11, 'This fiscal year', false, true, DateRangeScene.Normal, DateRangeScene.TrendAnalysis, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
|
||||
public static readonly LastFiscalYear = new DateRange(12, 'Last fiscal year', false, true, DateRangeScene.Normal, DateRangeScene.TrendAnalysis, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
|
||||
public static readonly ThisYear = new DateRange(9, 'This year', false, false, false, DateRangeScene.Normal, DateRangeScene.TrendAnalysis, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
|
||||
public static readonly LastYear = new DateRange(10, 'Last year', false, false, false, DateRangeScene.Normal, DateRangeScene.TrendAnalysis, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
|
||||
public static readonly ThisFiscalYear = new DateRange(11, 'This fiscal year', false, false, true, DateRangeScene.Normal, DateRangeScene.TrendAnalysis, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
|
||||
public static readonly LastFiscalYear = new DateRange(12, 'Last fiscal year', false, false, true, DateRangeScene.Normal, DateRangeScene.TrendAnalysis, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
|
||||
|
||||
// Billing cycle date ranges for normal scene only
|
||||
public static readonly CurrentBillingCycle = new DateRange(51, 'Current Billing Cycle', true, true, DateRangeScene.Normal);
|
||||
public static readonly PreviousBillingCycle = new DateRange(52, 'Previous Billing Cycle', true, true, DateRangeScene.Normal);
|
||||
public static readonly CurrentBillingCycle = new DateRange(51, 'Current Billing Cycle', true, false, true, DateRangeScene.Normal);
|
||||
public static readonly PreviousBillingCycle = new DateRange(52, 'Previous Billing Cycle', true, false, true, DateRangeScene.Normal);
|
||||
|
||||
// Last reconciled time ranges for normal scene only
|
||||
public static readonly SinceLastReconciledTime = new DateRange(71, 'Since Last Reconciled Time', false, true, true, DateRangeScene.Normal)
|
||||
|
||||
// Date ranges for trend analysis scene only
|
||||
public static readonly RecentTwelveMonths = new DateRange(101, 'Recent 12 months', false, false, DateRangeScene.TrendAnalysis, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
|
||||
public static readonly RecentTwentyFourMonths = new DateRange(102, 'Recent 24 months', false, false, DateRangeScene.TrendAnalysis, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
|
||||
public static readonly RecentThirtySixMonths = new DateRange(103, 'Recent 36 months', false, false, DateRangeScene.TrendAnalysis, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
|
||||
public static readonly RecentTwoYears = new DateRange(104, 'Recent 2 years', false, false, DateRangeScene.TrendAnalysis, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
|
||||
public static readonly RecentThreeYears = new DateRange(105, 'Recent 3 years', false, false, DateRangeScene.TrendAnalysis, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
|
||||
public static readonly RecentFiveYears = new DateRange(106, 'Recent 5 years', false, false, DateRangeScene.TrendAnalysis, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
|
||||
public static readonly RecentTwelveMonths = new DateRange(101, 'Recent 12 months', false, false, false, DateRangeScene.TrendAnalysis, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
|
||||
public static readonly RecentTwentyFourMonths = new DateRange(102, 'Recent 24 months', false, false, false, DateRangeScene.TrendAnalysis, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
|
||||
public static readonly RecentThirtySixMonths = new DateRange(103, 'Recent 36 months', false, false, false, DateRangeScene.TrendAnalysis, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
|
||||
public static readonly RecentTwoYears = new DateRange(104, 'Recent 2 years', false, false, false, DateRangeScene.TrendAnalysis, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
|
||||
public static readonly RecentThreeYears = new DateRange(105, 'Recent 3 years', false, false, false, DateRangeScene.TrendAnalysis, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
|
||||
public static readonly RecentFiveYears = new DateRange(106, 'Recent 5 years', false, false, false, DateRangeScene.TrendAnalysis, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
|
||||
|
||||
// Custom date range
|
||||
public static readonly Custom = new DateRange(255, 'Custom Date', false, true, DateRangeScene.Normal, DateRangeScene.TrendAnalysis, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
|
||||
public static readonly Custom = new DateRange(255, 'Custom Date', false, false, true, DateRangeScene.Normal, DateRangeScene.TrendAnalysis, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
|
||||
|
||||
public readonly type: number;
|
||||
public readonly name: string;
|
||||
public readonly isBillingCycle: boolean;
|
||||
public readonly isLastReconciledTimeRange: boolean;
|
||||
public readonly isUserCustomRange: boolean;
|
||||
private readonly availableScenes: Record<number, boolean>;
|
||||
|
||||
private constructor(type: number, name: string, isBillingCycle: boolean, isUserCustomRange: boolean, ...availableScenes: DateRangeScene[]) {
|
||||
private constructor(type: number, name: string, isBillingCycle: boolean, isLastReconciledTimeRange: boolean, isUserCustomRange: boolean, ...availableScenes: DateRangeScene[]) {
|
||||
this.type = type;
|
||||
this.name = name;
|
||||
this.isBillingCycle = isBillingCycle;
|
||||
this.isLastReconciledTimeRange = isLastReconciledTimeRange;
|
||||
this.isUserCustomRange = isUserCustomRange;
|
||||
this.availableScenes = {};
|
||||
|
||||
@@ -777,4 +783,9 @@ export class DateRange implements TypeAndName {
|
||||
const dateRange = DateRange.allInstancesByType[type];
|
||||
return dateRange?.isBillingCycle || false;
|
||||
}
|
||||
|
||||
public static isLastReconciledTimeRange(type: number): boolean {
|
||||
const dateRange = DateRange.allInstancesByType[type];
|
||||
return dateRange?.isLastReconciledTimeRange || false;
|
||||
}
|
||||
}
|
||||
|
||||
+201
-102
@@ -1,5 +1,6 @@
|
||||
import { type NameValue } from '@/core/base.ts';
|
||||
import { DateRange } from '@/core/datetime.ts';
|
||||
import { ChartSortingType } from '@/core/statistics.ts';
|
||||
|
||||
export enum TransactionExplorerConditionRelation {
|
||||
First = 'first',
|
||||
@@ -25,6 +26,11 @@ export const TransactionExplorerConditionRelationPriority: Record<TransactionExp
|
||||
|
||||
export enum TransactionExplorerConditionFieldType {
|
||||
Undefined = 'undefined',
|
||||
TransactionTimeDayOfWeek = 'transactionTimeDayOfWeek',
|
||||
TransactionTimeDayOfMonth = 'transactionTimeDayOfMonth',
|
||||
TransactionTimeMonthOfYear = 'transactionTimeMonthOfYear',
|
||||
TransactionTimeHourOfDay = 'transactionTimeHourOfDay',
|
||||
TransactionTimezone = 'transactionTimezone',
|
||||
TransactionType = 'transactionType',
|
||||
TransactionCategory = 'transactionCategory',
|
||||
SourceAccount = 'sourceAccount',
|
||||
@@ -41,6 +47,11 @@ export class TransactionExplorerConditionField implements NameValue {
|
||||
private static readonly allInstances: TransactionExplorerConditionField[] = [];
|
||||
private static readonly allInstancesByValue: Record<string, TransactionExplorerConditionField> = {};
|
||||
|
||||
public static readonly TransactionTimeDayOfWeek = new TransactionExplorerConditionField('Transaction Day of Week', TransactionExplorerConditionFieldType.TransactionTimeDayOfWeek);
|
||||
public static readonly TransactionTimeDayOfMonth = new TransactionExplorerConditionField('Transaction Day of Month', TransactionExplorerConditionFieldType.TransactionTimeDayOfMonth)
|
||||
public static readonly TransactionTimeMonthOfYear = new TransactionExplorerConditionField('Transaction Month of Year', TransactionExplorerConditionFieldType.TransactionTimeMonthOfYear);
|
||||
public static readonly TransactionTimeHourOfDay = new TransactionExplorerConditionField('Transaction Hour of Day', TransactionExplorerConditionFieldType.TransactionTimeHourOfDay);
|
||||
public static readonly TransactionTimezone = new TransactionExplorerConditionField('Transaction Timezone', TransactionExplorerConditionFieldType.TransactionTimezone);
|
||||
public static readonly TransactionType = new TransactionExplorerConditionField('Transaction Type', TransactionExplorerConditionFieldType.TransactionType);
|
||||
public static readonly TransactionCategory = new TransactionExplorerConditionField('Category', TransactionExplorerConditionFieldType.TransactionCategory);
|
||||
public static readonly SourceAccount = new TransactionExplorerConditionField('Source Account', TransactionExplorerConditionFieldType.SourceAccount);
|
||||
@@ -74,6 +85,7 @@ export class TransactionExplorerConditionField implements NameValue {
|
||||
|
||||
export enum TransactionExplorerConditionOperatorType {
|
||||
In = 'in',
|
||||
NotIn = 'notIn',
|
||||
GreaterThan = 'greaterThan',
|
||||
LessThan = 'lessThan',
|
||||
Equals = 'equals',
|
||||
@@ -92,6 +104,10 @@ export enum TransactionExplorerConditionOperatorType {
|
||||
NotStartsWith = 'notStartsWith',
|
||||
EndsWith = 'endsWith',
|
||||
NotEndsWith = 'notEndsWith',
|
||||
RegexMatch = 'regexMatch',
|
||||
NotRegexMatch = 'notRegexMatch',
|
||||
MinuteOffsetBetween = 'minuteOffsetBetween',
|
||||
MinuteOffsetNotBetween = 'minuteOffsetNotBetween',
|
||||
LatitudeBetween = 'latitudeBetween',
|
||||
LatitudeNotBetween = 'latitudeNotBetween',
|
||||
LongitudeBetween = 'longitudeBetween',
|
||||
@@ -103,6 +119,7 @@ export class TransactionExplorerConditionOperator implements NameValue {
|
||||
private static readonly allInstancesByValue: Record<string, TransactionExplorerConditionOperator> = {};
|
||||
|
||||
public static readonly In = new TransactionExplorerConditionOperator('In', TransactionExplorerConditionOperatorType.In);
|
||||
public static readonly NotIn = new TransactionExplorerConditionOperator('Not in', TransactionExplorerConditionOperatorType.NotIn);
|
||||
public static readonly GreaterThan = new TransactionExplorerConditionOperator('Greater than', TransactionExplorerConditionOperatorType.GreaterThan);
|
||||
public static readonly LessThan = new TransactionExplorerConditionOperator('Less than', TransactionExplorerConditionOperatorType.LessThan);
|
||||
public static readonly Equals = new TransactionExplorerConditionOperator('Equal to', TransactionExplorerConditionOperatorType.Equals);
|
||||
@@ -111,20 +128,24 @@ export class TransactionExplorerConditionOperator implements NameValue {
|
||||
public static readonly NotBetween = new TransactionExplorerConditionOperator('Not between', TransactionExplorerConditionOperatorType.NotBetween);
|
||||
public static readonly HasAny = new TransactionExplorerConditionOperator('Has any', TransactionExplorerConditionOperatorType.HasAny);
|
||||
public static readonly HasAll = new TransactionExplorerConditionOperator('Has all', TransactionExplorerConditionOperatorType.HasAll);
|
||||
public static readonly NotHasAny = new TransactionExplorerConditionOperator('Not has any', TransactionExplorerConditionOperatorType.NotHasAny);
|
||||
public static readonly NotHasAll = new TransactionExplorerConditionOperator('Not has all', TransactionExplorerConditionOperatorType.NotHasAll);
|
||||
public static readonly NotHasAny = new TransactionExplorerConditionOperator('Does not have any', TransactionExplorerConditionOperatorType.NotHasAny);
|
||||
public static readonly NotHasAll = new TransactionExplorerConditionOperator('Does not have all', TransactionExplorerConditionOperatorType.NotHasAll);
|
||||
public static readonly IsEmpty = new TransactionExplorerConditionOperator('Is empty', TransactionExplorerConditionOperatorType.IsEmpty);
|
||||
public static readonly IsNotEmpty = new TransactionExplorerConditionOperator('Is not empty', TransactionExplorerConditionOperatorType.IsNotEmpty);
|
||||
public static readonly Contains = new TransactionExplorerConditionOperator('Contains', TransactionExplorerConditionOperatorType.Contains);
|
||||
public static readonly NotContains = new TransactionExplorerConditionOperator('Not contains', TransactionExplorerConditionOperatorType.NotContains);
|
||||
public static readonly NotContains = new TransactionExplorerConditionOperator('Does not contain', TransactionExplorerConditionOperatorType.NotContains);
|
||||
public static readonly StartsWith = new TransactionExplorerConditionOperator('Starts with', TransactionExplorerConditionOperatorType.StartsWith);
|
||||
public static readonly NotStartsWith = new TransactionExplorerConditionOperator('Not starts with', TransactionExplorerConditionOperatorType.NotStartsWith);
|
||||
public static readonly NotStartsWith = new TransactionExplorerConditionOperator('Does not start with', TransactionExplorerConditionOperatorType.NotStartsWith);
|
||||
public static readonly EndsWith = new TransactionExplorerConditionOperator('Ends with', TransactionExplorerConditionOperatorType.EndsWith);
|
||||
public static readonly NotEndsWith = new TransactionExplorerConditionOperator('Not ends with', TransactionExplorerConditionOperatorType.NotEndsWith);
|
||||
public static readonly LatitudeBetween = new TransactionExplorerConditionOperator('Latitude between', TransactionExplorerConditionOperatorType.LatitudeBetween);
|
||||
public static readonly LatitudeNotBetween = new TransactionExplorerConditionOperator('Latitude not between', TransactionExplorerConditionOperatorType.LatitudeNotBetween);
|
||||
public static readonly LongitudeBetween = new TransactionExplorerConditionOperator('Longitude between', TransactionExplorerConditionOperatorType.LongitudeBetween);
|
||||
public static readonly LongitudeNotBetween = new TransactionExplorerConditionOperator('Longitude not between', TransactionExplorerConditionOperatorType.LongitudeNotBetween);
|
||||
public static readonly NotEndsWith = new TransactionExplorerConditionOperator('Does not end with', TransactionExplorerConditionOperatorType.NotEndsWith);
|
||||
public static readonly RegexMatch = new TransactionExplorerConditionOperator('Matches regex', TransactionExplorerConditionOperatorType.RegexMatch);
|
||||
public static readonly NotRegexMatch = new TransactionExplorerConditionOperator('Does not match regex', TransactionExplorerConditionOperatorType.NotRegexMatch);
|
||||
public static readonly MinuteOffsetBetween = new TransactionExplorerConditionOperator('Minute offset is between', TransactionExplorerConditionOperatorType.MinuteOffsetBetween);
|
||||
public static readonly MinuteOffsetNotBetween = new TransactionExplorerConditionOperator('Minute offset is not between', TransactionExplorerConditionOperatorType.MinuteOffsetNotBetween);
|
||||
public static readonly LatitudeBetween = new TransactionExplorerConditionOperator('Latitude is between', TransactionExplorerConditionOperatorType.LatitudeBetween);
|
||||
public static readonly LatitudeNotBetween = new TransactionExplorerConditionOperator('Latitude is not between', TransactionExplorerConditionOperatorType.LatitudeNotBetween);
|
||||
public static readonly LongitudeBetween = new TransactionExplorerConditionOperator('Longitude is between', TransactionExplorerConditionOperatorType.LongitudeBetween);
|
||||
public static readonly LongitudeNotBetween = new TransactionExplorerConditionOperator('Longitude is not between', TransactionExplorerConditionOperatorType.LongitudeNotBetween);
|
||||
|
||||
public readonly name: string;
|
||||
public readonly value: TransactionExplorerConditionOperatorType;
|
||||
@@ -146,56 +167,6 @@ export class TransactionExplorerConditionOperator implements NameValue {
|
||||
}
|
||||
}
|
||||
|
||||
export enum TransactionExplorerChartTypeValue {
|
||||
Pie = 'pie',
|
||||
ColumnStacked = 'columnStacked',
|
||||
Column100PercentStacked = 'column100%Stacked',
|
||||
ColumnGrouped = 'columnGrouped',
|
||||
LineGrouped = 'lineGrouped',
|
||||
AreaStacked = 'areaStacked',
|
||||
Area100PercentStacked = 'area100%Stacked',
|
||||
BubbleGrouped = 'bubbleGrouped',
|
||||
Radar = 'radar'
|
||||
}
|
||||
|
||||
export class TransactionExplorerChartType implements NameValue {
|
||||
private static readonly allInstances: TransactionExplorerChartType[] = [];
|
||||
private static readonly allInstancesByValue: Record<string, TransactionExplorerChartType> = {};
|
||||
|
||||
public static readonly Pie = new TransactionExplorerChartType('Pie Chart', TransactionExplorerChartTypeValue.Pie, false);
|
||||
public static readonly Radar = new TransactionExplorerChartType('Radar Chart', TransactionExplorerChartTypeValue.Radar, false);
|
||||
public static readonly ColumnStacked = new TransactionExplorerChartType('Column Chart (Stacked)', TransactionExplorerChartTypeValue.ColumnStacked, true);
|
||||
public static readonly Column100PercentStacked = new TransactionExplorerChartType('Column Chart (100% Stacked)', TransactionExplorerChartTypeValue.Column100PercentStacked, true);
|
||||
public static readonly ColumnGrouped = new TransactionExplorerChartType('Column Chart (Grouped)', TransactionExplorerChartTypeValue.ColumnGrouped, true);
|
||||
public static readonly LineGrouped = new TransactionExplorerChartType('Line Chart (Grouped)', TransactionExplorerChartTypeValue.LineGrouped, true);
|
||||
public static readonly AreaStacked = new TransactionExplorerChartType('Area Chart (Stacked)', TransactionExplorerChartTypeValue.AreaStacked, true);
|
||||
public static readonly Area100PercentStacked = new TransactionExplorerChartType('Area Chart (100% Stacked)', TransactionExplorerChartTypeValue.Area100PercentStacked, true);
|
||||
public static readonly BubbleGrouped = new TransactionExplorerChartType('Bubble Chart (Grouped)', TransactionExplorerChartTypeValue.BubbleGrouped, true);
|
||||
|
||||
public static readonly Default = TransactionExplorerChartType.Pie;
|
||||
|
||||
public readonly name: string;
|
||||
public readonly value: TransactionExplorerChartTypeValue;
|
||||
public readonly seriesDimensionRequired: boolean;
|
||||
|
||||
private constructor(name: string, value: TransactionExplorerChartTypeValue, seriesDimensionRequired: boolean) {
|
||||
this.name = name;
|
||||
this.value = value;
|
||||
this.seriesDimensionRequired = seriesDimensionRequired;
|
||||
|
||||
TransactionExplorerChartType.allInstances.push(this);
|
||||
TransactionExplorerChartType.allInstancesByValue[value] = this;
|
||||
}
|
||||
|
||||
public static values(): TransactionExplorerChartType[] {
|
||||
return TransactionExplorerChartType.allInstances;
|
||||
}
|
||||
|
||||
public static valueOf(value: string): TransactionExplorerChartType | undefined {
|
||||
return TransactionExplorerChartType.allInstancesByValue[value];
|
||||
}
|
||||
}
|
||||
|
||||
export enum TransactionExplorerDataDimensionType {
|
||||
None = 'none',
|
||||
Query = 'query',
|
||||
@@ -218,51 +189,75 @@ export enum TransactionExplorerDataDimensionType {
|
||||
DestinationAccount = 'destinationAccount',
|
||||
DestinationAccountCategory = 'destinationAccountCategory',
|
||||
DestinationAccountCurrency = 'destinationAccountCurrency',
|
||||
PrimaryCategory = 'primaryCategory',
|
||||
SecondaryCategory = 'secondaryCategory',
|
||||
SourceAmount = 'sourceAmount',
|
||||
DestinationAmount = 'destinationAmount',
|
||||
PrimaryCategory = 'primaryCategory',
|
||||
SecondaryCategory = 'secondaryCategory'
|
||||
SourceAmountRangeEqualFrequency = 'sourceAmountRangeEqualFrequency',
|
||||
SourceAmountRangeEqualWidth = 'sourceAmountRangeEqualWidth',
|
||||
SourceAmountRangeLogScale = 'sourceAmountRangeLogScale',
|
||||
SourceAmountRangeStandardDeviation = 'sourceAmountRangeStandardDeviation',
|
||||
SourceAmountRangeNaturalBreaks = 'sourceAmountRangeNaturalBreaks',
|
||||
DestinationAmountRangeEqualFrequency = 'destinationAmountRangeEqualFrequency',
|
||||
DestinationAmountRangeEqualWidth = 'destinationAmountRangeEqualWidth',
|
||||
DestinationAmountRangeLogScale = 'destinationAmountRangeLogScale',
|
||||
DestinationAmountRangeStandardDeviation = 'destinationAmountRangeStandardDeviation',
|
||||
DestinationAmountRangeNaturalBreaks = 'destinationAmountRangeNaturalBreaks'
|
||||
}
|
||||
|
||||
export class TransactionExplorerDataDimension implements NameValue {
|
||||
private static readonly allInstances: TransactionExplorerDataDimension[] = [];
|
||||
private static readonly allInstancesByValue: Record<string, TransactionExplorerDataDimension> = {};
|
||||
|
||||
public static readonly None = new TransactionExplorerDataDimension('None', TransactionExplorerDataDimensionType.None);
|
||||
public static readonly Query = new TransactionExplorerDataDimension('Query', TransactionExplorerDataDimensionType.Query);
|
||||
public static readonly DateTime = new TransactionExplorerDataDimension('Transaction Time', TransactionExplorerDataDimensionType.DateTime);
|
||||
public static readonly DateTimeByYearMonthDay = new TransactionExplorerDataDimension('Transaction Date', TransactionExplorerDataDimensionType.DateTimeByYearMonthDay);
|
||||
public static readonly DateTimeByYearMonth = new TransactionExplorerDataDimension('Transaction Year-Month', TransactionExplorerDataDimensionType.DateTimeByYearMonth);
|
||||
public static readonly DateTimeByYearQuarter = new TransactionExplorerDataDimension('Transaction Year-Quarter', TransactionExplorerDataDimensionType.DateTimeByYearQuarter);
|
||||
public static readonly DateTimeByYear = new TransactionExplorerDataDimension('Transaction Year', TransactionExplorerDataDimensionType.DateTimeByYear);
|
||||
public static readonly DateTimeByFiscalYear = new TransactionExplorerDataDimension('Transaction Fiscal Year', TransactionExplorerDataDimensionType.DateTimeByFiscalYear);
|
||||
public static readonly DateTimeByDayOfWeek = new TransactionExplorerDataDimension('Transaction Day of Week', TransactionExplorerDataDimensionType.DateTimeByDayOfWeek);
|
||||
public static readonly DateTimeByDayOfMonth = new TransactionExplorerDataDimension('Transaction Day of Month', TransactionExplorerDataDimensionType.DateTimeByDayOfMonth);
|
||||
public static readonly DateTimeByMonthOfYear = new TransactionExplorerDataDimension('Transaction Month of Year', TransactionExplorerDataDimensionType.DateTimeByMonthOfYear);
|
||||
public static readonly DateTimeByQuarterOfYear = new TransactionExplorerDataDimension('Transaction Quarter of Year', TransactionExplorerDataDimensionType.DateTimeByQuarterOfYear);
|
||||
public static readonly DateTimeByHourOfDay = new TransactionExplorerDataDimension('Transaction Hour of Day', TransactionExplorerDataDimensionType.DateTimeByHourOfDay);
|
||||
public static readonly TimezoneOffset = new TransactionExplorerDataDimension('Transaction Timezone', TransactionExplorerDataDimensionType.TimezoneOffset);
|
||||
public static readonly TransactionType = new TransactionExplorerDataDimension('Transaction Type', TransactionExplorerDataDimensionType.TransactionType);
|
||||
public static readonly SourceAccount = new TransactionExplorerDataDimension('Source Account', TransactionExplorerDataDimensionType.SourceAccount);
|
||||
public static readonly SourceAccountCategory = new TransactionExplorerDataDimension('Source Account Category', TransactionExplorerDataDimensionType.SourceAccountCategory);
|
||||
public static readonly SourceAccountCurrency = new TransactionExplorerDataDimension('Source Account Currency', TransactionExplorerDataDimensionType.SourceAccountCurrency);
|
||||
public static readonly DestinationAccount = new TransactionExplorerDataDimension('Destination Account', TransactionExplorerDataDimensionType.DestinationAccount);
|
||||
public static readonly DestinationAccountCategory = new TransactionExplorerDataDimension('Destination Account Category', TransactionExplorerDataDimensionType.DestinationAccountCategory);
|
||||
public static readonly DestinationAccountCurrency = new TransactionExplorerDataDimension('Destination Account Currency', TransactionExplorerDataDimensionType.DestinationAccountCurrency);
|
||||
public static readonly PrimaryCategory = new TransactionExplorerDataDimension('Primary Category', TransactionExplorerDataDimensionType.PrimaryCategory);
|
||||
public static readonly SecondaryCategory = new TransactionExplorerDataDimension('Secondary Category', TransactionExplorerDataDimensionType.SecondaryCategory);
|
||||
public static readonly SourceAmount = new TransactionExplorerDataDimension('Amount', TransactionExplorerDataDimensionType.SourceAmount);
|
||||
public static readonly DestinationAmount = new TransactionExplorerDataDimension('Transfer In Amount', TransactionExplorerDataDimensionType.DestinationAmount);
|
||||
public static readonly None = new TransactionExplorerDataDimension('None', TransactionExplorerDataDimensionType.None, false, false);
|
||||
public static readonly Query = new TransactionExplorerDataDimension('Query', TransactionExplorerDataDimensionType.Query, false, false);
|
||||
public static readonly DateTime = new TransactionExplorerDataDimension('Transaction Time', TransactionExplorerDataDimensionType.DateTime, false, false);
|
||||
public static readonly DateTimeByYearMonthDay = new TransactionExplorerDataDimension('Transaction Date', TransactionExplorerDataDimensionType.DateTimeByYearMonthDay, false, false);
|
||||
public static readonly DateTimeByYearMonth = new TransactionExplorerDataDimension('Transaction Year-Month', TransactionExplorerDataDimensionType.DateTimeByYearMonth, false, false);
|
||||
public static readonly DateTimeByYearQuarter = new TransactionExplorerDataDimension('Transaction Year-Quarter', TransactionExplorerDataDimensionType.DateTimeByYearQuarter, false, false);
|
||||
public static readonly DateTimeByYear = new TransactionExplorerDataDimension('Transaction Year', TransactionExplorerDataDimensionType.DateTimeByYear, false, false);
|
||||
public static readonly DateTimeByFiscalYear = new TransactionExplorerDataDimension('Transaction Fiscal Year', TransactionExplorerDataDimensionType.DateTimeByFiscalYear, false, false);
|
||||
public static readonly DateTimeByDayOfWeek = new TransactionExplorerDataDimension('Transaction Day of Week', TransactionExplorerDataDimensionType.DateTimeByDayOfWeek, false, false);
|
||||
public static readonly DateTimeByDayOfMonth = new TransactionExplorerDataDimension('Transaction Day of Month', TransactionExplorerDataDimensionType.DateTimeByDayOfMonth, false, false);
|
||||
public static readonly DateTimeByMonthOfYear = new TransactionExplorerDataDimension('Transaction Month of Year', TransactionExplorerDataDimensionType.DateTimeByMonthOfYear, false, false);
|
||||
public static readonly DateTimeByQuarterOfYear = new TransactionExplorerDataDimension('Transaction Quarter of Year', TransactionExplorerDataDimensionType.DateTimeByQuarterOfYear, false, false);
|
||||
public static readonly DateTimeByHourOfDay = new TransactionExplorerDataDimension('Transaction Hour of Day', TransactionExplorerDataDimensionType.DateTimeByHourOfDay, false, false);
|
||||
public static readonly TimezoneOffset = new TransactionExplorerDataDimension('Transaction Timezone', TransactionExplorerDataDimensionType.TimezoneOffset, false, false);
|
||||
public static readonly TransactionType = new TransactionExplorerDataDimension('Transaction Type', TransactionExplorerDataDimensionType.TransactionType, false, false);
|
||||
public static readonly SourceAccount = new TransactionExplorerDataDimension('Source Account', TransactionExplorerDataDimensionType.SourceAccount, false, false);
|
||||
public static readonly SourceAccountCategory = new TransactionExplorerDataDimension('Source Account Category', TransactionExplorerDataDimensionType.SourceAccountCategory, false, false);
|
||||
public static readonly SourceAccountCurrency = new TransactionExplorerDataDimension('Source Account Currency', TransactionExplorerDataDimensionType.SourceAccountCurrency, false, false);
|
||||
public static readonly DestinationAccount = new TransactionExplorerDataDimension('Destination Account', TransactionExplorerDataDimensionType.DestinationAccount, false, false);
|
||||
public static readonly DestinationAccountCategory = new TransactionExplorerDataDimension('Destination Account Category', TransactionExplorerDataDimensionType.DestinationAccountCategory, false, false);
|
||||
public static readonly DestinationAccountCurrency = new TransactionExplorerDataDimension('Destination Account Currency', TransactionExplorerDataDimensionType.DestinationAccountCurrency, false, false);
|
||||
public static readonly PrimaryCategory = new TransactionExplorerDataDimension('Primary Category', TransactionExplorerDataDimensionType.PrimaryCategory, false, false);
|
||||
public static readonly SecondaryCategory = new TransactionExplorerDataDimension('Secondary Category', TransactionExplorerDataDimensionType.SecondaryCategory, false, false);
|
||||
public static readonly SourceAmount = new TransactionExplorerDataDimension('Amount', TransactionExplorerDataDimensionType.SourceAmount, false, false);
|
||||
public static readonly DestinationAmount = new TransactionExplorerDataDimension('Transfer In Amount', TransactionExplorerDataDimensionType.DestinationAmount, false, false);
|
||||
public static readonly SourceAmountRangeEqualFrequency = new TransactionExplorerDataDimension('Amount Range (Equal Frequency)', TransactionExplorerDataDimensionType.SourceAmountRangeEqualFrequency, true, false);
|
||||
public static readonly SourceAmountRangeEqualWidth = new TransactionExplorerDataDimension('Amount Range (Equal Width)', TransactionExplorerDataDimensionType.SourceAmountRangeEqualWidth, true, false);
|
||||
public static readonly SourceAmountRangeLogScale = new TransactionExplorerDataDimension('Amount Range (Log Scale)', TransactionExplorerDataDimensionType.SourceAmountRangeLogScale, true, false);
|
||||
public static readonly SourceAmountRangeStandardDeviation = new TransactionExplorerDataDimension('Amount Range (Standard Deviation)', TransactionExplorerDataDimensionType.SourceAmountRangeStandardDeviation, true, false);
|
||||
public static readonly SourceAmountRangeNaturalBreaks = new TransactionExplorerDataDimension('Amount Range (Natural Breaks)', TransactionExplorerDataDimensionType.SourceAmountRangeNaturalBreaks, true, false);
|
||||
public static readonly DestinationAmountRangeEqualFrequency = new TransactionExplorerDataDimension('Transfer In Amount Range (Equal Frequency)', TransactionExplorerDataDimensionType.DestinationAmountRangeEqualFrequency, false, true);
|
||||
public static readonly DestinationAmountRangeEqualWidth = new TransactionExplorerDataDimension('Transfer In Amount Range (Equal Width)', TransactionExplorerDataDimensionType.DestinationAmountRangeEqualWidth, false, true);
|
||||
public static readonly DestinationAmountRangeLogScale = new TransactionExplorerDataDimension('Transfer In Amount Range (Log Scale)', TransactionExplorerDataDimensionType.DestinationAmountRangeLogScale, false, true);
|
||||
public static readonly DestinationAmountRangeStandardDeviation = new TransactionExplorerDataDimension('Transfer In Amount Range (Standard Deviation)', TransactionExplorerDataDimensionType.DestinationAmountRangeStandardDeviation, false, true);
|
||||
public static readonly DestinationAmountRangeNaturalBreaks = new TransactionExplorerDataDimension('Transfer In Amount Range (Natural Breaks)', TransactionExplorerDataDimensionType.DestinationAmountRangeNaturalBreaks, false, true);
|
||||
|
||||
public static readonly CategoryDimensionDefault = TransactionExplorerDataDimension.Query;
|
||||
public static readonly SeriesDimensionDefault = TransactionExplorerDataDimension.None;
|
||||
|
||||
public readonly name: string;
|
||||
public readonly value: TransactionExplorerDataDimensionType;
|
||||
public readonly isSourceAmountRange: boolean;
|
||||
public readonly isDestinationAmountRange: boolean;
|
||||
|
||||
private constructor(name: string, value: TransactionExplorerDataDimensionType) {
|
||||
private constructor(name: string, value: TransactionExplorerDataDimensionType, isSourceAmountRange: boolean, isDestinationAmountRange: boolean) {
|
||||
this.name = name;
|
||||
this.value = value;
|
||||
this.isSourceAmountRange = isSourceAmountRange;
|
||||
this.isDestinationAmountRange = isDestinationAmountRange;
|
||||
|
||||
TransactionExplorerDataDimension.allInstances.push(this);
|
||||
TransactionExplorerDataDimension.allInstancesByValue[value] = this;
|
||||
@@ -279,47 +274,89 @@ export class TransactionExplorerDataDimension implements NameValue {
|
||||
|
||||
export enum TransactionExplorerValueMetricType {
|
||||
TransactionCount = 'transactionCount',
|
||||
ActiveTransactionDays = 'activeTransactionDays',
|
||||
TransactionsPerActiveDay = 'transactionsPerActiveDay',
|
||||
SourceAmountSum = 'sourceAmountSum',
|
||||
SourceIncomeAmountSum = 'sourceIncomeAmountSum',
|
||||
SourceExpenseAmountSum = 'sourceExpenseAmountSum',
|
||||
SourceNetIncomeAmountSum = 'sourceNetIncomeAmountSum',
|
||||
SrouceAmountExpenseIncomeRatio = 'sourceExpenseIncomeRatio',
|
||||
SourceAmountSavingsRate = 'sourceAmountSavingsRate',
|
||||
SourceAmountAverage = 'sourceAmountAverage',
|
||||
SourceAmountMedian = 'sourceAmountMedian',
|
||||
SourceAmount90thPercentile = 'source90thPercentileAmount',
|
||||
SourceAmountMinimum = 'sourceAmountMinimum',
|
||||
SourceAmountMaximum = 'sourceAmountMaximum',
|
||||
SourceAmountQ1Amount = 'sourceQ1Amount',
|
||||
SourceAmountQ3Amount = 'sourceQ3Amount',
|
||||
SourceAmount10thPercentile = 'source10thPercentileAmount',
|
||||
SourceAmount90thPercentile = 'source90thPercentileAmount',
|
||||
SourceAmount95thPercentile = 'source95thPercentileAmount',
|
||||
SourceAmount99thPercentile = 'source99thPercentileAmount',
|
||||
SourceAmountRange = 'sourceAmountRange',
|
||||
SourceAmountInterquartileRange = 'sourceAmountInterquartileRange',
|
||||
SourceAmountMeanAbsoluteDeviation = 'sourceAmountMeanAbsoluteDeviation',
|
||||
SourceAmountMedianAbsoluteDeviation = 'sourceAmountMedianAbsoluteDeviation',
|
||||
SourceMaximumAmountShare = 'sourceMaximumAmountShare',
|
||||
SourceTop5AmountSum = 'sourceTop5AmountSum',
|
||||
SourceTop5AmountShare = 'sourceTop5AmountShare',
|
||||
TransactionsForEightyPercentOfSourceAmount = 'transactionsForEightyPercentOfSourceAmount',
|
||||
SourceAmountVariance = 'sourceAmountVariance',
|
||||
SourceAmountStandardDeviation = 'sourceAmountStandardDeviation',
|
||||
SourceAmountCoefficientOfVariation = 'sourceAmountCoefficientOfVariation'
|
||||
SourceAmountCoefficientOfVariation = 'sourceAmountCoefficientOfVariation',
|
||||
SourceAmountSkewness = 'sourceAmountSkewness',
|
||||
SourceAmountKurtosis = 'sourceAmountKurtosis'
|
||||
}
|
||||
|
||||
export class TransactionExplorerValueMetric implements NameValue {
|
||||
private static readonly allInstances: TransactionExplorerValueMetric[] = [];
|
||||
private static readonly allInstancesByValue: Record<string, TransactionExplorerValueMetric> = {};
|
||||
|
||||
public static readonly TransactionCount = new TransactionExplorerValueMetric('Transaction Count', TransactionExplorerValueMetricType.TransactionCount, false, true);
|
||||
public static readonly SourceAmountSum = new TransactionExplorerValueMetric('Total Amount', TransactionExplorerValueMetricType.SourceAmountSum, true, true);
|
||||
public static readonly SourceAmountAverage = new TransactionExplorerValueMetric('Average Amount', TransactionExplorerValueMetricType.SourceAmountAverage, true, true);
|
||||
public static readonly SourceAmountMedian = new TransactionExplorerValueMetric('Median Amount', TransactionExplorerValueMetricType.SourceAmountMedian, true, true);
|
||||
public static readonly SourceAmount90thPercentile = new TransactionExplorerValueMetric('90th Percentile Amount', TransactionExplorerValueMetricType.SourceAmount90thPercentile, true, true);
|
||||
public static readonly SourceAmountMinimum = new TransactionExplorerValueMetric('Minimum Amount', TransactionExplorerValueMetricType.SourceAmountMinimum, true, true);
|
||||
public static readonly SourceAmountMaximum = new TransactionExplorerValueMetric('Maximum Amount', TransactionExplorerValueMetricType.SourceAmountMaximum, true, true);
|
||||
public static readonly SourceAmountRange = new TransactionExplorerValueMetric('Range (Max - Min)', TransactionExplorerValueMetricType.SourceAmountRange, true, true);
|
||||
public static readonly SourceAmountInterquartileRange = new TransactionExplorerValueMetric('Interquartile Range (Q3 - Q1)', TransactionExplorerValueMetricType.SourceAmountInterquartileRange, true, true);
|
||||
public static readonly SourceAmountVariance = new TransactionExplorerValueMetric('Variance', TransactionExplorerValueMetricType.SourceAmountVariance, false, false);
|
||||
public static readonly SourceAmountStandardDeviation = new TransactionExplorerValueMetric('Standard Deviation', TransactionExplorerValueMetricType.SourceAmountStandardDeviation, false, false);
|
||||
public static readonly SourceAmountCoefficientOfVariation = new TransactionExplorerValueMetric('Coefficient of Variation', TransactionExplorerValueMetricType.SourceAmountCoefficientOfVariation, false, false);
|
||||
public static readonly TransactionCount = new TransactionExplorerValueMetric('Transaction Count', TransactionExplorerValueMetricType.TransactionCount, false, false, true);
|
||||
public static readonly ActiveTransactionDays = new TransactionExplorerValueMetric('Active Transaction Days', TransactionExplorerValueMetricType.ActiveTransactionDays, false, false, true);
|
||||
public static readonly TransactionsPerDay = new TransactionExplorerValueMetric('Transactions per Active Day', TransactionExplorerValueMetricType.TransactionsPerActiveDay, false, false, true);
|
||||
public static readonly SourceAmountSum = new TransactionExplorerValueMetric('Total Amount', TransactionExplorerValueMetricType.SourceAmountSum, true, false, true);
|
||||
public static readonly SourceIncomeAmountSum = new TransactionExplorerValueMetric('Total Income', TransactionExplorerValueMetricType.SourceIncomeAmountSum, true, false, true);
|
||||
public static readonly SourceExpenseAmountSum = new TransactionExplorerValueMetric('Total Expense', TransactionExplorerValueMetricType.SourceExpenseAmountSum, true, false, true);
|
||||
public static readonly SourceNetIncomeAmountSum = new TransactionExplorerValueMetric('Net Income', TransactionExplorerValueMetricType.SourceNetIncomeAmountSum, true, false, true);
|
||||
public static readonly SrouceAmountExpenseIncomeRatio = new TransactionExplorerValueMetric('Expense / Income Ratio', TransactionExplorerValueMetricType.SrouceAmountExpenseIncomeRatio, false, true, false);
|
||||
public static readonly SourceAmountSavingsRate = new TransactionExplorerValueMetric('Savings Rate', TransactionExplorerValueMetricType.SourceAmountSavingsRate, false, true, false);
|
||||
public static readonly SourceAmountAverage = new TransactionExplorerValueMetric('Average Amount', TransactionExplorerValueMetricType.SourceAmountAverage, true, false, true);
|
||||
public static readonly SourceAmountMedian = new TransactionExplorerValueMetric('Median Amount', TransactionExplorerValueMetricType.SourceAmountMedian, true, false, true);
|
||||
public static readonly SourceAmountMinimum = new TransactionExplorerValueMetric('Minimum Amount', TransactionExplorerValueMetricType.SourceAmountMinimum, true, false, true);
|
||||
public static readonly SourceAmountMaximum = new TransactionExplorerValueMetric('Maximum Amount', TransactionExplorerValueMetricType.SourceAmountMaximum, true, false, true);
|
||||
public static readonly SourceAmountQ1Amount = new TransactionExplorerValueMetric('Q1 Amount (First Quartile)', TransactionExplorerValueMetricType.SourceAmountQ1Amount, true, false, true);
|
||||
public static readonly SourceAmountQ3Amount = new TransactionExplorerValueMetric('Q3 Amount (Third Quartile)', TransactionExplorerValueMetricType.SourceAmountQ3Amount, true, false, true);
|
||||
public static readonly SourceAmount10thPercentile = new TransactionExplorerValueMetric('10th Percentile Amount', TransactionExplorerValueMetricType.SourceAmount10thPercentile, true, false, true);
|
||||
public static readonly SourceAmount90thPercentile = new TransactionExplorerValueMetric('90th Percentile Amount', TransactionExplorerValueMetricType.SourceAmount90thPercentile, true, false, true);
|
||||
public static readonly SourceAmount95thPercentile = new TransactionExplorerValueMetric('95th Percentile Amount', TransactionExplorerValueMetricType.SourceAmount95thPercentile, true, false, true);
|
||||
public static readonly SourceAmount99thPercentile = new TransactionExplorerValueMetric('99th Percentile Amount', TransactionExplorerValueMetricType.SourceAmount99thPercentile, true, false, true);
|
||||
public static readonly SourceAmountRange = new TransactionExplorerValueMetric('Range (Max - Min)', TransactionExplorerValueMetricType.SourceAmountRange, true, false, true);
|
||||
public static readonly SourceAmountInterquartileRange = new TransactionExplorerValueMetric('Interquartile Range (Q3 - Q1)', TransactionExplorerValueMetricType.SourceAmountInterquartileRange, true, false, true);
|
||||
public static readonly SourceAmountMeanAbsoluteDeviation = new TransactionExplorerValueMetric('Mean Absolute Deviation', TransactionExplorerValueMetricType.SourceAmountMeanAbsoluteDeviation, true, false, false);
|
||||
public static readonly SourceAmountMedianAbsoluteDeviation = new TransactionExplorerValueMetric('Median Absolute Deviation', TransactionExplorerValueMetricType.SourceAmountMedianAbsoluteDeviation, true, false, false);
|
||||
public static readonly SourceMaximumAmountShare = new TransactionExplorerValueMetric('Maximum Amount Share', TransactionExplorerValueMetricType.SourceMaximumAmountShare, false, true, false);
|
||||
public static readonly SourceTop5AmountSum = new TransactionExplorerValueMetric('Top 5 Amount Sum', TransactionExplorerValueMetricType.SourceTop5AmountSum, true, false, true);
|
||||
public static readonly SourceTop5AmountShare = new TransactionExplorerValueMetric('Top 5 Amount Share', TransactionExplorerValueMetricType.SourceTop5AmountShare, false, true, false);
|
||||
public static readonly TransactionsForEightyPercentOfSourceAmount = new TransactionExplorerValueMetric('Transactions for 80% of Amount', TransactionExplorerValueMetricType.TransactionsForEightyPercentOfSourceAmount, false, true, false);
|
||||
public static readonly SourceAmountVariance = new TransactionExplorerValueMetric('Variance', TransactionExplorerValueMetricType.SourceAmountVariance, false, false, false);
|
||||
public static readonly SourceAmountStandardDeviation = new TransactionExplorerValueMetric('Standard Deviation', TransactionExplorerValueMetricType.SourceAmountStandardDeviation, false, false, false);
|
||||
public static readonly SourceAmountCoefficientOfVariation = new TransactionExplorerValueMetric('Coefficient of Variation', TransactionExplorerValueMetricType.SourceAmountCoefficientOfVariation, false, false, false);
|
||||
public static readonly SourceAmountSkewness = new TransactionExplorerValueMetric('Skewness', TransactionExplorerValueMetricType.SourceAmountSkewness, false, false, false);
|
||||
public static readonly SourceAmountKurtosis = new TransactionExplorerValueMetric('Kurtosis', TransactionExplorerValueMetricType.SourceAmountKurtosis, false, false, false);
|
||||
|
||||
public static readonly Default = TransactionExplorerValueMetric.SourceAmountSum;
|
||||
|
||||
public readonly name: string;
|
||||
public readonly value: TransactionExplorerValueMetricType;
|
||||
public readonly isAmount: boolean;
|
||||
public readonly isPercent: boolean;
|
||||
public readonly supportSum: boolean;
|
||||
|
||||
private constructor(name: string, value: TransactionExplorerValueMetricType, isAmount: boolean, supportSum: boolean) {
|
||||
private constructor(name: string, value: TransactionExplorerValueMetricType, isAmount: boolean, isPercent: boolean, supportSum: boolean) {
|
||||
this.name = name;
|
||||
this.value = value;
|
||||
this.isAmount = isAmount;
|
||||
this.isPercent = isPercent;
|
||||
this.supportSum = supportSum;
|
||||
|
||||
TransactionExplorerValueMetric.allInstances.push(this);
|
||||
@@ -335,4 +372,66 @@ export class TransactionExplorerValueMetric implements NameValue {
|
||||
}
|
||||
}
|
||||
|
||||
export enum TransactionExplorerChartTypeValue {
|
||||
Pie = 'pie',
|
||||
ColumnStacked = 'columnStacked',
|
||||
Column100PercentStacked = 'column100%Stacked',
|
||||
ColumnGrouped = 'columnGrouped',
|
||||
LineGrouped = 'lineGrouped',
|
||||
AreaStacked = 'areaStacked',
|
||||
Area100PercentStacked = 'area100%Stacked',
|
||||
BubbleGrouped = 'bubbleGrouped',
|
||||
Radar = 'radar',
|
||||
Treemap = 'treemap',
|
||||
Sunburst = 'sunburst',
|
||||
Heatmap = 'heatmap',
|
||||
CalendarHeatmap = 'calendarHeatmap'
|
||||
}
|
||||
|
||||
export class TransactionExplorerChartType implements NameValue {
|
||||
private static readonly allInstances: TransactionExplorerChartType[] = [];
|
||||
private static readonly allInstancesByValue: Record<string, TransactionExplorerChartType> = {};
|
||||
|
||||
public static readonly Pie = new TransactionExplorerChartType('Pie Chart', TransactionExplorerChartTypeValue.Pie, undefined, false, undefined);
|
||||
public static readonly Radar = new TransactionExplorerChartType('Radar Chart', TransactionExplorerChartTypeValue.Radar, undefined, false, undefined);
|
||||
public static readonly ColumnStacked = new TransactionExplorerChartType('Column Chart (Stacked)', TransactionExplorerChartTypeValue.ColumnStacked, undefined, true, undefined);
|
||||
public static readonly Column100PercentStacked = new TransactionExplorerChartType('Column Chart (100% Stacked)', TransactionExplorerChartTypeValue.Column100PercentStacked, undefined, true, undefined);
|
||||
public static readonly ColumnGrouped = new TransactionExplorerChartType('Column Chart (Grouped)', TransactionExplorerChartTypeValue.ColumnGrouped, undefined, true, undefined);
|
||||
public static readonly LineGrouped = new TransactionExplorerChartType('Line Chart (Grouped)', TransactionExplorerChartTypeValue.LineGrouped, undefined, true, undefined);
|
||||
public static readonly AreaStacked = new TransactionExplorerChartType('Area Chart (Stacked)', TransactionExplorerChartTypeValue.AreaStacked, undefined, true, undefined);
|
||||
public static readonly Area100PercentStacked = new TransactionExplorerChartType('Area Chart (100% Stacked)', TransactionExplorerChartTypeValue.Area100PercentStacked, undefined, true, undefined);
|
||||
public static readonly BubbleGrouped = new TransactionExplorerChartType('Bubble Chart (Grouped)', TransactionExplorerChartTypeValue.BubbleGrouped, undefined, true, undefined);
|
||||
public static readonly Treemap = new TransactionExplorerChartType('Treemap Chart', TransactionExplorerChartTypeValue.Treemap, undefined, true, undefined);
|
||||
public static readonly Sunburst = new TransactionExplorerChartType('Sunburst Chart', TransactionExplorerChartTypeValue.Sunburst, undefined, true, undefined);
|
||||
public static readonly Heatmap = new TransactionExplorerChartType('Heatmap Chart', TransactionExplorerChartTypeValue.Heatmap, undefined, true, undefined);
|
||||
public static readonly CalendarHeatmap = new TransactionExplorerChartType('Calendar Heatmap Chart', TransactionExplorerChartTypeValue.CalendarHeatmap, TransactionExplorerDataDimensionType.DateTimeByYearMonthDay, false, ChartSortingType.DisplayOrder.type);
|
||||
|
||||
public static readonly Default = TransactionExplorerChartType.Pie;
|
||||
|
||||
public readonly name: string;
|
||||
public readonly value: TransactionExplorerChartTypeValue;
|
||||
public readonly fixedCategoryDimension: TransactionExplorerDataDimensionType | undefined;
|
||||
public readonly seriesDimensionRequired: boolean;
|
||||
public readonly fixedSortingType: number | undefined;
|
||||
|
||||
private constructor(name: string, value: TransactionExplorerChartTypeValue, fixedCategoryDimension: TransactionExplorerDataDimensionType | undefined, seriesDimensionRequired: boolean, fixedSortingType: number | undefined) {
|
||||
this.name = name;
|
||||
this.value = value;
|
||||
this.fixedCategoryDimension = fixedCategoryDimension;
|
||||
this.seriesDimensionRequired = seriesDimensionRequired;
|
||||
this.fixedSortingType = fixedSortingType;
|
||||
|
||||
TransactionExplorerChartType.allInstances.push(this);
|
||||
TransactionExplorerChartType.allInstancesByValue[value] = this;
|
||||
}
|
||||
|
||||
public static values(): TransactionExplorerChartType[] {
|
||||
return TransactionExplorerChartType.allInstances;
|
||||
}
|
||||
|
||||
public static valueOf(value: string): TransactionExplorerChartType | undefined {
|
||||
return TransactionExplorerChartType.allInstancesByValue[value];
|
||||
}
|
||||
}
|
||||
|
||||
export const DEFAULT_TRANSACTION_EXPLORER_DATE_RANGE: DateRange = DateRange.ThisMonth;
|
||||
|
||||
+9
-1
@@ -12,7 +12,9 @@ import {
|
||||
ChartSortingType,
|
||||
DEFAULT_CATEGORICAL_CHART_DATA_RANGE,
|
||||
DEFAULT_TREND_CHART_DATA_RANGE,
|
||||
DEFAULT_ASSET_TRENDS_CHART_DATA_RANGE
|
||||
DEFAULT_ASSET_TRENDS_CHART_DATA_RANGE,
|
||||
DEFAULT_RECONCILIATION_STATEMENT_DATE_RANGE_IN_DESKTOP,
|
||||
DEFAULT_RECONCILIATION_STATEMENT_DATE_RANGE_IN_MOBILE,
|
||||
} from './statistics.ts';
|
||||
import { DEFAULT_TRANSACTION_EXPLORER_DATE_RANGE } from './explorer.ts';
|
||||
import { DEFAULT_CURRENCY_CODE } from '@/consts/currency.ts';
|
||||
@@ -66,6 +68,8 @@ export interface ApplicationSettings extends BaseApplicationSetting {
|
||||
totalAmountExcludeAccountIds: Record<string, boolean>;
|
||||
accountCategoryOrders: string;
|
||||
hideCategoriesWithoutAccounts: boolean;
|
||||
reconciliationStatementButtonDefaultDateRangeTypeInDesktop: number;
|
||||
reconciliationStatementPageDefaultDateRangeTypeInMobile: number;
|
||||
// Exchange Rates Data Page
|
||||
currencySortByInExchangeRatesPage: number;
|
||||
// Browser Cache Management
|
||||
@@ -144,6 +148,8 @@ export const ALL_ALLOWED_CLOUD_SYNC_APP_SETTING_KEY_TYPES: Record<string, UserAp
|
||||
'totalAmountExcludeAccountIds': UserApplicationCloudSettingType.StringBooleanMap,
|
||||
'accountCategoryOrders': UserApplicationCloudSettingType.String,
|
||||
'hideCategoriesWithoutAccounts': UserApplicationCloudSettingType.Boolean,
|
||||
'reconciliationStatementButtonDefaultDateRangeTypeInDesktop': UserApplicationCloudSettingType.Number,
|
||||
'reconciliationStatementPageDefaultDateRangeTypeInMobile': UserApplicationCloudSettingType.Number,
|
||||
// Exchange Rates Data Page
|
||||
'currencySortByInExchangeRatesPage': UserApplicationCloudSettingType.Number,
|
||||
// Browser Cache Management
|
||||
@@ -204,6 +210,8 @@ export const DEFAULT_APPLICATION_SETTINGS: ApplicationSettings = {
|
||||
totalAmountExcludeAccountIds: {},
|
||||
accountCategoryOrders: '',
|
||||
hideCategoriesWithoutAccounts: false,
|
||||
reconciliationStatementButtonDefaultDateRangeTypeInDesktop: DEFAULT_RECONCILIATION_STATEMENT_DATE_RANGE_IN_DESKTOP.type,
|
||||
reconciliationStatementPageDefaultDateRangeTypeInMobile: DEFAULT_RECONCILIATION_STATEMENT_DATE_RANGE_IN_MOBILE.type,
|
||||
// Exchange Rates Data Page
|
||||
currencySortByInExchangeRatesPage: CurrencySortingType.Default.type,
|
||||
// Browser Cache Management
|
||||
|
||||
@@ -236,6 +236,8 @@ export class ChartDateAggregationType {
|
||||
public static readonly Year = new ChartDateAggregationType(2, 'Yearly', 'Aggregate by Year', StatisticsAnalysisType.TrendAnalysis, StatisticsAnalysisType.AssetTrends);
|
||||
public static readonly FiscalYear = new ChartDateAggregationType(3, 'FiscalYearly', 'Aggregate by Fiscal Year', StatisticsAnalysisType.TrendAnalysis, StatisticsAnalysisType.AssetTrends);
|
||||
|
||||
public static readonly BillingCycle = new ChartDateAggregationType(11, 'BillingCycle', 'Aggregate by Billing Cycle');
|
||||
|
||||
public static readonly Default = ChartDateAggregationType.Month;
|
||||
|
||||
public readonly type: number;
|
||||
@@ -295,3 +297,6 @@ export enum ExportMermaidChartType {
|
||||
export const DEFAULT_CATEGORICAL_CHART_DATA_RANGE: DateRange = DateRange.ThisMonth;
|
||||
export const DEFAULT_TREND_CHART_DATA_RANGE: DateRange = DateRange.ThisYear;
|
||||
export const DEFAULT_ASSET_TRENDS_CHART_DATA_RANGE: DateRange = DateRange.ThisYear;
|
||||
|
||||
export const DEFAULT_RECONCILIATION_STATEMENT_DATE_RANGE_IN_DESKTOP: DateRange = DateRange.Custom;
|
||||
export const DEFAULT_RECONCILIATION_STATEMENT_DATE_RANGE_IN_MOBILE: DateRange = DateRange.ThisMonth;
|
||||
|
||||
@@ -32,8 +32,10 @@ export class ScheduledTemplateFrequencyType implements TypeAndName {
|
||||
private static readonly allInstancesByType: Record<number, ScheduledTemplateFrequencyType> = {};
|
||||
|
||||
public static readonly Disabled = new ScheduledTemplateFrequencyType(0, 'Disabled');
|
||||
public static readonly Daily = new ScheduledTemplateFrequencyType(3, 'Daily');
|
||||
public static readonly Weekly = new ScheduledTemplateFrequencyType(1, 'Weekly');
|
||||
public static readonly Monthly = new ScheduledTemplateFrequencyType(2, 'Monthly');
|
||||
public static readonly Yearly = new ScheduledTemplateFrequencyType(4, 'Yearly');
|
||||
|
||||
public readonly type: number;
|
||||
public readonly name: string;
|
||||
|
||||
+29
-10
@@ -14,27 +14,46 @@ export enum TransactionRelatedAccountType {
|
||||
|
||||
export class TransactionEditScopeType implements TypeAndName {
|
||||
private static readonly allInstances: TransactionEditScopeType[] = [];
|
||||
private static readonly allInstancesWithoutReconciledTime: TransactionEditScopeType[] = [];
|
||||
private static readonly allInstancesByType: Record<number, TransactionEditScopeType> = {};
|
||||
|
||||
public static readonly None = new TransactionEditScopeType(0, 'None');
|
||||
public static readonly All = new TransactionEditScopeType(1, 'All');
|
||||
public static readonly TodayOrLater = new TransactionEditScopeType(2, 'Today or later');
|
||||
public static readonly Recent24HoursOrLater = new TransactionEditScopeType(3, 'Recent 24 hours or later');
|
||||
public static readonly ThisWeekOrLater = new TransactionEditScopeType(4, 'This week or later');
|
||||
public static readonly ThisMonthOrLater = new TransactionEditScopeType(5, 'This month or later');
|
||||
public static readonly ThisYearOrLater = new TransactionEditScopeType(6, 'This year or later');
|
||||
public static readonly None = new TransactionEditScopeType(0, 'None', false);
|
||||
public static readonly All = new TransactionEditScopeType(1, 'All', false);
|
||||
public static readonly TodayOrLater = new TransactionEditScopeType(2, 'Today or later', false);
|
||||
public static readonly Recent24HoursOrLater = new TransactionEditScopeType(3, 'Recent 24 hours or later', false);
|
||||
public static readonly ThisWeekOrLater = new TransactionEditScopeType(4, 'This week or later', false);
|
||||
public static readonly ThisMonthOrLater = new TransactionEditScopeType(5, 'This month or later', false);
|
||||
public static readonly ThisYearOrLater = new TransactionEditScopeType(6, 'This year or later', false);
|
||||
public static readonly LastReconciledTimeOrlater = new TransactionEditScopeType(7, 'Last reconciled time or later', true);
|
||||
|
||||
public readonly type: number;
|
||||
public readonly name: string;
|
||||
public readonly needLastReconciledTime: boolean;
|
||||
|
||||
private constructor(type: number, name: string) {
|
||||
private constructor(type: number, name: string, needLastReconciledTime: boolean) {
|
||||
this.type = type;
|
||||
this.name = name;
|
||||
this.needLastReconciledTime = needLastReconciledTime;
|
||||
|
||||
TransactionEditScopeType.allInstances.push(this);
|
||||
|
||||
if (!needLastReconciledTime) {
|
||||
TransactionEditScopeType.allInstancesWithoutReconciledTime.push(this);
|
||||
}
|
||||
|
||||
TransactionEditScopeType.allInstancesByType[type] = this;
|
||||
}
|
||||
|
||||
public static values(): TransactionEditScopeType[] {
|
||||
return TransactionEditScopeType.allInstances;
|
||||
public static values(useLastReconciledTime: boolean): TransactionEditScopeType[] {
|
||||
if (useLastReconciledTime) {
|
||||
return TransactionEditScopeType.allInstances;
|
||||
} else {
|
||||
return TransactionEditScopeType.allInstancesWithoutReconciledTime;
|
||||
}
|
||||
}
|
||||
|
||||
public static valueOf(type: number): TransactionEditScopeType | undefined {
|
||||
return TransactionEditScopeType.allInstancesByType[type];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+27
-2
@@ -52,11 +52,25 @@ import 'vuetify/styles';
|
||||
|
||||
import * as echarts from 'echarts/core';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
import { LineChart, BarChart, PieChart, ScatterChart, BoxplotChart, CandlestickChart, RadarChart, SankeyChart } from 'echarts/charts';
|
||||
import {
|
||||
LineChart,
|
||||
BarChart,
|
||||
PieChart,
|
||||
ScatterChart,
|
||||
BoxplotChart,
|
||||
CandlestickChart,
|
||||
RadarChart,
|
||||
TreemapChart,
|
||||
SunburstChart,
|
||||
HeatmapChart,
|
||||
SankeyChart
|
||||
} from 'echarts/charts';
|
||||
import {
|
||||
GridComponent,
|
||||
CalendarComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
VisualMapComponent
|
||||
} from 'echarts/components';
|
||||
import VChart from 'vue-echarts';
|
||||
|
||||
@@ -103,6 +117,9 @@ import PieChartComponent from '@/components/desktop/PieChart.vue';
|
||||
import RadarChartComponent from '@/components/desktop/RadarChart.vue';
|
||||
import AxisChart from '@/components/desktop/AxisChart.vue';
|
||||
import TrendsChart from '@/components/desktop/TrendsChart.vue';
|
||||
import HierarchyChart from '@/components/desktop/HierarchyChart.vue';
|
||||
import HeatMapChart from '@/components/desktop/HeatMapChart.vue';
|
||||
import CalendarHeatMapChart from '@/components/desktop/CalendarHeatMapChart.vue';
|
||||
import RenameDialog from '@/components/desktop/RenameDialog.vue';
|
||||
import DateRangeSelectionDialog from '@/components/desktop/DateRangeSelectionDialog.vue';
|
||||
import MonthSelectionDialog from '@/components/desktop/MonthSelectionDialog.vue';
|
||||
@@ -506,10 +523,15 @@ echarts.use([
|
||||
BoxplotChart,
|
||||
CandlestickChart,
|
||||
RadarChart,
|
||||
TreemapChart,
|
||||
SunburstChart,
|
||||
HeatmapChart,
|
||||
SankeyChart,
|
||||
GridComponent,
|
||||
CalendarComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent
|
||||
LegendComponent,
|
||||
VisualMapComponent
|
||||
]);
|
||||
|
||||
app.use(pinia);
|
||||
@@ -551,6 +573,9 @@ app.component('PieChart', PieChartComponent);
|
||||
app.component('RadarChart', RadarChartComponent);
|
||||
app.component('AxisChart', AxisChart);
|
||||
app.component('TrendsChart', TrendsChart);
|
||||
app.component('HierarchyChart', HierarchyChart);
|
||||
app.component('HeatMapChart', HeatMapChart);
|
||||
app.component('CalendarHeatMapChart', CalendarHeatMapChart);
|
||||
app.component('RenameDialog', RenameDialog);
|
||||
app.component('DateRangeSelectionDialog', DateRangeSelectionDialog);
|
||||
app.component('MonthSelectionDialog', MonthSelectionDialog);
|
||||
|
||||
Vendored
+3
@@ -1,3 +1,6 @@
|
||||
declare module 'vuetify/styles';
|
||||
declare module 'framework7-icons';
|
||||
|
||||
declare const __EZBOOKKEEPING_IS_PRODUCTION__: boolean;
|
||||
declare const __EZBOOKKEEPING_VERSION__: string;
|
||||
declare const __EZBOOKKEEPING_BUILD_UNIX_TIME__: string;
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { describe, expect, it, beforeAll } from 'vitest';
|
||||
import moment from 'moment-timezone';
|
||||
|
||||
import type { TextualYearMonth } from '@/core/datetime.ts';
|
||||
import { FiscalYearStart, FiscalYearUnixTime } from '@/core/fiscalyear.ts';
|
||||
|
||||
import {
|
||||
getFiscalYearFromUnixTime,
|
||||
getFiscalYearStartUnixTime,
|
||||
getFiscalYearEndUnixTime,
|
||||
getFiscalYearTimeRangeFromUnixTime,
|
||||
getAllFiscalYearsStartAndEndUnixTimes,
|
||||
getFiscalYearTimeRangeFromYear
|
||||
} from '@/lib/datetime.ts';
|
||||
|
||||
// Set test environment timezone to UTC, since the test data constants are in UTC
|
||||
beforeAll(() => {
|
||||
moment.tz.setDefault('UTC');
|
||||
});
|
||||
|
||||
function importTestData(datasetName: string): unknown[] {
|
||||
const data = JSON.parse(
|
||||
fs.readFileSync(path.join(__dirname, 'fiscal_year.data.json'), 'utf8')
|
||||
);
|
||||
if (!data || typeof data[datasetName] === 'undefined') {
|
||||
throw new Error(`${datasetName} is undefined or missing in the data object.`);
|
||||
}
|
||||
return data[datasetName];
|
||||
}
|
||||
|
||||
function formatUnixTimeISO(unixTime: number): string {
|
||||
return moment.unix(unixTime).format('YYYY-MM-DDTHH:mm:ssZ');
|
||||
}
|
||||
|
||||
function withISO(data: FiscalYearUnixTime) {
|
||||
return {
|
||||
...data,
|
||||
minUnixTimeISO: formatUnixTimeISO(data.minUnixTime),
|
||||
maxUnixTimeISO: formatUnixTimeISO(data.maxUnixTime),
|
||||
};
|
||||
}
|
||||
|
||||
type FiscalYearStartConfig = {
|
||||
id: string;
|
||||
monthDateString: string;
|
||||
value: number;
|
||||
};
|
||||
|
||||
const FISCAL_YEAR_START_PRESETS: Record<string, FiscalYearStartConfig> = {
|
||||
'January 1': { id: 'January 1', monthDateString: '01-01', value: 0x0101 },
|
||||
'April 1': { id: 'April 1', monthDateString: '04-01', value: 0x0401 },
|
||||
'October 1': { id: 'October 1', monthDateString: '10-01', value: 0x0A01 },
|
||||
};
|
||||
|
||||
describe('validateFiscalYearStart', () => {
|
||||
Object.values(FISCAL_YEAR_START_PRESETS).forEach(({ id, value, monthDateString }) => {
|
||||
it(`should return a fiscal year start object for valid value 0x${value.toString(16)} (${id})`, () => {
|
||||
expect(FiscalYearStart.valueOf(value)).toBeDefined();
|
||||
});
|
||||
|
||||
it(`should return the correct month-date string for valid value 0x${value.toString(16)} (${id})`, () => {
|
||||
expect(FiscalYearStart.valueOf(value)?.toMonthDashDayString()).toStrictEqual(monthDateString);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const INVALID_FISCAL_YEAR_VALUES = [
|
||||
0x0000, // Invalid: L0/0
|
||||
0x0D01, // Invalid: Month 13
|
||||
0x0100, // Invalid: Day 0
|
||||
0x0120, // Invalid: January 32
|
||||
0x021D, // Invalid: February 29 (not permitted)
|
||||
0x021E, // Invalid: February 30
|
||||
0x041F, // Invalid: April 31
|
||||
0x061F, // Invalid: June 31
|
||||
0x091F, // Invalid: September 31
|
||||
0x0B20, // Invalid: November 32
|
||||
0xFFFF, // Invalid: Largest uint16
|
||||
];
|
||||
|
||||
describe('validateFiscalYearStartInvalidValues', () => {
|
||||
INVALID_FISCAL_YEAR_VALUES.forEach((value) => {
|
||||
it(`should return undefined for invalid fiscal year start value 0x${value.toString(16)}`, () => {
|
||||
expect(FiscalYearStart.valueOf(value)).not.toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateFiscalYearStartLeapDay', () => {
|
||||
it('should return undefined for February 29 value (0x021D)', () => {
|
||||
expect(FiscalYearStart.valueOf(0x021D)).not.toBeDefined();
|
||||
});
|
||||
|
||||
it('should return undefined when parsing month-day string "02-29"', () => {
|
||||
expect(FiscalYearStart.parse('02-29')).not.toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
type FiscalYearFromUnixTimeCase = {
|
||||
date: string;
|
||||
unixTime: number;
|
||||
expected: { [fiscalYearStartId: string]: number };
|
||||
};
|
||||
|
||||
const TEST_CASES_GET_FISCAL_YEAR_FROM_UNIX_TIME =
|
||||
importTestData('test_cases_getFiscalYearFromUnixTime') as FiscalYearFromUnixTimeCase[];
|
||||
|
||||
describe('getFiscalYearFromUnixTime', () => {
|
||||
Object.values(FISCAL_YEAR_START_PRESETS).forEach(({ id, value }) => {
|
||||
TEST_CASES_GET_FISCAL_YEAR_FROM_UNIX_TIME.forEach((testCase) => {
|
||||
it(`should return correct fiscal year for FY_START ${id}, date ${moment(testCase.date).format('MMMM D, YYYY')}`, () => {
|
||||
expect(getFiscalYearFromUnixTime(moment(testCase.date).unix(), value)).toBe(testCase.expected[id]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
type FiscalYearStartUnixTimeCase = {
|
||||
date: string;
|
||||
expected: {
|
||||
[fiscalYearStart: string]: { unixTime: number; unixTimeISO: string };
|
||||
};
|
||||
};
|
||||
|
||||
const TEST_CASES_GET_FISCAL_YEAR_START_UNIX_TIME =
|
||||
importTestData('test_cases_getFiscalYearStartUnixTime') as FiscalYearStartUnixTimeCase[];
|
||||
|
||||
describe('getFiscalYearStartUnixTime', () => {
|
||||
Object.values(FISCAL_YEAR_START_PRESETS).forEach(({ id, value }) => {
|
||||
TEST_CASES_GET_FISCAL_YEAR_START_UNIX_TIME.forEach((testCase) => {
|
||||
it(`should return correct start unix time for FY_START ${id}, date ${moment(testCase.date).format('MMMM D, YYYY')}`, () => {
|
||||
const startUnixTime = getFiscalYearStartUnixTime(moment(testCase.date).unix(), value);
|
||||
const expected = testCase.expected[id];
|
||||
expect({ unixTime: startUnixTime, ISO: formatUnixTimeISO(startUnixTime) })
|
||||
.toStrictEqual({ unixTime: expected!.unixTime, ISO: expected!.unixTimeISO });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
type FiscalYearEndUnixTimeCase = {
|
||||
date: string;
|
||||
expected: {
|
||||
[fiscalYearStart: string]: { unixTime: number; unixTimeISO: string };
|
||||
};
|
||||
};
|
||||
|
||||
const TEST_CASES_GET_FISCAL_YEAR_END_UNIX_TIME =
|
||||
importTestData('test_cases_getFiscalYearEndUnixTime') as FiscalYearEndUnixTimeCase[];
|
||||
|
||||
describe('getFiscalYearEndUnixTime', () => {
|
||||
Object.values(FISCAL_YEAR_START_PRESETS).forEach(({ id, value }) => {
|
||||
TEST_CASES_GET_FISCAL_YEAR_END_UNIX_TIME.forEach((testCase) => {
|
||||
it(`should return correct end unix time for FY_START ${id}, date ${moment(testCase.date).format('MMMM D, YYYY')}`, () => {
|
||||
const endUnixTime = getFiscalYearEndUnixTime(moment(testCase.date).unix(), value);
|
||||
const expected = testCase.expected[id];
|
||||
expect({ unixTime: endUnixTime, ISO: formatUnixTimeISO(endUnixTime) })
|
||||
.toStrictEqual({ unixTime: expected!.unixTime, ISO: expected!.unixTimeISO });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
type FiscalYearTimeRangeFromUnixTimeCase = {
|
||||
date: string;
|
||||
expected: { [fiscalYearStart: string]: FiscalYearUnixTime[] };
|
||||
};
|
||||
|
||||
const TEST_CASES_GET_FISCAL_YEAR_UNIX_TIME_RANGE =
|
||||
importTestData('test_cases_getFiscalYearTimeRangeFromUnixTime') as FiscalYearTimeRangeFromUnixTimeCase[];
|
||||
|
||||
describe('getFiscalYearTimeRangeFromUnixTime', () => {
|
||||
Object.values(FISCAL_YEAR_START_PRESETS).forEach(({ id, value }) => {
|
||||
TEST_CASES_GET_FISCAL_YEAR_UNIX_TIME_RANGE.forEach((testCase) => {
|
||||
it(`should return correct fiscal year unix time range for FY_START ${id}, date ${moment(testCase.date).format('MMMM D, YYYY')}`, () => {
|
||||
expect(getFiscalYearTimeRangeFromUnixTime(moment(testCase.date).unix(), value))
|
||||
.toStrictEqual(testCase.expected[id]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
type AllFiscalYearsStartAndEndUnixTimesCase = {
|
||||
startYearMonth: TextualYearMonth;
|
||||
endYearMonth: TextualYearMonth;
|
||||
fiscalYearStart: string;
|
||||
fiscalYearStartId: string;
|
||||
expected: FiscalYearUnixTime[];
|
||||
};
|
||||
|
||||
const TEST_CASES_GET_ALL_FISCAL_YEARS_START_AND_END_UNIX_TIMES =
|
||||
importTestData('test_cases_getAllFiscalYearsStartAndEndUnixTimes') as AllFiscalYearsStartAndEndUnixTimesCase[];
|
||||
|
||||
describe('getAllFiscalYearsStartAndEndUnixTimes', () => {
|
||||
TEST_CASES_GET_ALL_FISCAL_YEARS_START_AND_END_UNIX_TIMES.forEach((testCase) => {
|
||||
it(`should return correct fiscal year start and end unix times for FY_START ${testCase.fiscalYearStartId}, range ${testCase.startYearMonth} to ${testCase.endYearMonth}`, () => {
|
||||
const fiscalYearStart = FiscalYearStart.parse(testCase.fiscalYearStart);
|
||||
expect(fiscalYearStart).toBeDefined();
|
||||
expect(getAllFiscalYearsStartAndEndUnixTimes(testCase.startYearMonth, testCase.endYearMonth, fiscalYearStart?.value || 0).map(withISO))
|
||||
.toStrictEqual(testCase.expected.map(withISO));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
type FiscalYearTimeRangeFromYearCase = {
|
||||
year: number;
|
||||
fiscalYearStart: string;
|
||||
expected: FiscalYearUnixTime;
|
||||
};
|
||||
|
||||
const TEST_CASES_GET_FISCAL_YEAR_RANGE_FROM_YEAR =
|
||||
importTestData('test_cases_getFiscalYearTimeRangeFromYear') as FiscalYearTimeRangeFromYearCase[];
|
||||
|
||||
describe('getFiscalYearTimeRangeFromYear', () => {
|
||||
TEST_CASES_GET_FISCAL_YEAR_RANGE_FROM_YEAR.forEach((testCase) => {
|
||||
it(`should return correct fiscal year unix time range for year ${testCase.year} and FY_START ${testCase.fiscalYearStart}`, () => {
|
||||
const fiscalYearStart = FiscalYearStart.parse(testCase.fiscalYearStart);
|
||||
expect(fiscalYearStart).toBeDefined();
|
||||
expect(getFiscalYearTimeRangeFromYear(testCase.year, fiscalYearStart?.value || 0))
|
||||
.toStrictEqual(testCase.expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,289 +0,0 @@
|
||||
// Unit tests for fiscal year functions
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { describe, expect, test, beforeAll } from '@jest/globals';
|
||||
import moment from 'moment-timezone';
|
||||
|
||||
// Import all the fiscal year functions from the lib
|
||||
import type { TextualYearMonth } from '@/core/datetime.ts';
|
||||
import { FiscalYearStart, FiscalYearUnixTime } from '@/core/fiscalyear.ts';
|
||||
|
||||
import {
|
||||
getFiscalYearFromUnixTime,
|
||||
getFiscalYearStartUnixTime,
|
||||
getFiscalYearEndUnixTime,
|
||||
getFiscalYearTimeRangeFromUnixTime,
|
||||
getAllFiscalYearsStartAndEndUnixTimes,
|
||||
getFiscalYearTimeRangeFromYear
|
||||
} from '@/lib/datetime.ts';
|
||||
|
||||
// Set test environment timezone to UTC, since the test data constants are in UTC
|
||||
beforeAll(() => {
|
||||
moment.tz.setDefault('UTC');
|
||||
});
|
||||
|
||||
// UTILITIES
|
||||
function importTestData(datasetName: string): unknown[] {
|
||||
const data = JSON.parse(
|
||||
fs.readFileSync(path.join(__dirname, 'fiscal_year.data.json'), 'utf8')
|
||||
);
|
||||
if (!data || typeof data[datasetName] === 'undefined') {
|
||||
throw new Error(`${datasetName} is undefined or missing in the data object.`);
|
||||
}
|
||||
return data[datasetName];
|
||||
}
|
||||
|
||||
function formatUnixTimeISO(unixTime: number): string {
|
||||
return moment.unix(unixTime).format('YYYY-MM-DDTHH:mm:ssZ');
|
||||
}
|
||||
|
||||
function getTestTitleFormatDate(testFiscalYearStartId: string, testCaseDateString: string): string {
|
||||
return `FY_START: ${testFiscalYearStartId.padStart(10, ' ')}; DATE: ${moment(testCaseDateString).format('MMMM D, YYYY')}`;
|
||||
}
|
||||
|
||||
function getTestTitleFormatString(testFiscalYearStartId: string, testCaseString: string): string {
|
||||
return `FY_START: ${testFiscalYearStartId.padStart(10, ' ')}; ${testCaseString}`;
|
||||
}
|
||||
|
||||
// FISCAL YEAR START CONFIGURATION
|
||||
type FiscalYearStartConfig = {
|
||||
id: string;
|
||||
monthDateString: string;
|
||||
value: number;
|
||||
};
|
||||
|
||||
const TEST_FISCAL_YEAR_START_PRESETS: Record<string, FiscalYearStartConfig> = {
|
||||
'January 1': {
|
||||
id: 'January 1',
|
||||
monthDateString: '01-01',
|
||||
value: 0x0101,
|
||||
},
|
||||
'April 1': {
|
||||
id: 'April 1',
|
||||
monthDateString: '04-01',
|
||||
value: 0x0401,
|
||||
},
|
||||
'October 1': {
|
||||
id: 'October 1',
|
||||
monthDateString: '10-01',
|
||||
value: 0x0A01,
|
||||
},
|
||||
};
|
||||
|
||||
// VALIDATE FISCAL YEAR START PRESETS
|
||||
describe('validateFiscalYearStart', () => {
|
||||
Object.values(TEST_FISCAL_YEAR_START_PRESETS).forEach((testFiscalYearStart) => {
|
||||
test(`should return fiscal year start object if fiscal year start value (uint16) is valid: id: ${testFiscalYearStart.id}; value: 0x${testFiscalYearStart.value.toString(16)}`, () => {
|
||||
expect(FiscalYearStart.valueOf(testFiscalYearStart.value)).toBeDefined();
|
||||
});
|
||||
|
||||
test(`returns same month-date string for valid fiscal year start value: id: ${testFiscalYearStart.id}; value: 0x${testFiscalYearStart.value.toString(16)}`, () => {
|
||||
const fiscalYearStart = FiscalYearStart.valueOf(testFiscalYearStart.value);
|
||||
expect(fiscalYearStart?.toMonthDashDayString()).toStrictEqual(testFiscalYearStart.monthDateString);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// VALIDATE INVALID FISCAL YEAR START VALUES
|
||||
const TestCase_invalidFiscalYearValues = [
|
||||
0x0000, // Invalid: L0/0
|
||||
0x0D01, // Invalid: Month 13
|
||||
0x0100, // Invalid: Day 0
|
||||
0x0120, // Invalid: January 32
|
||||
0x021D, // Invalid: February 29 (not permitted)
|
||||
0x021E, // Invalid: February 30
|
||||
0x041F, // Invalid: April 31
|
||||
0x061F, // Invalid: June 31
|
||||
0x091F, // Invalid: September 31
|
||||
0x0B20, // Invalid: November 32
|
||||
0xFFFF, // Invalid: Largest uint16
|
||||
]
|
||||
|
||||
describe('validateFiscalYearStartInvalidValues', () => {
|
||||
TestCase_invalidFiscalYearValues.forEach((testCase) => {
|
||||
test(`should return undefined if fiscal year start value (uint16) is invalid: value: 0x${testCase.toString(16)}`, () => {
|
||||
expect(FiscalYearStart.valueOf(testCase)).not.toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// VALIDATE LEAP DAY FEBRUARY 29 IS NOT VALID
|
||||
describe('validateFiscalYearStartLeapDay', () => {
|
||||
test(`should return undefined if fiscal year start value (uint16) for February 29 is invalid: value: 0x0229}`, () => {
|
||||
expect(FiscalYearStart.valueOf(0x021D)).not.toBeDefined();
|
||||
});
|
||||
|
||||
test(`should return undefined if fiscal year month-day string "02-29" is used to create fiscal year start object`, () => {
|
||||
expect(FiscalYearStart.parse('02-29')).not.toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// FISCAL YEAR FROM UNIX TIME
|
||||
type TestCase_getFiscalYearFromUnixTime = {
|
||||
date: string;
|
||||
unixTime: number;
|
||||
expected: {
|
||||
[fiscalYearStartId: string]: number;
|
||||
};
|
||||
};
|
||||
|
||||
const TEST_CASES_GET_FISCAL_YEAR_FROM_UNIX_TIME: TestCase_getFiscalYearFromUnixTime[] =
|
||||
importTestData('test_cases_getFiscalYearFromUnixTime') as TestCase_getFiscalYearFromUnixTime[];
|
||||
|
||||
describe('getFiscalYearFromUnixTime', () => {
|
||||
Object.values(TEST_FISCAL_YEAR_START_PRESETS).forEach((testFiscalYearStart) => {
|
||||
TEST_CASES_GET_FISCAL_YEAR_FROM_UNIX_TIME.forEach((testCase) => {
|
||||
test(`returns correct fiscal year for ${getTestTitleFormatDate(testFiscalYearStart.id, testCase.date)}`, () => {
|
||||
const testCaseUnixTime = moment(testCase.date).unix();
|
||||
const fiscalYear = getFiscalYearFromUnixTime(testCaseUnixTime, testFiscalYearStart.value);
|
||||
const expected = testCase.expected[testFiscalYearStart.id];
|
||||
expect(fiscalYear).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// FISCAL YEAR START UNIX TIME
|
||||
type TestCase_getFiscalYearStartUnixTime = {
|
||||
date: string;
|
||||
expected: {
|
||||
[fiscalYearStart: string]: {
|
||||
unixTime: number;
|
||||
unixTimeISO: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const TEST_CASES_GET_FISCAL_YEAR_START_UNIX_TIME: TestCase_getFiscalYearStartUnixTime[] =
|
||||
importTestData('test_cases_getFiscalYearStartUnixTime') as TestCase_getFiscalYearStartUnixTime[];
|
||||
|
||||
describe('getFiscalYearStartUnixTime', () => {
|
||||
Object.values(TEST_FISCAL_YEAR_START_PRESETS).forEach((testFiscalYearStart) => {
|
||||
TEST_CASES_GET_FISCAL_YEAR_START_UNIX_TIME.forEach((testCase) => {
|
||||
test(`returns correct start unix time for ${getTestTitleFormatDate(testFiscalYearStart.id, testCase.date)}`, () => {
|
||||
const testCaseUnixTime = moment(testCase.date).unix();
|
||||
const startUnixTime = getFiscalYearStartUnixTime(testCaseUnixTime, testFiscalYearStart.value);
|
||||
const expected = testCase.expected[testFiscalYearStart.id];
|
||||
const unixTimeISO = formatUnixTimeISO(startUnixTime);
|
||||
|
||||
expect({ unixTime: startUnixTime, ISO: unixTimeISO }).toStrictEqual({ unixTime: expected!.unixTime, ISO: expected!.unixTimeISO });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// FISCAL YEAR END UNIX TIME
|
||||
type TestCase_getFiscalYearEndUnixTime = {
|
||||
date: string;
|
||||
expected: {
|
||||
[fiscalYearStart: string]: {
|
||||
unixTime: number;
|
||||
unixTimeISO: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const TEST_CASES_GET_FISCAL_YEAR_END_UNIX_TIME: TestCase_getFiscalYearEndUnixTime[] =
|
||||
importTestData('test_cases_getFiscalYearEndUnixTime') as TestCase_getFiscalYearEndUnixTime[];
|
||||
|
||||
describe('getFiscalYearEndUnixTime', () => {
|
||||
Object.values(TEST_FISCAL_YEAR_START_PRESETS).forEach((testFiscalYearStart) => {
|
||||
TEST_CASES_GET_FISCAL_YEAR_END_UNIX_TIME.forEach((testCase) => {
|
||||
test(`returns correct end unix time for ${getTestTitleFormatDate(testFiscalYearStart.id, testCase.date)}`, () => {
|
||||
const testCaseUnixTime = moment(testCase.date).unix();
|
||||
const endUnixTime = getFiscalYearEndUnixTime(testCaseUnixTime, testFiscalYearStart.value);
|
||||
const expected = testCase.expected[testFiscalYearStart.id];
|
||||
const unixTimeISO = formatUnixTimeISO(endUnixTime);
|
||||
|
||||
expect({ unixTime: endUnixTime, ISO: unixTimeISO }).toStrictEqual({ unixTime: expected!.unixTime, ISO: expected!.unixTimeISO });
|
||||
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// GET FISCAL YEAR UNIX TIME RANGE
|
||||
type TestCase_getFiscalYearTimeRangeFromUnixTime = {
|
||||
date: string;
|
||||
expected: {
|
||||
[fiscalYearStart: string]: FiscalYearUnixTime[]
|
||||
}
|
||||
}
|
||||
|
||||
const TEST_CASES_GET_FISCAL_YEAR_UNIX_TIME_RANGE: TestCase_getFiscalYearTimeRangeFromUnixTime[] =
|
||||
importTestData('test_cases_getFiscalYearTimeRangeFromUnixTime') as TestCase_getFiscalYearTimeRangeFromUnixTime[];
|
||||
|
||||
describe('getFiscalYearTimeRangeFromUnixTime', () => {
|
||||
Object.values(TEST_FISCAL_YEAR_START_PRESETS).forEach((testFiscalYearStart) => {
|
||||
TEST_CASES_GET_FISCAL_YEAR_UNIX_TIME_RANGE.forEach((testCase) => {
|
||||
test(`returns correct fiscal year unix time range for ${getTestTitleFormatDate(testFiscalYearStart.id, testCase.date)}`, () => {
|
||||
const testCaseUnixTime = moment(testCase.date).unix();
|
||||
const fiscalYearUnixTimeRange = getFiscalYearTimeRangeFromUnixTime(testCaseUnixTime, testFiscalYearStart.value);
|
||||
expect(fiscalYearUnixTimeRange).toStrictEqual(testCase.expected[testFiscalYearStart.id]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// GET ALL FISCAL YEAR START AND END UNIX TIMES
|
||||
type TestCase_getAllFiscalYearsStartAndEndUnixTimes = {
|
||||
startYearMonth: TextualYearMonth;
|
||||
endYearMonth: TextualYearMonth;
|
||||
fiscalYearStart: string;
|
||||
fiscalYearStartId: string;
|
||||
expected: FiscalYearUnixTime[]
|
||||
}
|
||||
|
||||
const TEST_CASES_GET_ALL_FISCAL_YEARS_START_AND_END_UNIX_TIMES: TestCase_getAllFiscalYearsStartAndEndUnixTimes[] =
|
||||
importTestData('test_cases_getAllFiscalYearsStartAndEndUnixTimes') as TestCase_getAllFiscalYearsStartAndEndUnixTimes[];
|
||||
|
||||
describe('getAllFiscalYearsStartAndEndUnixTimes', () => {
|
||||
TEST_CASES_GET_ALL_FISCAL_YEARS_START_AND_END_UNIX_TIMES.forEach((testCase) => {
|
||||
const fiscalYearStart = FiscalYearStart.parse(testCase.fiscalYearStart);
|
||||
test(`returns correct fiscal year start and end unix times for ${getTestTitleFormatString(testCase.fiscalYearStartId, `${testCase.startYearMonth} to ${testCase.endYearMonth}`)}`, () => {
|
||||
expect(fiscalYearStart).toBeDefined();
|
||||
|
||||
const fiscalYearStartAndEndUnixTimes = getAllFiscalYearsStartAndEndUnixTimes(testCase.startYearMonth, testCase.endYearMonth, fiscalYearStart?.value || 0);
|
||||
|
||||
// Convert results to include ISO strings for better test output
|
||||
const resultWithISO = fiscalYearStartAndEndUnixTimes.map(data => ({
|
||||
...data,
|
||||
minUnixTimeISO: formatUnixTimeISO(data.minUnixTime),
|
||||
maxUnixTimeISO: formatUnixTimeISO(data.maxUnixTime)
|
||||
}));
|
||||
|
||||
// Convert expected to include ISO strings
|
||||
const expectedWithISO = testCase.expected.map(data => ({
|
||||
...data,
|
||||
minUnixTimeISO: formatUnixTimeISO(data.minUnixTime),
|
||||
maxUnixTimeISO: formatUnixTimeISO(data.maxUnixTime)
|
||||
}));
|
||||
|
||||
expect(resultWithISO).toStrictEqual(expectedWithISO);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// GET FISCAL YEAR RANGE FROM YEAR
|
||||
type TestCase_getFiscalYearTimeRangeFromYear = {
|
||||
year: number;
|
||||
fiscalYearStart: string;
|
||||
expected: FiscalYearUnixTime;
|
||||
}
|
||||
|
||||
const TEST_CASES_GET_FISCAL_YEAR_RANGE_FROM_YEAR: TestCase_getFiscalYearTimeRangeFromYear[] =
|
||||
importTestData('test_cases_getFiscalYearTimeRangeFromYear') as TestCase_getFiscalYearTimeRangeFromYear[];
|
||||
|
||||
describe('getFiscalYearTimeRangeFromYear', () => {
|
||||
TEST_CASES_GET_FISCAL_YEAR_RANGE_FROM_YEAR.forEach((testCase) => {
|
||||
const fiscalYearStart = FiscalYearStart.parse(testCase.fiscalYearStart);
|
||||
test(`returns correct fiscal year unix time range for input year integer ${testCase.year} and FY_START: ${testCase.fiscalYearStart}`, () => {
|
||||
expect(fiscalYearStart).toBeDefined();
|
||||
const fiscalYearRange = getFiscalYearTimeRangeFromYear(testCase.year, fiscalYearStart?.value || 0);
|
||||
expect(fiscalYearRange).toStrictEqual(testCase.expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { mean, median, percentile, sumMaxN } from '@/lib/math.ts';
|
||||
|
||||
describe('mean', () => {
|
||||
it('should return zero for empty array', () => {
|
||||
expect(mean([], item => item)).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
it('should return the average for positive values', () => {
|
||||
expect(mean([1, 2, 3, 4], item => item)).toBeCloseTo(2.5);
|
||||
});
|
||||
|
||||
it('should return the average for negative and positive values', () => {
|
||||
expect(mean([-10, 0, 20], item => item)).toBeCloseTo(10 / 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('median', () => {
|
||||
it('should return zero for empty sorted array', () => {
|
||||
expect(median([], item => item)).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
it('should return the middle value for odd-length sorted array', () => {
|
||||
expect(median([1, 3, 5], item => item)).toBeCloseTo(3);
|
||||
});
|
||||
|
||||
it('should return the average of the two middle values for even-length sorted array', () => {
|
||||
expect(median([1, 3, 5, 7], item => item)).toBeCloseTo(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('percentile', () => {
|
||||
it('should return zero for empty sorted array', () => {
|
||||
expect(percentile([], 0.5, item => item)).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
it('should return zero when percentile is smaller than zero', () => {
|
||||
expect(percentile([1, 2, 3], -0.1, item => item)).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
it('should return zero when percentile is larger than one', () => {
|
||||
expect(percentile([1, 2, 3], 1.1, item => item)).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
it('should return the minimum value for zero percentile', () => {
|
||||
expect(percentile([5, 10, 15, 20], 0, item => item)).toBeCloseTo(5);
|
||||
});
|
||||
|
||||
it('should return the maximum value for one percentile', () => {
|
||||
expect(percentile([5, 10, 15, 20], 1, item => item)).toBeCloseTo(20);
|
||||
});
|
||||
|
||||
it('should return the exact indexed value when percentile maps to an integer index', () => {
|
||||
expect(percentile([10, 20, 30, 40, 50], 0.25, item => item)).toBeCloseTo(20);
|
||||
});
|
||||
|
||||
it('should interpolate between neighboring values when percentile maps to a fractional index', () => {
|
||||
expect(percentile([10, 20, 30, 40, 50, 60, 70, 80], 0.25, item => item)).toBeCloseTo(27.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sumMaxN', () => {
|
||||
it('should return zero for empty sorted array', () => {
|
||||
expect(sumMaxN([], 3, item => item)).toBe(0);
|
||||
});
|
||||
|
||||
it('should return zero when n is zero', () => {
|
||||
expect(sumMaxN([1, 2, 3], 0, item => item)).toBe(0);
|
||||
});
|
||||
|
||||
it('should return the sum of the largest n values', () => {
|
||||
expect(sumMaxN([1, 2, 3, 4, 5], 2, item => item)).toBe(9);
|
||||
});
|
||||
|
||||
it('should return the sum of all values when n is larger than array length', () => {
|
||||
expect(sumMaxN([1, 2, 3, 4], 10, item => item)).toBe(10);
|
||||
});
|
||||
});
|
||||
+40
-39
@@ -1,13 +1,12 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { describe, expect, test } from '@jest/globals';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { DEFAULT_CONTENT } from '@/locales/calendar/chinese/index.ts';
|
||||
|
||||
import { itemAndIndex, entries } from '@/core/base.ts';
|
||||
import type { ChineseCalendarLocaleData } from '@/core/calendar.ts';
|
||||
import {
|
||||
type ChineseYearMonthDayInfo,
|
||||
getChineseYearMonthAllDayInfos,
|
||||
getChineseYearMonthDayInfo
|
||||
} from '@/lib/calendar/chinese_calendar.ts';
|
||||
@@ -45,12 +44,33 @@ const localeData: ChineseCalendarLocaleData = {
|
||||
'Winter Solstice'
|
||||
]
|
||||
};
|
||||
|
||||
const ordinalSuffix = ['st', 'nd', 'rd'];
|
||||
|
||||
describe('getChineseYearMonthAllDayInfos', () => {
|
||||
const lines: string[] = fs.readFileSync(path.join(__dirname, 'chinese_calendar_all_data.txt'), 'utf8').replace(/\r/g, '').split('\n');
|
||||
function lunarMonthOrDayLabel(month: number, day: number): string {
|
||||
return day === 1
|
||||
? `${month}${ordinalSuffix[month - 1] ?? 'th'} Lunar Month`.toLowerCase()
|
||||
: day.toString();
|
||||
}
|
||||
|
||||
type PerDayEntry = {
|
||||
gregorianDate: string;
|
||||
gregorianYear: number;
|
||||
gregorianMonth: number;
|
||||
gregorianDay: number;
|
||||
expectedChineseMonthOrDay: string;
|
||||
expectedSolarTermName: string;
|
||||
};
|
||||
|
||||
function parseCalendarDataFile(): {
|
||||
allMonthChineseDays: Record<string, string[]>;
|
||||
allMonthSolarTermNames: Record<string, string[]>;
|
||||
perDayEntries: PerDayEntry[];
|
||||
} {
|
||||
const lines = fs.readFileSync(path.join(__dirname, 'chinese_calendar_all_data.txt'), 'utf8').replace(/\r/g, '').split('\n');
|
||||
const allMonthChineseDays: Record<string, string[]> = {};
|
||||
const allMonthSolarTermNames: Record<string, string[]> = {};
|
||||
const perDayEntries: PerDayEntry[] = [];
|
||||
let currentMonthChineseDays: string[] = [];
|
||||
let currentMonthSolarTermNames: string[] = [];
|
||||
let currentYear: number = 0;
|
||||
@@ -66,16 +86,17 @@ describe('getChineseYearMonthAllDayInfos', () => {
|
||||
const gregorianDateItems = gregorianDate.split('/');
|
||||
const gregorianYear = parseInt(gregorianDateItems[0] as string, 10);
|
||||
const gregorianMonth = parseInt(gregorianDateItems[1] as string, 10);
|
||||
const gregorianDay = parseInt(gregorianDateItems[2] as string, 10);
|
||||
const chineseDay = items[1] as string;
|
||||
const solarTermName = items.length > 3 ? items[3] as string : '';
|
||||
|
||||
perDayEntries.push({ gregorianDate, gregorianYear, gregorianMonth, gregorianDay, expectedChineseMonthOrDay: chineseDay, expectedSolarTermName: solarTermName });
|
||||
|
||||
if (currentYear > 0 && currentMonth > 0 && (gregorianYear !== currentYear || gregorianMonth !== currentMonth)) {
|
||||
allMonthChineseDays[`${currentYear}-${currentMonth}`] = currentMonthChineseDays;
|
||||
allMonthSolarTermNames[`${currentYear}-${currentMonth}`] = currentMonthSolarTermNames;
|
||||
|
||||
currentMonthChineseDays = [];
|
||||
currentMonthSolarTermNames = [];
|
||||
|
||||
currentYear = gregorianYear;
|
||||
currentMonth = gregorianMonth;
|
||||
} else if (currentYear === 0 && currentMonth === 0) {
|
||||
@@ -92,27 +113,27 @@ describe('getChineseYearMonthAllDayInfos', () => {
|
||||
allMonthChineseDays[`${currentYear}-${currentMonth}`] = currentMonthChineseDays;
|
||||
allMonthSolarTermNames[`${currentYear}-${currentMonth}`] = currentMonthSolarTermNames;
|
||||
|
||||
return { allMonthChineseDays, allMonthSolarTermNames, perDayEntries };
|
||||
}
|
||||
|
||||
const { allMonthChineseDays, allMonthSolarTermNames, perDayEntries } = parseCalendarDataFile();
|
||||
|
||||
describe('getChineseYearMonthAllDayInfos', () => {
|
||||
for (const [yearMonth, monthChineseDays] of entries(allMonthChineseDays)) {
|
||||
test(`returns correct chinese all dates in month for ${yearMonth}`, () => {
|
||||
it(`should return correct chinese dates for all days in ${yearMonth}`, () => {
|
||||
const [yearStr, monthStr] = yearMonth.split('-');
|
||||
const year = parseInt(yearStr as string);
|
||||
const month = parseInt(monthStr as string);
|
||||
const expectedChineseMonthOrDays = monthChineseDays;
|
||||
const expectedSolarTermNames = allMonthSolarTermNames[yearMonth] as string[];
|
||||
|
||||
const actualChineseDates: ChineseYearMonthDayInfo[] | undefined = getChineseYearMonthAllDayInfos({
|
||||
year: year,
|
||||
month1base: month
|
||||
}, localeData);
|
||||
const actualChineseDates = getChineseYearMonthAllDayInfos({ year, month1base: month }, localeData);
|
||||
|
||||
expect(actualChineseDates).toBeDefined();
|
||||
|
||||
if (actualChineseDates) {
|
||||
for (const [actualChineseDate, index] of itemAndIndex(actualChineseDates)) {
|
||||
const chineseMonthOrDay: string | undefined = actualChineseDate?.day === 1 ? `${actualChineseDate?.month}${ordinalSuffix[actualChineseDate?.month - 1] ?? 'th'} Lunar Month`.toLowerCase() : actualChineseDate?.day.toString();
|
||||
|
||||
expect(actualChineseDate).toBeDefined();
|
||||
expect(chineseMonthOrDay).toBe(expectedChineseMonthOrDays[index]);
|
||||
expect(lunarMonthOrDayLabel(actualChineseDate!.month, actualChineseDate!.day)).toBe(monthChineseDays[index]);
|
||||
expect(actualChineseDate?.solarTermName).toBe(expectedSolarTermNames[index]);
|
||||
}
|
||||
}
|
||||
@@ -121,32 +142,12 @@ describe('getChineseYearMonthAllDayInfos', () => {
|
||||
});
|
||||
|
||||
describe('getChineseYearMonthDayInfo', () => {
|
||||
const lines: string[] = fs.readFileSync(path.join(__dirname, 'chinese_calendar_all_data.txt'), 'utf8').replace(/\r/g, '').split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim() || line.startsWith('#')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const items = line.split('\t');
|
||||
const gregorianDate = items[0] as string;
|
||||
const gregorianDateItems = gregorianDate.split('/');
|
||||
const gregorianYear = parseInt(gregorianDateItems[0] as string);
|
||||
const gregorianMonth = parseInt(gregorianDateItems[1] as string);
|
||||
const gregorianDay = parseInt(gregorianDateItems[2] as string);
|
||||
const expectedChineseMonthOrDay = items[1] as string;
|
||||
const expectedSolarTermName = items.length > 3 ? items[3] as string : '';
|
||||
|
||||
test(`returns correct chinese date for ${gregorianDate}`, () => {
|
||||
const actualChineseDate: ChineseYearMonthDayInfo | undefined = getChineseYearMonthDayInfo({
|
||||
year: gregorianYear,
|
||||
month: gregorianMonth,
|
||||
day: gregorianDay
|
||||
}, localeData);
|
||||
const actualChineseMonthOrDay: string | undefined = actualChineseDate?.day === 1 ? `${actualChineseDate?.month}${ordinalSuffix[actualChineseDate?.month - 1] ?? 'th'} Lunar Month`.toLowerCase() : actualChineseDate?.day.toString();
|
||||
for (const { gregorianDate, gregorianYear, gregorianMonth, gregorianDay, expectedChineseMonthOrDay, expectedSolarTermName } of perDayEntries) {
|
||||
it(`should return correct chinese date for ${gregorianDate}`, () => {
|
||||
const actualChineseDate = getChineseYearMonthDayInfo({ year: gregorianYear, month: gregorianMonth, day: gregorianDay }, localeData);
|
||||
|
||||
expect(actualChineseDate).toBeDefined();
|
||||
expect(actualChineseMonthOrDay).toBe(expectedChineseMonthOrDay.toLowerCase());
|
||||
expect(lunarMonthOrDayLabel(actualChineseDate!.month, actualChineseDate!.day)).toBe(expectedChineseMonthOrDay.toLowerCase());
|
||||
expect(actualChineseDate?.solarTermName).toBe(expectedSolarTermName);
|
||||
});
|
||||
}
|
||||
+32
-1
@@ -2,9 +2,10 @@ import {
|
||||
type GenericNameValue,
|
||||
type TypeAndName,
|
||||
type TypeAndDisplayName,
|
||||
entries,
|
||||
keys,
|
||||
keysIfValueEquals,
|
||||
values
|
||||
values,
|
||||
} from '@/core/base.ts';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
@@ -190,6 +191,21 @@ export function getObjectOwnFieldCount(object: object): number {
|
||||
return count;
|
||||
}
|
||||
|
||||
export function getObjectOwnFieldWithValueCount(object: object, value: unknown): number {
|
||||
let count = 0;
|
||||
|
||||
if (!object || !isObject(object)) {
|
||||
return count;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
for (const _ of keysIfValueEquals(object, value)) {
|
||||
count++;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
export function replaceAll(value: string, originalValue: string, targetValue: string): string {
|
||||
// Escape special characters in originalValue to safely use it in a regex pattern.
|
||||
// This ensures that characters like . (dot), * (asterisk), +, ?, etc. are treated literally,
|
||||
@@ -387,6 +403,21 @@ export function objectFieldToArrayItem(object: object): string[] {
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function mapObjectToArray<V, R>(object: Record<string | number | symbol, V>, mapFunc: (value: V, key: string | number | symbol, index: number) => R): R[] {
|
||||
const ret: R[] = [];
|
||||
let index = 0;
|
||||
|
||||
for (const [key, value] of entries(object)) {
|
||||
const mappedValue = mapFunc(value, key, index++);
|
||||
|
||||
if (isDefined(mappedValue)) {
|
||||
ret.push(mappedValue);
|
||||
}
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function objectFieldWithValueToArrayItem<T>(object: Record<string, T>, value: T): string[] {
|
||||
const ret: string[] = [];
|
||||
|
||||
|
||||
+87
-4
@@ -51,6 +51,11 @@ import {
|
||||
NumeralSystem
|
||||
} from '@/core/numeral.ts';
|
||||
|
||||
import {
|
||||
WESTERNMOST_TIMEZONE_UTC_OFFSET,
|
||||
EASTERNMOST_TIMEZONE_UTC_OFFSET,
|
||||
} from '@/consts/timezone.ts';
|
||||
|
||||
import {
|
||||
isFunction,
|
||||
isDefined,
|
||||
@@ -74,15 +79,12 @@ interface DateTimeFormatResult {
|
||||
|
||||
type DateTimeTokenFormatFunction = (d: MomentDateTime, options: DateTimeFormatOptions) => DateTimeFormatResult;
|
||||
|
||||
const westernmostTimezoneUtcOffset: number = -720; // Etc/GMT+12 (UTC-12:00)
|
||||
const easternmostTimezoneUtcOffset: number = 840; // Pacific/Kiritimati (UTC+14:00)
|
||||
|
||||
function getFixedTimezoneName(utcOffset: number): string {
|
||||
return `Fixed/Timezone${utcOffset}`;
|
||||
}
|
||||
|
||||
(function initFixedTimezone(): void {
|
||||
for (let utcOffset = westernmostTimezoneUtcOffset; utcOffset <= easternmostTimezoneUtcOffset; utcOffset += 15) {
|
||||
for (let utcOffset = WESTERNMOST_TIMEZONE_UTC_OFFSET; utcOffset <= EASTERNMOST_TIMEZONE_UTC_OFFSET; utcOffset += 15) {
|
||||
const timezoneName = getFixedTimezoneName(utcOffset);
|
||||
|
||||
if (moment.tz.zone(timezoneName)) {
|
||||
@@ -254,6 +256,10 @@ class MomentDateTime implements DateTime {
|
||||
return (this.instance.year() + '-' + (this.instance.month() + 1).toString().padStart(2, NumeralSystem.WesternArabicNumerals.digitZero)) as TextualYearMonth;
|
||||
}
|
||||
|
||||
public getMaxDayOfGregorianCalendarMonth(): number {
|
||||
return this.instance.clone().endOf('month').date();
|
||||
}
|
||||
|
||||
public getWeekDay(): WeekDay {
|
||||
return WeekDay.valueOf(this.instance.day()) as WeekDay;
|
||||
}
|
||||
@@ -890,6 +896,26 @@ export function getDayLastDateTimeBySpecifiedUnixTime(unixTime: number, utcOffse
|
||||
return getDayFirstDateTimeBySpecifiedUnixTime(unixTime, utcOffset).add(1, 'days').subtract(1, 'seconds');
|
||||
}
|
||||
|
||||
export function getBillingCycleFirstUnixTimeBySpecifiedUnixTime(unixTime: number, statementDate: number, utcOffset?: number): DateTime {
|
||||
let date = moment.unix(unixTime);
|
||||
|
||||
if (isNumber(utcOffset)) {
|
||||
date = date.tz(getFixedTimezoneName(utcOffset));
|
||||
}
|
||||
|
||||
if (date.date() > statementDate) {
|
||||
date = date.set({ date: statementDate + 1, hour: 0, minute: 0, second: 0, millisecond: 0 });
|
||||
} else {
|
||||
date = date.set({ date: statementDate, hour: 0, minute: 0, second: 0, millisecond: 0 }).add(-1, 'months').add(1, 'days');
|
||||
}
|
||||
|
||||
return MomentDateTime.of(date);
|
||||
}
|
||||
|
||||
export function getBillingCycleLastUnixTimeBySpecifiedUnixTime(unixTime: number, statementDate: number, utcOffset?: number): DateTime {
|
||||
return getBillingCycleFirstUnixTimeBySpecifiedUnixTime(unixTime, statementDate, utcOffset).add(1, 'months').subtract(1, 'seconds');
|
||||
}
|
||||
|
||||
export function getYearFirstUnixTime(year: number): number {
|
||||
return moment().set({ year: year, month: 0, date: 1, hour: 0, minute: 0, second: 0, millisecond: 0 }).unix();
|
||||
}
|
||||
@@ -1106,6 +1132,41 @@ export function getAllMonthsStartAndEndUnixTimes(startYearMonth: Year0BasedMonth
|
||||
return allYearMonthTimes;
|
||||
}
|
||||
|
||||
export function getAllBillingCyclesStartAndEndUnixTimes(startUnixTime: number, endUnixTime: number, statementDate: number): YearMonthUnixTime[] {
|
||||
const allYearMonthTimes: YearMonthUnixTime[] = [];
|
||||
|
||||
if (!startUnixTime || !endUnixTime) {
|
||||
return allYearMonthTimes;
|
||||
}
|
||||
|
||||
let unixTime: number = startUnixTime;
|
||||
|
||||
while (unixTime <= endUnixTime) {
|
||||
const currentDateTime = parseDateTimeFromUnixTime(unixTime);
|
||||
let currentBillingCycleMinDateTime: DateTime;
|
||||
|
||||
if (currentDateTime.getGregorianCalendarDay() > statementDate) {
|
||||
const currentMonthMinDateTime = getMonthFirstDateTimeBySpecifiedUnixTime(unixTime);
|
||||
currentBillingCycleMinDateTime = currentMonthMinDateTime.add(statementDate, 'days');
|
||||
} else {
|
||||
const currentMonthMinDateTime = getMonthFirstDateTimeBySpecifiedUnixTime(unixTime);
|
||||
const previousMonthMinDateTime = currentMonthMinDateTime.add(-1, 'months');
|
||||
currentBillingCycleMinDateTime = previousMonthMinDateTime.add(statementDate, 'days');
|
||||
}
|
||||
|
||||
const currentBillingCycleMaxDateTime = currentBillingCycleMinDateTime.add(1, 'months').subtract(1, 'seconds');
|
||||
const yearMonth: Year0BasedMonth = {
|
||||
year: currentBillingCycleMaxDateTime.getGregorianCalendarYear(),
|
||||
month0base: currentBillingCycleMaxDateTime.getGregorianCalendarMonth() - 1
|
||||
};
|
||||
|
||||
allYearMonthTimes.push(YearMonthUnixTime.of(yearMonth, currentBillingCycleMinDateTime.getUnixTime(), currentBillingCycleMaxDateTime.getUnixTime()));
|
||||
unixTime = currentBillingCycleMaxDateTime.getUnixTime() + 1;
|
||||
}
|
||||
|
||||
return allYearMonthTimes;
|
||||
}
|
||||
|
||||
export function getAllDaysStartAndEndUnixTimes(startUnixTime: number, endUnixTime: number): YearMonthDayUnixTime[] {
|
||||
const allYearMonthDayTimes: YearMonthDayUnixTime[] = [];
|
||||
|
||||
@@ -1372,6 +1433,28 @@ export function getDateRangeByBillingCycleDateType(dateType: number, firstDayOfW
|
||||
};
|
||||
}
|
||||
|
||||
export function getDateRangeByLastReconciledTimeRangeDateType(dateType: number, lastReconciledTime: number | undefined | null): TimeRangeAndDateType | null {
|
||||
let maxTime = 0;
|
||||
let minTime = 0;
|
||||
|
||||
if (dateType === DateRange.SinceLastReconciledTime.type) { // Since Last Reconciled Time
|
||||
if (lastReconciledTime) {
|
||||
maxTime = getTodayLastUnixTime();
|
||||
minTime = lastReconciledTime;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
dateType: dateType,
|
||||
maxTime: maxTime,
|
||||
minTime: minTime
|
||||
};
|
||||
}
|
||||
|
||||
export function getRecentMonthDateRanges(monthCount: number): RecentMonthDateRange[] {
|
||||
const recentDateRanges: RecentMonthDateRange[] = [];
|
||||
const thisMonthFirstUnixTime = getThisMonthFirstUnixTime();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AMOUNT_FACTOR } from '@/consts/numeral.ts';
|
||||
import { TRANSACTION_MIN_AMOUNT, TRANSACTION_MAX_AMOUNT } from '../consts/transaction.ts';
|
||||
|
||||
import { replaceAll } from './common.ts';
|
||||
@@ -10,7 +11,7 @@ type OperatorAndParenthesis = Operator | '(' | ')';
|
||||
const maxAllowedDecimalCount = 6;
|
||||
const normalizeFactor: number = 1000000;
|
||||
const normalizedDecimalsMaxZeroString: string = '000000';
|
||||
const normalizedNumberToAmountFactor: number = 10000; // 1000000 / 100
|
||||
const normalizedNumberToAmountFactor: number = normalizeFactor / AMOUNT_FACTOR;
|
||||
|
||||
const operatorPriority: Record<Operator, number> = {
|
||||
'+': 1,
|
||||
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
import { reversed } from '@/core/base.ts';
|
||||
|
||||
export function mean<T>(values: T[], valueFn: (item: T) => number): number {
|
||||
if (values.length < 1) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let sum: number = 0;
|
||||
|
||||
for (const item of values) {
|
||||
sum += valueFn(item);
|
||||
}
|
||||
|
||||
return sum / values.length;
|
||||
}
|
||||
|
||||
export function median<T>(sortedValues: T[], valueFn: (item: T) => number): number {
|
||||
if (sortedValues.length < 1) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const mid: number = Math.floor(sortedValues.length / 2);
|
||||
|
||||
if (sortedValues.length % 2 === 0) {
|
||||
return (valueFn(sortedValues[mid - 1] as T) + valueFn(sortedValues[mid] as T)) / 2;
|
||||
} else {
|
||||
return valueFn(sortedValues[mid] as T);
|
||||
}
|
||||
}
|
||||
|
||||
export function percentile<T>(sortedValues: T[], percentile: number, valueFn: (item: T) => number): number {
|
||||
if (sortedValues.length < 1 || percentile < 0 || percentile > 1) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const index: number = (sortedValues.length - 1) * percentile + 1;
|
||||
const indexFloor: number = Math.floor(index);
|
||||
const indexCeil: number = Math.ceil(index);
|
||||
|
||||
if (indexFloor === indexCeil) {
|
||||
return valueFn(sortedValues[indexFloor - 1] as T);
|
||||
} else {
|
||||
const value1: number = valueFn(sortedValues[indexFloor - 1] as T);
|
||||
const value2: number = valueFn(sortedValues[indexCeil - 1] as T);
|
||||
return value1 + (index - indexFloor) * (value2 - value1);
|
||||
}
|
||||
}
|
||||
|
||||
export function sumMaxN<T>(sortedValues: T[], n: number, valueFn: (item: T) => number): number {
|
||||
if (sortedValues.length < 1 || n <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let sum: number = 0;
|
||||
const count: number = Math.min(n, sortedValues.length);
|
||||
const startIndex: number = sortedValues.length - count;
|
||||
|
||||
for (let i = sortedValues.length - 1; i >= startIndex; i--) {
|
||||
sum += valueFn(sortedValues[i] as T);
|
||||
}
|
||||
|
||||
return sum;
|
||||
}
|
||||
|
||||
export function cumulativePercentage<T>(sortedValues: T[], percentageThreshold: number, totalValue: number, valueFn: (item: T) => number): number {
|
||||
if (sortedValues.length < 1 || percentageThreshold < 0 || percentageThreshold > 1) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const thresholdValue: number = percentageThreshold * totalValue;
|
||||
let cumulativeValue: number = 0;
|
||||
let cumulativeCount: number = 0;
|
||||
|
||||
for (const item of reversed(sortedValues)) {
|
||||
cumulativeValue += valueFn(item);
|
||||
cumulativeCount++;
|
||||
|
||||
if (cumulativeValue >= thresholdValue) {
|
||||
return 100.0 * cumulativeCount / sortedValues.length;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function meanAbsoluteDeviation<T>(values: T[], meanValue: number, valueFn: (item: T) => number): number {
|
||||
if (values.length < 1) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let sumOfAbsoluteDifferences: number = 0;
|
||||
|
||||
for (const item of values) {
|
||||
const difference: number = Math.abs(valueFn(item) - meanValue);
|
||||
sumOfAbsoluteDifferences += difference;
|
||||
}
|
||||
|
||||
return sumOfAbsoluteDifferences / values.length;
|
||||
}
|
||||
|
||||
export function medianAbsoluteDeviation<T>(sortedValues: T[], medianValue: number, valueFn: (item: T) => number): number {
|
||||
if (sortedValues.length < 1) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const absoluteDeviations: number[] = sortedValues.map(item => Math.abs(valueFn(item) - medianValue));
|
||||
absoluteDeviations.sort((a, b) => a - b);
|
||||
|
||||
return median(absoluteDeviations, x => x);
|
||||
}
|
||||
|
||||
export function varianceAndStandardDeviation<T>(values: T[], meanValue: number, valueFn: (item: T) => number): { variance: number; standardDeviation: number } {
|
||||
if (values.length < 1) {
|
||||
return { variance: 0, standardDeviation: 0 };
|
||||
}
|
||||
|
||||
let sumOfSquaredDifferences: number = 0;
|
||||
|
||||
for (const item of values) {
|
||||
const difference: number = valueFn(item) - meanValue;
|
||||
sumOfSquaredDifferences += difference * difference;
|
||||
}
|
||||
|
||||
const variance: number = sumOfSquaredDifferences / values.length;
|
||||
const standardDeviation: number = Math.sqrt(variance);
|
||||
|
||||
return { variance, standardDeviation };
|
||||
}
|
||||
|
||||
export function coefficientOfVariation(standardDeviation: number, meanValue: number): number | undefined {
|
||||
if (meanValue === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return standardDeviation / meanValue;
|
||||
}
|
||||
|
||||
export function skewness<T>(values: T[], meanValue: number, standardDeviation: number, valueFn: (item: T) => number): number {
|
||||
if (values.length < 1 || standardDeviation === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let sumOfCubedDifferences: number = 0;
|
||||
|
||||
for (const item of values) {
|
||||
const difference: number = valueFn(item) - meanValue;
|
||||
sumOfCubedDifferences += Math.pow(difference, 3);
|
||||
}
|
||||
|
||||
return sumOfCubedDifferences / (values.length * Math.pow(standardDeviation, 3));
|
||||
}
|
||||
|
||||
export function kurtosis<T>(values: T[], meanValue: number, variance: number, valueFn: (item: T) => number): number {
|
||||
if (values.length < 1 || variance === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let sumOfQuarticDifferences: number = 0;
|
||||
|
||||
for (const item of values) {
|
||||
const difference: number = valueFn(item) - meanValue;
|
||||
sumOfQuarticDifferences += Math.pow(difference, 4);
|
||||
}
|
||||
|
||||
return sumOfQuarticDifferences / (values.length * Math.pow(variance, 2));
|
||||
}
|
||||
+10
-17
@@ -6,20 +6,12 @@ import {
|
||||
DigitGroupingSymbol
|
||||
} from '@/core/numeral.ts';
|
||||
|
||||
import { AMOUNT_FACTOR } from '@/consts/numeral.ts';
|
||||
|
||||
import { DEFAULT_DECIMAL_NUMBER_COUNT, MAX_SUPPORTED_DECIMAL_NUMBER_COUNT, DISPLAY_HIDDEN_AMOUNT } from '@/consts/numeral.ts';
|
||||
|
||||
import { isDefined, isString, isNumber, replaceAll, removeAll } from './common.ts';
|
||||
|
||||
export function sumAmounts(amounts: number[]): number {
|
||||
let sum = 0;
|
||||
|
||||
for (const amount of amounts) {
|
||||
sum += amount;
|
||||
}
|
||||
|
||||
return sum;
|
||||
}
|
||||
|
||||
export function appendDigitGroupingSymbolAndDecimalSeparator(textualNumber: string, options: NumberFormatOptions): string {
|
||||
if (!textualNumber) {
|
||||
return textualNumber;
|
||||
@@ -125,7 +117,7 @@ export function parseAmount(str: string, options: NumberFormatOptions): number {
|
||||
let decimalSeparatorPos = str.indexOf(decimalSeparator);
|
||||
|
||||
if (decimalSeparatorPos < 0) {
|
||||
return sign * numeralSystem.parseInt(str) * 100;
|
||||
return sign * numeralSystem.parseInt(str) * AMOUNT_FACTOR;
|
||||
} else if (decimalSeparatorPos === 0) {
|
||||
str = numeralSystem.digitZero + str;
|
||||
decimalSeparatorPos++;
|
||||
@@ -135,13 +127,13 @@ export function parseAmount(str: string, options: NumberFormatOptions): number {
|
||||
const decimals = str.substring(decimalSeparatorPos + 1, str.length);
|
||||
|
||||
if (decimals.length < 1) {
|
||||
return sign * numeralSystem.parseInt(integer) * 100;
|
||||
return sign * numeralSystem.parseInt(integer) * AMOUNT_FACTOR;
|
||||
} else if (decimals.length === 1) {
|
||||
return sign * numeralSystem.parseInt(integer) * 100 + sign * numeralSystem.parseInt(decimals) * 10;
|
||||
return sign * numeralSystem.parseInt(integer) * AMOUNT_FACTOR + sign * numeralSystem.parseInt(decimals) * AMOUNT_FACTOR / 10;
|
||||
} else if (decimals.length === 2) {
|
||||
return sign * numeralSystem.parseInt(integer) * 100 + sign * numeralSystem.parseInt(decimals);
|
||||
return sign * numeralSystem.parseInt(integer) * AMOUNT_FACTOR + sign * numeralSystem.parseInt(decimals);
|
||||
} else {
|
||||
return sign * numeralSystem.parseInt(integer) * 100 + sign * numeralSystem.parseInt(decimals.substring(0, 2));
|
||||
return sign * numeralSystem.parseInt(integer) * AMOUNT_FACTOR + sign * numeralSystem.parseInt(decimals.substring(0, 2));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,9 +254,10 @@ export function formatPercent(value: number, precision: number, lowPrecisionValu
|
||||
|
||||
export function getAmountWithDecimalNumberCount(amount: number, decimalNumberCount: number): number {
|
||||
if (decimalNumberCount === 0) {
|
||||
return Math.trunc(amount / 100) * 100;
|
||||
return Math.trunc(amount / AMOUNT_FACTOR) * AMOUNT_FACTOR;
|
||||
} else if (decimalNumberCount === 1) {
|
||||
return Math.trunc(amount / 10) * 10;
|
||||
const factor = AMOUNT_FACTOR / 10;
|
||||
return Math.trunc(amount / factor) * factor;
|
||||
}
|
||||
|
||||
return amount;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user