Compare commits
224 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d9643dcb2 | |||
| 5dc4ad60ba | |||
| 2e5dd7d513 | |||
| efe088f591 | |||
| d5016e853e | |||
| d334bd7b9a | |||
| 21edf0157a | |||
| 388167705a | |||
| 2423b37cbb | |||
| 786796d457 | |||
| 0ed9216260 | |||
| eb13f10121 | |||
| 76ce6f6f9c | |||
| eb305139f5 | |||
| 8df73f202a | |||
| c22751de6f | |||
| c3f1cb0c61 | |||
| fc1fc58aa1 | |||
| e4b5e96534 | |||
| 66303a8965 | |||
| 9589dd2486 | |||
| 3d5b887e23 | |||
| b967a214cb | |||
| 5a9877588f | |||
| fc5f8e4633 | |||
| 028bca50ea | |||
| 6853bbfb68 | |||
| d4fee27a3d | |||
| 245fdd78e4 | |||
| cbe784172e | |||
| bf21e45cba | |||
| 359c430a39 | |||
| 669a217180 | |||
| e9507241ed | |||
| f2536749f6 | |||
| 118558d25b | |||
| d9cd270ff4 | |||
| 9dee449f10 | |||
| ae19ca4383 | |||
| 32fed8d6fb | |||
| ec325c9e6b | |||
| 5fbb29abd3 | |||
| f06c6523a2 | |||
| 02514fc457 | |||
| 5d88287ae2 | |||
| 00f1d0418f | |||
| 18b270debb | |||
| d947164eb6 | |||
| 1a1bb6077c | |||
| 05b5cab12b | |||
| a82fdd4946 | |||
| 4def7ed60c | |||
| d50ce0140f | |||
| 51678aee04 | |||
| 019689087d | |||
| 0c1d77f7ae | |||
| 8de51e6e71 | |||
| dc993da218 | |||
| 983f7fec0f | |||
| ce74c4817b | |||
| bc363438f1 | |||
| 979b16d520 | |||
| 9686eb020f | |||
| 88dea9acaa | |||
| c75fdfea1c | |||
| 538d2b8205 | |||
| 30d36a3b07 | |||
| 95bcd8e4c8 | |||
| 1a8ce7d58d | |||
| 4700446ca0 | |||
| 67bc81d3e2 | |||
| 878a3a018e | |||
| e463c2dc95 | |||
| 422cf49517 | |||
| 77d2426c14 | |||
| 1c4dc55bb6 | |||
| ba72f421dc | |||
| 36d1e01008 | |||
| e52c7037c7 | |||
| f5235ba08e | |||
| adc4899ea6 | |||
| 34c5a1750e | |||
| c75a902d84 | |||
| 7e2e1a4ad3 | |||
| d4603a1892 | |||
| 642e51bc0c | |||
| 5591abdb3b | |||
| ce9378c43f | |||
| 3ae72623ad | |||
| affc02655b | |||
| a469d66358 | |||
| 757f9e5b02 | |||
| 8368b02be8 | |||
| e15a5617e6 | |||
| f604b2c766 | |||
| d6dc9f8170 | |||
| a71be1bf05 | |||
| bcf11631d6 | |||
| 989183c8be | |||
| 8bd0fd88af | |||
| 20e2444307 | |||
| 8154bd712b | |||
| 4d0e376568 | |||
| 32cf41a7a0 | |||
| e85a4701ed | |||
| b79ffafaee | |||
| 8f6adaa417 | |||
| 0e634d83f4 | |||
| af8cbe0b15 | |||
| 411130db4e | |||
| c099443783 | |||
| 23ffdbb163 | |||
| 0b48502a10 | |||
| 25681f622d | |||
| f196ce969b | |||
| 0408c470fc | |||
| 01aeb945ff | |||
| 601a1f83c6 | |||
| 2a470742e0 | |||
| 8ba1e1997f | |||
| 27ae401a7f | |||
| 81727d3b1e | |||
| 06a0501633 | |||
| 781c2d9044 | |||
| 15e4ad00ee | |||
| 8064a00252 | |||
| f2d0fe407b | |||
| 9589657fd5 | |||
| 790837076f | |||
| 6d923027a0 | |||
| 13d5759e84 | |||
| efe39c7390 | |||
| c00770201b | |||
| 4eff3a337f | |||
| 451385011e | |||
| cd4d230d29 | |||
| ab6d4ee6fc | |||
| 274aa6a17c | |||
| 2f8d4ad5e4 | |||
| fe59d3b280 | |||
| e2c99c4f04 | |||
| 127393b64a | |||
| f3d240442b | |||
| 55bf8b9e30 | |||
| eadcf7768f | |||
| 876bf8cc31 | |||
| 6b5aac0111 | |||
| dc4a4e1463 | |||
| 0677ed07db | |||
| ecf6fbd187 | |||
| 351cebe169 | |||
| 0f94a90882 | |||
| 04996d784f | |||
| aafcfeda84 | |||
| 7283b724b1 | |||
| 0d55912f6c | |||
| 60108e26c7 | |||
| be129cd3c6 | |||
| f210bfa9f4 | |||
| 263113a67f | |||
| 3b29303237 | |||
| 6e5f857e97 | |||
| 791c0ea26e | |||
| 84523d8b8a | |||
| d35e127b9e | |||
| ebe00d3271 | |||
| 14b4e40039 | |||
| 15d1d269ae | |||
| e90b76c80e | |||
| e28e27080a | |||
| 2268496dcb | |||
| 3781327c58 | |||
| 51c33d7e83 | |||
| 975a56e7d9 | |||
| 29a87dcfaf | |||
| cad53d0bfc | |||
| 56a3905df1 | |||
| 428a1f2156 | |||
| b5233399e6 | |||
| f8878c5405 | |||
| 8dcaa457f9 | |||
| b24ebdb83e | |||
| d41a2141a7 | |||
| 09a1dd0358 | |||
| 531c4a44d5 | |||
| ceecff8c24 | |||
| f32cc4ab04 | |||
| 8fa46281e0 | |||
| f7bc4b3ab6 | |||
| ad4f5bd88d | |||
| e4cb66718d | |||
| 175b272fa0 | |||
| ca0fb9446b | |||
| 6eb749dca2 | |||
| 880b614636 | |||
| d146a99c65 | |||
| fd99c784b3 | |||
| 22f9c5243a | |||
| 67f5aaa5ee | |||
| 713b621169 | |||
| 80df5f95aa | |||
| 1e492d8724 | |||
| 602f15fe2e | |||
| 3335533a18 | |||
| d385358aa3 | |||
| d6ee8a416f | |||
| c5aa37037f | |||
| 6050f5deab | |||
| 5d07d1a70d | |||
| bae330c6f3 | |||
| ea17994c6c | |||
| c3d29ee2f8 | |||
| 515b9af61a | |||
| 4ba3893b83 | |||
| bcb6c4f419 | |||
| 53f101fb60 | |||
| 8da4f65048 | |||
| 428bcba56e | |||
| 68e896d8eb | |||
| eef62722a4 | |||
| e3dcb2ce0c | |||
| 0cf89562cd | |||
| 8b06731cdb | |||
| 36abd1acec |
@@ -0,0 +1,70 @@
|
|||||||
|
name: Bug Report
|
||||||
|
description: Report a bug in ezBookkeeping
|
||||||
|
labels: bug
|
||||||
|
body:
|
||||||
|
- type: checkboxes
|
||||||
|
id: checkboxes
|
||||||
|
attributes:
|
||||||
|
label: Before You Submit
|
||||||
|
description: Please check whether the following items have been completed.
|
||||||
|
options:
|
||||||
|
- label: I've already checked this bug hasn't been raised in [issues](https://github.com/mayswind/ezbookkeeping/issues)
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: description
|
||||||
|
attributes:
|
||||||
|
label: Description
|
||||||
|
description: Please provide a brief description of this bug.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: reproduction-steps
|
||||||
|
attributes:
|
||||||
|
label: Steps to reproduce
|
||||||
|
description: Please describe the steps to reproduce this bug.
|
||||||
|
placeholder: |
|
||||||
|
1.
|
||||||
|
2.
|
||||||
|
3.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: ezbookkeeping-version
|
||||||
|
attributes:
|
||||||
|
label: ezBookkeeping Version
|
||||||
|
description: ezBookkeeping version and commit hash of your instance, e.g. "v1.0.0 (20e2444)"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: server-os
|
||||||
|
attributes:
|
||||||
|
label: Server Operating System
|
||||||
|
description: The operating system information you are using to deploy ezBookkeeping, e.g "Debian GNU/Linux 11 amd64"
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: server-database
|
||||||
|
attributes:
|
||||||
|
label: Database
|
||||||
|
description: The database system you are using, e.g. "MariaDB v11.7.2"
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: reproduce-on-demo-site
|
||||||
|
attributes:
|
||||||
|
label: Can you reproduce this bug on the ezBookkeeping demo site?
|
||||||
|
description: |
|
||||||
|
ezBookkeeping demo site: https://ezbookkeeping-demo.mayswind.net/
|
||||||
|
options:
|
||||||
|
- "No"
|
||||||
|
- "Yes"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: additional-context
|
||||||
|
attributes:
|
||||||
|
label: Additional information
|
||||||
|
description: If you can, provide any related screenshots or logs here.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
name: Feature Request
|
||||||
|
description: Request a feature or enhancement for ezBookkeeping
|
||||||
|
labels: enhancement
|
||||||
|
body:
|
||||||
|
- type: checkboxes
|
||||||
|
id: checkboxes
|
||||||
|
attributes:
|
||||||
|
label: Before You Submit
|
||||||
|
description: Please check whether the following items have been completed.
|
||||||
|
options:
|
||||||
|
- label: I've already checked this request hasn't been raised in [issues](https://github.com/mayswind/ezbookkeeping/issues)
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: description
|
||||||
|
attributes:
|
||||||
|
label: Feature Description
|
||||||
|
description: Please describe your feature request.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: additional-context
|
||||||
|
attributes:
|
||||||
|
label: Additional information
|
||||||
|
description: If you can, provide any other context or screenshots about this feature request here.
|
||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
name: Docker Snapshot
|
name: Build for Non-Main Branches
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -6,7 +6,7 @@ on:
|
|||||||
- main
|
- main
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build-linux-docker:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
name: Build Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*.*.*"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-linux-docker:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
image-tag: ${{ steps.meta.outputs.version }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Docker meta
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: |
|
||||||
|
${{ secrets.DOCKER_USERNAME }}/ezbookkeeping
|
||||||
|
tags: |
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=raw,value=latest
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
with:
|
||||||
|
image: tonistiigi/binfmt:qemu-v8.1.5
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to DockerHub
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
file: Dockerfile
|
||||||
|
context: .
|
||||||
|
platforms: |
|
||||||
|
linux/amd64
|
||||||
|
linux/arm64/v8
|
||||||
|
linux/arm/v7
|
||||||
|
linux/arm/v6
|
||||||
|
push: true
|
||||||
|
build-args: |
|
||||||
|
RELEASE_BUILD=1
|
||||||
|
BUILD_PIPELINE=1
|
||||||
|
CHECK_3RD_API=${{ vars.CHECK_3RD_API }}
|
||||||
|
SKIP_TESTS=${{ vars.SKIP_TESTS }}
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
||||||
|
upload-linux-artifact:
|
||||||
|
needs: build-linux-docker
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- arch: linux/amd64
|
||||||
|
arch_alias: linux-amd64
|
||||||
|
- arch: linux/arm64/v8
|
||||||
|
arch_alias: linux-arm64
|
||||||
|
- arch: linux/arm/v7
|
||||||
|
arch_alias: linux-armv7
|
||||||
|
- arch: linux/arm/v6
|
||||||
|
arch_alias: linux-armv6
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Login to DockerHub
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Pull and save packaged files for ${{ matrix.arch }}
|
||||||
|
run: |
|
||||||
|
VERSION=${{ needs.build-linux-docker.outputs.image-tag }}
|
||||||
|
IMAGE=${{ secrets.DOCKER_USERNAME }}/ezbookkeeping:${VERSION}
|
||||||
|
docker pull --platform ${{ matrix.arch }} ${IMAGE}
|
||||||
|
cid=$(docker create "${IMAGE}")
|
||||||
|
docker cp ${cid}:/ezbookkeeping ./ezbookkeeping
|
||||||
|
docker rm ${cid}
|
||||||
|
cd ezbookkeeping
|
||||||
|
tar -czf ../ezbookkeeping-v${VERSION}-${{ matrix.arch_alias }}.tar.gz *
|
||||||
|
cd ..
|
||||||
|
rm -rf ezbookkeeping
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ezbookkeeping-${{ github.ref_name }}-${{ matrix.arch_alias }}
|
||||||
|
path: ezbookkeeping-${{ github.ref_name }}-${{ matrix.arch_alias }}.tar.gz
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
build-and-upload-windows-package:
|
||||||
|
needs: upload-linux-artifact
|
||||||
|
runs-on: windows-latest
|
||||||
|
env:
|
||||||
|
GO_VERSION: "1.25.1"
|
||||||
|
MINGW_VERSION: "14.2.0"
|
||||||
|
MINGW_REVISION: "v12-rev2"
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Download linux-amd64 packaged files
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ezbookkeeping-${{ github.ref_name }}-linux-amd64
|
||||||
|
path: artifacts
|
||||||
|
|
||||||
|
- name: Extract frontend files from linux-amd64 package
|
||||||
|
run: |
|
||||||
|
New-Item -ItemType Directory -Path package
|
||||||
|
tar -xzf (Get-ChildItem artifacts\ezbookkeeping-${{ github.ref_name }}-linux-amd64.tar.gz) -C package
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v6
|
||||||
|
with:
|
||||||
|
go-version: ${{ env.GO_VERSION }}
|
||||||
|
|
||||||
|
- name: Install MinGW
|
||||||
|
run: |
|
||||||
|
$mingwVersion = "${{ env.MINGW_VERSION }}"
|
||||||
|
$mingwRevision = "${{ env.MINGW_REVISION }}"
|
||||||
|
$url = "https://github.com/niXman/mingw-builds-binaries/releases/download/${mingwVersion}-rt_${mingwRevision}/x86_64-${mingwVersion}-release-posix-seh-ucrt-rt_${mingwRevision}.7z"
|
||||||
|
$archive = "C:\mingw.7z"
|
||||||
|
$mingwDir = "C:\mingw64"
|
||||||
|
|
||||||
|
Write-Host "Downloading MinGW from ${url}"
|
||||||
|
Invoke-WebRequest -Uri ${url} -OutFile ${archive}
|
||||||
|
|
||||||
|
Remove-Item -Recurse -Force ${mingwDir}
|
||||||
|
New-Item -ItemType Directory -Path ${mingwDir}
|
||||||
|
|
||||||
|
Write-Host "Extracting MinGW to ${mingwDir}"
|
||||||
|
7z x ${archive} -oC:\
|
||||||
|
"${mingwDir}\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||||
|
|
||||||
|
- name: Build backend for windows-x64
|
||||||
|
env:
|
||||||
|
RELEASE_BUILD: "1"
|
||||||
|
BUILD_PIPELINE: "1"
|
||||||
|
CHECK_3RD_API: ${{ vars.CHECK_3RD_API }}
|
||||||
|
SKIP_TESTS: ${{ vars.SKIP_TESTS }}
|
||||||
|
run: |
|
||||||
|
.\build.ps1 backend
|
||||||
|
|
||||||
|
- name: Package Windows build
|
||||||
|
run: |
|
||||||
|
New-Item -ItemType Directory -Path "ezbookkeeping"
|
||||||
|
New-Item -ItemType Directory -Path "ezbookkeeping\data"
|
||||||
|
New-Item -ItemType Directory -Path "ezbookkeeping\storage"
|
||||||
|
New-Item -ItemType Directory -Path "ezbookkeeping\log"
|
||||||
|
Copy-Item ezbookkeeping.exe -Destination ezbookkeeping\
|
||||||
|
Copy-Item -Recurse -Force package\public -Destination ezbookkeeping\public
|
||||||
|
Copy-Item -Recurse -Force conf -Destination ezbookkeeping\conf
|
||||||
|
Copy-Item -Recurse -Force templates -Destination ezbookkeeping\templates
|
||||||
|
Copy-Item .\LICENSE -Destination ezbookkeeping\
|
||||||
|
Push-Location ezbookkeeping
|
||||||
|
7z a -r -tzip -mx9 ..\ezbookkeeping-${{ github.ref_name }}-windows-x64.zip *
|
||||||
|
Pop-Location
|
||||||
|
Remove-Item -Recurse -Force ezbookkeeping
|
||||||
|
|
||||||
|
- name: Upload Windows artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ezbookkeeping-${{ github.ref_name }}-windows-x64
|
||||||
|
path: ezbookkeeping-${{ github.ref_name }}-windows-x64.zip
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
publish-release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs:
|
||||||
|
- upload-linux-artifact
|
||||||
|
- build-and-upload-windows-package
|
||||||
|
steps:
|
||||||
|
- name: Download linux-amd64 packaged files
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ezbookkeeping-${{ github.ref_name }}-linux-amd64
|
||||||
|
path: ./release-files
|
||||||
|
|
||||||
|
- name: Download linux-arm64 packaged files
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ezbookkeeping-${{ github.ref_name }}-linux-arm64
|
||||||
|
path: ./release-files
|
||||||
|
|
||||||
|
- name: Download linux-armv6 packaged files
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ezbookkeeping-${{ github.ref_name }}-linux-armv6
|
||||||
|
path: ./release-files
|
||||||
|
|
||||||
|
- name: Download linux-armv7 packaged files
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ezbookkeeping-${{ github.ref_name }}-linux-armv7
|
||||||
|
path: ./release-files
|
||||||
|
|
||||||
|
- name: Download windows-x64 packaged files
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ezbookkeeping-${{ github.ref_name }}-windows-x64
|
||||||
|
path: ./release-files
|
||||||
|
|
||||||
|
- name: Publish Release ${{ github.ref_name }}
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
name: ${{ github.ref_name }}
|
||||||
|
tag_name: ${{ github.ref_name }}
|
||||||
|
files: ./release-files/*
|
||||||
|
draft: true
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
name: Build Snapshot
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-linux-docker:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
image-tag: ${{ steps.meta.outputs.version }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Docker meta
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: |
|
||||||
|
${{ secrets.DOCKER_USERNAME }}/ezbookkeeping
|
||||||
|
tags: |
|
||||||
|
type=raw,value=SNAPSHOT-{{date 'YYYYMMDD'}}.${{ github.run_id }}
|
||||||
|
type=raw,value=SNAPSHOT-{{date 'YYYYMMDD'}}
|
||||||
|
type=raw,value=latest-snapshot
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
with:
|
||||||
|
image: tonistiigi/binfmt:qemu-v8.1.5
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to DockerHub
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
file: Dockerfile
|
||||||
|
context: .
|
||||||
|
platforms: |
|
||||||
|
linux/amd64
|
||||||
|
linux/arm64/v8
|
||||||
|
linux/arm/v7
|
||||||
|
linux/arm/v6
|
||||||
|
push: true
|
||||||
|
build-args: |
|
||||||
|
BUILD_PIPELINE=1
|
||||||
|
CHECK_3RD_API=${{ vars.CHECK_3RD_API }}
|
||||||
|
SKIP_TESTS=${{ vars.SKIP_TESTS }}
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
||||||
|
upload-linux-artifact:
|
||||||
|
needs: build-linux-docker
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- arch: linux/amd64
|
||||||
|
arch_alias: linux-amd64
|
||||||
|
- arch: linux/arm64/v8
|
||||||
|
arch_alias: linux-arm64
|
||||||
|
- arch: linux/arm/v7
|
||||||
|
arch_alias: linux-armv7
|
||||||
|
- arch: linux/arm/v6
|
||||||
|
arch_alias: linux-armv6
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Login to DockerHub
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Pull and save packaged files for ${{ matrix.arch }}
|
||||||
|
run: |
|
||||||
|
TAG=${{ needs.build-linux-docker.outputs.image-tag }}
|
||||||
|
IMAGE=${{ secrets.DOCKER_USERNAME }}/ezbookkeeping:${TAG}
|
||||||
|
docker pull --platform ${{ matrix.arch }} ${IMAGE}
|
||||||
|
cid=$(docker create "${IMAGE}")
|
||||||
|
docker cp ${cid}:/ezbookkeeping ./ezbookkeeping
|
||||||
|
docker rm ${cid}
|
||||||
|
cd ezbookkeeping
|
||||||
|
tar -czf ../ezbookkeeping-dev-${{ github.run_id }}-${{ matrix.arch_alias }}.tar.gz *
|
||||||
|
cd ..
|
||||||
|
rm -rf ezbookkeeping
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ezbookkeeping-dev-${{ github.run_id }}-${{ matrix.arch_alias }}
|
||||||
|
path: ezbookkeeping-dev-${{ github.run_id }}-${{ matrix.arch_alias }}.tar.gz
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
build-and-upload-windows-package:
|
||||||
|
needs: upload-linux-artifact
|
||||||
|
runs-on: windows-latest
|
||||||
|
env:
|
||||||
|
GO_VERSION: "1.25.1"
|
||||||
|
MINGW_VERSION: "14.2.0"
|
||||||
|
MINGW_REVISION: "v12-rev2"
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Download linux-amd64 packaged files
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ezbookkeeping-dev-${{ github.run_id }}-linux-amd64
|
||||||
|
path: artifacts
|
||||||
|
|
||||||
|
- name: Extract frontend files from linux-amd64 package
|
||||||
|
run: |
|
||||||
|
New-Item -ItemType Directory -Path package
|
||||||
|
tar -xzf (Get-ChildItem artifacts\ezbookkeeping-dev-${{ github.run_id }}-linux-amd64.tar.gz) -C package
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v6
|
||||||
|
with:
|
||||||
|
go-version: ${{ env.GO_VERSION }}
|
||||||
|
|
||||||
|
- name: Install MinGW
|
||||||
|
run: |
|
||||||
|
$mingwVersion = "${{ env.MINGW_VERSION }}"
|
||||||
|
$mingwRevision = "${{ env.MINGW_REVISION }}"
|
||||||
|
$url = "https://github.com/niXman/mingw-builds-binaries/releases/download/${mingwVersion}-rt_${mingwRevision}/x86_64-${mingwVersion}-release-posix-seh-ucrt-rt_${mingwRevision}.7z"
|
||||||
|
$archive = "C:\mingw.7z"
|
||||||
|
$mingwDir = "C:\mingw64"
|
||||||
|
|
||||||
|
Write-Host "Downloading MinGW from ${url}"
|
||||||
|
Invoke-WebRequest -Uri ${url} -OutFile ${archive}
|
||||||
|
|
||||||
|
Remove-Item -Recurse -Force ${mingwDir}
|
||||||
|
New-Item -ItemType Directory -Path ${mingwDir}
|
||||||
|
|
||||||
|
Write-Host "Extracting MinGW to ${mingwDir}"
|
||||||
|
7z x ${archive} -oC:\
|
||||||
|
"${mingwDir}\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||||
|
|
||||||
|
- name: Build backend for windows-x64
|
||||||
|
env:
|
||||||
|
BUILD_PIPELINE: "1"
|
||||||
|
CHECK_3RD_API: ${{ vars.CHECK_3RD_API }}
|
||||||
|
SKIP_TESTS: ${{ vars.SKIP_TESTS }}
|
||||||
|
run: |
|
||||||
|
.\build.ps1 backend
|
||||||
|
|
||||||
|
- name: Package Windows build
|
||||||
|
run: |
|
||||||
|
New-Item -ItemType Directory -Path "ezbookkeeping"
|
||||||
|
New-Item -ItemType Directory -Path "ezbookkeeping\data"
|
||||||
|
New-Item -ItemType Directory -Path "ezbookkeeping\storage"
|
||||||
|
New-Item -ItemType Directory -Path "ezbookkeeping\log"
|
||||||
|
Copy-Item ezbookkeeping.exe -Destination ezbookkeeping\
|
||||||
|
Copy-Item -Recurse -Force package\public -Destination ezbookkeeping\public
|
||||||
|
Copy-Item -Recurse -Force conf -Destination ezbookkeeping\conf
|
||||||
|
Copy-Item -Recurse -Force templates -Destination ezbookkeeping\templates
|
||||||
|
Copy-Item .\LICENSE -Destination ezbookkeeping\
|
||||||
|
Push-Location ezbookkeeping
|
||||||
|
7z a -r -tzip -mx9 ..\ezbookkeeping-dev-${{ github.run_id }}-windows-x64.zip *
|
||||||
|
Pop-Location
|
||||||
|
Remove-Item -Recurse -Force ezbookkeeping
|
||||||
|
|
||||||
|
- name: Upload Windows artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ezbookkeeping-dev-${{ github.run_id }}-windows-x64
|
||||||
|
path: ezbookkeeping-dev-${{ github.run_id }}-windows-x64.zip
|
||||||
|
if-no-files-found: error
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
name: Docker Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- v*
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Docker meta
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
|
||||||
images: |
|
|
||||||
${{ secrets.DOCKER_USERNAME }}/ezbookkeeping
|
|
||||||
tags: |
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
|
||||||
type=raw,value=latest
|
|
||||||
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v3
|
|
||||||
with:
|
|
||||||
image: tonistiigi/binfmt:qemu-v8.1.5
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Login to DockerHub
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
|
||||||
|
|
||||||
- name: Build and push
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
file: Dockerfile
|
|
||||||
context: .
|
|
||||||
platforms: |
|
|
||||||
linux/amd64
|
|
||||||
linux/arm64/v8
|
|
||||||
linux/arm/v7
|
|
||||||
linux/arm/v6
|
|
||||||
push: true
|
|
||||||
build-args: |
|
|
||||||
RELEASE_BUILD=1
|
|
||||||
BUILD_PIPELINE=1
|
|
||||||
CHECK_3RD_API=${{ vars.CHECK_3RD_API }}
|
|
||||||
SKIP_TESTS=${{ vars.SKIP_TESTS }}
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
name: Docker Snapshot
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Docker meta
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
|
||||||
images: |
|
|
||||||
${{ secrets.DOCKER_USERNAME }}/ezbookkeeping
|
|
||||||
tags: |
|
|
||||||
type=raw,value=SNAPSHOT-{{date 'YYYYMMDD'}}
|
|
||||||
type=raw,value=latest-snapshot
|
|
||||||
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v3
|
|
||||||
with:
|
|
||||||
image: tonistiigi/binfmt:qemu-v8.1.5
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Login to DockerHub
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
|
||||||
|
|
||||||
- name: Build and push
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
file: Dockerfile
|
|
||||||
context: .
|
|
||||||
platforms: |
|
|
||||||
linux/amd64
|
|
||||||
linux/arm64/v8
|
|
||||||
linux/arm/v7
|
|
||||||
linux/arm/v6
|
|
||||||
push: true
|
|
||||||
build-args: |
|
|
||||||
BUILD_PIPELINE=1
|
|
||||||
CHECK_3RD_API=${{ vars.CHECK_3RD_API }}
|
|
||||||
SKIP_TESTS=${{ vars.SKIP_TESTS }}
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
+3
-3
@@ -1,5 +1,5 @@
|
|||||||
# Build backend binary file
|
# Build backend binary file
|
||||||
FROM golang:1.24.4-alpine3.22 AS be-builder
|
FROM golang:1.25.1-alpine3.22 AS be-builder
|
||||||
ARG RELEASE_BUILD
|
ARG RELEASE_BUILD
|
||||||
ARG BUILD_PIPELINE
|
ARG BUILD_PIPELINE
|
||||||
ARG CHECK_3RD_API
|
ARG CHECK_3RD_API
|
||||||
@@ -15,7 +15,7 @@ RUN apk add git gcc g++ libc-dev
|
|||||||
RUN ./build.sh backend
|
RUN ./build.sh backend
|
||||||
|
|
||||||
# Build frontend files
|
# Build frontend files
|
||||||
FROM --platform=$BUILDPLATFORM node:22.16.0-alpine3.22 AS fe-builder
|
FROM --platform=$BUILDPLATFORM node:24.7.0-alpine3.22 AS fe-builder
|
||||||
ARG RELEASE_BUILD
|
ARG RELEASE_BUILD
|
||||||
ARG BUILD_PIPELINE
|
ARG BUILD_PIPELINE
|
||||||
ENV RELEASE_BUILD=$RELEASE_BUILD
|
ENV RELEASE_BUILD=$RELEASE_BUILD
|
||||||
@@ -27,7 +27,7 @@ RUN apk add git
|
|||||||
RUN ./build.sh frontend
|
RUN ./build.sh frontend
|
||||||
|
|
||||||
# Package docker image
|
# Package docker image
|
||||||
FROM alpine:3.22.0
|
FROM alpine:3.22.1
|
||||||
LABEL maintainer="MaysWind <i@mayswind.net>"
|
LABEL maintainer="MaysWind <i@mayswind.net>"
|
||||||
RUN addgroup -S -g 1000 ezbookkeeping && adduser -S -G ezbookkeeping -u 1000 ezbookkeeping
|
RUN addgroup -S -g 1000 ezbookkeeping && adduser -S -G ezbookkeeping -u 1000 ezbookkeeping
|
||||||
RUN apk --no-cache add tzdata
|
RUN apk --no-cache add tzdata
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
# ezBookkeeping
|
# ezBookkeeping
|
||||||
[](https://github.com/mayswind/ezbookkeeping/blob/master/LICENSE)
|
[](https://github.com/mayswind/ezbookkeeping/blob/master/LICENSE)
|
||||||
[](https://github.com/mayswind/ezbookkeeping/actions)
|
|
||||||
[](https://goreportcard.com/report/github.com/mayswind/ezbookkeeping)
|
[](https://goreportcard.com/report/github.com/mayswind/ezbookkeeping)
|
||||||
[](https://hub.docker.com/r/mayswind/ezbookkeeping)
|
|
||||||
[](https://github.com/mayswind/ezbookkeeping/releases)
|
[](https://github.com/mayswind/ezbookkeeping/releases)
|
||||||
|
[](https://github.com/mayswind/ezbookkeeping/actions)
|
||||||
|
[](https://hub.docker.com/r/mayswind/ezbookkeeping)
|
||||||
|
[](https://hub.docker.com/r/mayswind/ezbookkeeping)
|
||||||
|
[](https://deepwiki.com/mayswind/ezbookkeeping)
|
||||||
|
|
||||||
|
[](https://hellogithub.com/en/repository/mayswind/ezbookkeeping)
|
||||||
|
[](https://trendshift.io/repositories/12917)
|
||||||
|
|
||||||
## Introduction
|
## Introduction
|
||||||
ezBookkeeping is a lightweight, self-hosted personal finance app with a sleek, user-friendly interface and powerful bookkeeping features. Built with simplicity and portability in mind, it's easy to deploy, easy to use, and requires minimal system resources — perfect for microservers, NAS devices, and even Raspberry Pi.
|
ezBookkeeping is a lightweight, self-hosted personal finance app with a user-friendly interface and powerful bookkeeping features. It's easy to deploy, and you can start it with just one single Docker command. Designed to be resource-efficient and highly scalable, it can run smoothly on devices as small as a Raspberry Pi, or scale up to NAS, MicroServers, and even large cluster environments.
|
||||||
|
|
||||||
The app is fully cross-platform and device-friendly — you can use it seamlessly on **mobile, tablet, and desktop devices**. With support for PWA (Progressive Web Apps), you can even [add it to your mobile home screen](https://raw.githubusercontent.com/wiki/mayswind/ezbookkeeping/img/mobile/add_to_home_screen.gif) and use it like a native app.
|
ezBookkeeping offers tailored interfaces for both mobile and desktop devices. With support for PWA (Progressive Web Apps), you can even [add it to your mobile home screen](https://raw.githubusercontent.com/wiki/mayswind/ezbookkeeping/img/mobile/add_to_home_screen.gif) and use it like a native app.
|
||||||
|
|
||||||
Live Demo: [https://ezbookkeeping-demo.mayswind.net](https://ezbookkeeping-demo.mayswind.net)
|
Live Demo: [https://ezbookkeeping-demo.mayswind.net](https://ezbookkeeping-demo.mayswind.net)
|
||||||
|
|
||||||
@@ -27,6 +32,7 @@ Live Demo: [https://ezbookkeeping-demo.mayswind.net](https://ezbookkeeping-demo.
|
|||||||
- PWA support for native-like mobile experience
|
- PWA support for native-like mobile experience
|
||||||
- Dark mode
|
- Dark mode
|
||||||
- **AI-Powered Features**
|
- **AI-Powered Features**
|
||||||
|
- Receipt image recognition
|
||||||
- Supports MCP (Model Context Protocol) for AI integration
|
- Supports MCP (Model Context Protocol) for AI integration
|
||||||
- **Powerful Bookkeeping**
|
- **Powerful Bookkeeping**
|
||||||
- Two-level accounts and categories
|
- Two-level accounts and categories
|
||||||
@@ -91,6 +97,10 @@ All the files will be packaged in `ezbookkeeping.tar.gz`.
|
|||||||
|
|
||||||
> .\build.bat package -o ezbookkeeping.zip
|
> .\build.bat package -o ezbookkeeping.zip
|
||||||
|
|
||||||
|
or
|
||||||
|
|
||||||
|
PS > .\build.ps1 package -Output ezbookkeeping.zip
|
||||||
|
|
||||||
All the files will be packaged in `ezbookkeeping.zip`.
|
All the files will be packaged in `ezbookkeeping.zip`.
|
||||||
|
|
||||||
You can also build a Docker image. Make sure you have [Docker](https://www.docker.com/) installed, then follow these steps:
|
You can also build a Docker image. Make sure you have [Docker](https://www.docker.com/) installed, then follow these steps:
|
||||||
@@ -100,7 +110,7 @@ You can also build a Docker image. Make sure you have [Docker](https://www.docke
|
|||||||
$ ./build.sh docker
|
$ ./build.sh docker
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
We welcome contributions of all kinds!
|
We welcome contributions of all kinds.
|
||||||
|
|
||||||
Found a bug? [Submit an issue](https://github.com/mayswind/ezbookkeeping/issues)
|
Found a bug? [Submit an issue](https://github.com/mayswind/ezbookkeeping/issues)
|
||||||
|
|
||||||
@@ -108,10 +118,10 @@ Want to contribute code? Feel free to fork and send a pull request.
|
|||||||
|
|
||||||
Contributions of all kinds — bug reports, feature suggestions, documentation improvements, or code — are highly appreciated.
|
Contributions of all kinds — bug reports, feature suggestions, documentation improvements, or code — are highly appreciated.
|
||||||
|
|
||||||
Check out our [Contributor Graph](https://github.com/mayswind/ezbookkeeping/graphs/contributors) to see the amazing people who’ve already helped.
|
Check out our [Contributor Graph](https://github.com/mayswind/ezbookkeeping/graphs/contributors) to see the amazing people who've already helped.
|
||||||
|
|
||||||
## Translating
|
## Translating
|
||||||
Help make ezBookkeeping accessible to users around the world! If you want to contribute a translation, please refer to our [translation guide](https://ezbookkeeping.mayswind.net/translating).
|
Help make ezBookkeeping accessible to users around the world. If you want to contribute a translation, please refer to our [translation guide](https://ezbookkeeping.mayswind.net/translating).
|
||||||
|
|
||||||
Currently available translations:
|
Currently available translations:
|
||||||
|
|
||||||
@@ -120,16 +130,19 @@ Currently available translations:
|
|||||||
| de | Deutsch | [@chrgm](https://github.com/chrgm) |
|
| de | Deutsch | [@chrgm](https://github.com/chrgm) |
|
||||||
| en | English | / |
|
| en | English | / |
|
||||||
| es | Español | [@Miguelonlonlon](https://github.com/Miguelonlonlon) |
|
| es | Español | [@Miguelonlonlon](https://github.com/Miguelonlonlon) |
|
||||||
|
| fr | Français | [@brieucdlf](https://github.com/brieucdlf) |
|
||||||
| it | Italiano | [@waron97](https://github.com/waron97) |
|
| it | Italiano | [@waron97](https://github.com/waron97) |
|
||||||
| ja | 日本語 | [@tkymmm](https://github.com/tkymmm) |
|
| ja | 日本語 | [@tkymmm](https://github.com/tkymmm) |
|
||||||
|
| nl | Nederlands | [@automagic](https://github.com/automagics) |
|
||||||
| pt-BR | Português (Brasil) | [@thecodergus](https://github.com/thecodergus) |
|
| pt-BR | Português (Brasil) | [@thecodergus](https://github.com/thecodergus) |
|
||||||
| ru | Русский | [@artegoser](https://github.com/artegoser) |
|
| ru | Русский | [@artegoser](https://github.com/artegoser) |
|
||||||
|
| th | ไทย | [@natthavat28](https://github.com/natthavat28) |
|
||||||
| uk | Українська | [@nktlitvinenko](https://github.com/nktlitvinenko) |
|
| uk | Українська | [@nktlitvinenko](https://github.com/nktlitvinenko) |
|
||||||
| vi | Tiếng Việt | [@f97](https://github.com/f97) |
|
| vi | Tiếng Việt | [@f97](https://github.com/f97) |
|
||||||
| zh-Hans | 中文 (简体) | / |
|
| zh-Hans | 中文 (简体) | / |
|
||||||
| zh-Hant | 中文 (繁體) | / |
|
| zh-Hant | 中文 (繁體) | / |
|
||||||
|
|
||||||
Don't see your language? Help us add it!
|
Don't see your language? Help us add it.
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
1. [English](http://ezbookkeeping.mayswind.net)
|
1. [English](http://ezbookkeeping.mayswind.net)
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ goto :pre_parse_args
|
|||||||
set VERSION=%VERSION: =%
|
set VERSION=%VERSION: =%
|
||||||
set VERSION=%VERSION:,=%
|
set VERSION=%VERSION:,=%
|
||||||
set VERSION=%VERSION:"=%
|
set VERSION=%VERSION:"=%
|
||||||
for /f %%x in ('git rev-parse --short HEAD') do set "COMMIT_HASH=%%x"
|
for /f %%x in ('git rev-parse --short^=7 HEAD') do set "COMMIT_HASH=%%x"
|
||||||
call :set_unixtime BUILD_UNIXTIME
|
call :set_unixtime BUILD_UNIXTIME
|
||||||
call :set_date BUILD_DATE
|
call :set_date BUILD_DATE
|
||||||
|
|
||||||
@@ -261,7 +261,7 @@ goto :pre_parse_args
|
|||||||
goto :end
|
goto :end
|
||||||
)
|
)
|
||||||
|
|
||||||
call 7z a -r -tzip -mx9 ..\%package_file_name% package *
|
call 7z a -r -tzip -mx9 ..\%package_file_name% *
|
||||||
|
|
||||||
cd ..
|
cd ..
|
||||||
endlocal
|
endlocal
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
param(
|
||||||
|
[string]$Type,
|
||||||
|
[switch]$NoLint,
|
||||||
|
[switch]$NoTest,
|
||||||
|
[string]$Output,
|
||||||
|
[switch]$Release,
|
||||||
|
[switch]$Help
|
||||||
|
)
|
||||||
|
|
||||||
|
$script:SkipTests = $env:SKIP_TESTS
|
||||||
|
$script:ReleaseType = "unknown"
|
||||||
|
$script:Version = ""
|
||||||
|
$script:CommitHash = ""
|
||||||
|
$script:BuildUnixTime = ""
|
||||||
|
$script:BuildDate = ""
|
||||||
|
|
||||||
|
function Write-Red($msg) {
|
||||||
|
Write-Host $msg -ForegroundColor Red
|
||||||
|
}
|
||||||
|
|
||||||
|
function Check-Dependency {
|
||||||
|
param([string[]]$commands)
|
||||||
|
foreach ($cmd in $commands) {
|
||||||
|
if (-not (Get-Command $cmd -ErrorAction SilentlyContinue)) {
|
||||||
|
Write-Red "Error: `"$cmd`" is required."
|
||||||
|
exit 127
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Show-Help {
|
||||||
|
Write-Host "ezBookkeeping build script for Windows PowerShell"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Usage:"
|
||||||
|
Write-Host " build.ps1 type [options]"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Types:"
|
||||||
|
Write-Host " backend Build backend binary file"
|
||||||
|
Write-Host " frontend Build frontend files"
|
||||||
|
Write-Host " package Build package archive"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Options:"
|
||||||
|
Write-Host " -Release Build release (The script will use environment variable `"RELEASE_BUILD`" to detect whether this is release building by default)"
|
||||||
|
Write-Host " -Output <filename> Package file name (for `"package`" type only)"
|
||||||
|
Write-Host " -NoLint Do not execute lint check before building"
|
||||||
|
Write-Host " -NoTest Do not execute unit testing before building (You can use environment variable `"SKIP_TESTS`" to skip specified tests)"
|
||||||
|
Write-Host " -Help Show help"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Parse-Args {
|
||||||
|
if (-not $Type) {
|
||||||
|
Show-Help
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Release -or $env:RELEASE_BUILD) {
|
||||||
|
$script:ReleaseType = "release"
|
||||||
|
} else {
|
||||||
|
$script:ReleaseType = "snapshot"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Check-Type-Dependencies {
|
||||||
|
Check-Dependency "git"
|
||||||
|
|
||||||
|
switch ($Type.ToLower()) {
|
||||||
|
"backend" {
|
||||||
|
Check-Dependency "go","gcc"
|
||||||
|
}
|
||||||
|
"frontend" {
|
||||||
|
Check-Dependency "node","npm"
|
||||||
|
}
|
||||||
|
"package" {
|
||||||
|
Check-Dependency "go","gcc","node","npm","7z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Set-Build-Parameters {
|
||||||
|
$script:Version = (Get-Content package.json | ConvertFrom-Json).version
|
||||||
|
$script:CommitHash = git rev-parse --short=7 HEAD
|
||||||
|
$script:BuildUnixTime = [int][double]::Parse((Get-Date -UFormat %s))
|
||||||
|
$script:BuildDate = Get-Date -Format "yyyyMMdd"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Build-Backend {
|
||||||
|
Write-Host "Pulling backend dependencies..."
|
||||||
|
go get .
|
||||||
|
|
||||||
|
if (-not $NoLint) {
|
||||||
|
Write-Host "Executing backend lint checking..."
|
||||||
|
go vet -v .\...
|
||||||
|
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Red "Error: Failed to pass lint checking"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $NoTest) {
|
||||||
|
Write-Host "Executing backend unit testing..."
|
||||||
|
go clean -cache
|
||||||
|
|
||||||
|
if (-not $SkipTests) {
|
||||||
|
go test .\... -v
|
||||||
|
} else {
|
||||||
|
Write-Host "(Skip unit test `"$SkipTests`")"
|
||||||
|
go test .\... -v -skip "$SkipTests"
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Red "Error: Failed to pass unit testing"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$backend_build_extra_arguments = "-X main.Version=$Version "
|
||||||
|
$backend_build_extra_arguments = "$backend_build_extra_arguments -X main.CommitHash=$CommitHash"
|
||||||
|
|
||||||
|
if (-not $Release) {
|
||||||
|
$backend_build_extra_arguments += " -X main.BuildUnixTime=$BuildUnixTime"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Building backend binary file ($ReleaseType)..."
|
||||||
|
|
||||||
|
$env:CGO_ENABLED = 1
|
||||||
|
go build -a -v -trimpath -tags timetzdata -ldflags "-w -s -linkmode external -extldflags '-static' $backend_build_extra_arguments" -o ezbookkeeping.exe ezbookkeeping.go
|
||||||
|
|
||||||
|
Remove-Item Env:\CGO_ENABLED -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
|
||||||
|
function Build-Frontend {
|
||||||
|
Write-Host "Pulling frontend dependencies..."
|
||||||
|
npm install
|
||||||
|
|
||||||
|
if (-not $NoLint) {
|
||||||
|
Write-Host "Executing frontend lint checking..."
|
||||||
|
npm run lint
|
||||||
|
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Red "Error: Failed to pass lint checking"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $NoTest) {
|
||||||
|
Write-Host "Executing frontend unit testing..."
|
||||||
|
|
||||||
|
npm run test
|
||||||
|
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Red "Error: Failed to pass unit testing"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Building frontend files ($ReleaseType)..."
|
||||||
|
|
||||||
|
if (-not $Release) {
|
||||||
|
$env:buildUnixTime = $BuildUnixTime
|
||||||
|
npm run build
|
||||||
|
Remove-Item Env:\buildUnixTime -ErrorAction SilentlyContinue
|
||||||
|
} else {
|
||||||
|
npm run build
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Build-Package {
|
||||||
|
$packageFileName = "ezbookkeeping-$Version"
|
||||||
|
|
||||||
|
if (-not $Release) {
|
||||||
|
$packageFileName = "$packageFileName-$BuildDate"
|
||||||
|
}
|
||||||
|
|
||||||
|
$packageFileName = "$packageFileName-windows.zip"
|
||||||
|
|
||||||
|
if ($Output) {
|
||||||
|
$packageFileName = $Output
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Building package archive '$packageFileName' ($ReleaseType)..."
|
||||||
|
|
||||||
|
Build-Backend
|
||||||
|
Build-Frontend
|
||||||
|
|
||||||
|
Remove-Item package -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
New-Item -ItemType Directory -Path "package"
|
||||||
|
New-Item -ItemType Directory -Path "package\data"
|
||||||
|
New-Item -ItemType Directory -Path "package\storage"
|
||||||
|
New-Item -ItemType Directory -Path "package\log"
|
||||||
|
|
||||||
|
Copy-Item ezbookkeeping.exe package\
|
||||||
|
Copy-Item dist package\public -Recurse
|
||||||
|
Copy-Item conf package\conf -Recurse
|
||||||
|
Copy-Item templates package\templates -Recurse
|
||||||
|
Copy-Item LICENSE package\
|
||||||
|
|
||||||
|
Push-Location package
|
||||||
|
7z a -r -tzip -mx9 "..\$packageFileName" *
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
|
|
||||||
|
function Main {
|
||||||
|
if ($Help) {
|
||||||
|
Show-Help
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Parse-Args
|
||||||
|
Check-Type-Dependencies
|
||||||
|
Set-Build-Parameters
|
||||||
|
|
||||||
|
switch ($Type) {
|
||||||
|
"backend" {
|
||||||
|
Build-Backend
|
||||||
|
}
|
||||||
|
"frontend" {
|
||||||
|
Build-Frontend
|
||||||
|
}
|
||||||
|
"package" {
|
||||||
|
Build-Package
|
||||||
|
}
|
||||||
|
default {
|
||||||
|
Write-Red "Invalid type: $Type"
|
||||||
|
Show-Help
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Main
|
||||||
@@ -117,7 +117,7 @@ check_type_dependencies() {
|
|||||||
|
|
||||||
set_build_parameters() {
|
set_build_parameters() {
|
||||||
VERSION="$(grep '"version": ' package.json | awk -F ':' '{print $2}' | tr -d ' ' | tr -d ',' | tr -d '"')"
|
VERSION="$(grep '"version": ' package.json | awk -F ':' '{print $2}' | tr -d ' ' | tr -d ',' | tr -d '"')"
|
||||||
COMMIT_HASH="$(git rev-parse --short HEAD)"
|
COMMIT_HASH="$(git rev-parse --short=7 HEAD)"
|
||||||
BUILD_UNIXTIME="$(date '+%s')"
|
BUILD_UNIXTIME="$(date '+%s')"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"github.com/mayswind/ezbookkeeping/pkg/datastore"
|
"github.com/mayswind/ezbookkeeping/pkg/datastore"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/duplicatechecker"
|
"github.com/mayswind/ezbookkeeping/pkg/duplicatechecker"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/exchangerates"
|
"github.com/mayswind/ezbookkeeping/pkg/exchangerates"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/llm"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/mail"
|
"github.com/mayswind/ezbookkeeping/pkg/mail"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||||
@@ -90,6 +91,15 @@ func initializeSystem(c *core.CliContext) (*settings.Config, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = llm.InitializeLargeLanguageModelProvider(config)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if !isDisableBootLog {
|
||||||
|
log.BootErrorf(c, "[initializer.initializeSystem] initializes large language model provider failed, because %s", err.Error())
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
err = uuid.InitializeUuidGenerator(config)
|
err = uuid.InitializeUuidGenerator(config)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -158,5 +168,15 @@ func getConfigWithoutSensitiveData(config *settings.Config) *settings.Config {
|
|||||||
clonedConfig.SecretKey = "****"
|
clonedConfig.SecretKey = "****"
|
||||||
clonedConfig.AmapApplicationSecret = "****"
|
clonedConfig.AmapApplicationSecret = "****"
|
||||||
|
|
||||||
|
if clonedConfig.WebDAVConfig != nil {
|
||||||
|
clonedConfig.WebDAVConfig.Password = "****"
|
||||||
|
}
|
||||||
|
|
||||||
|
if clonedConfig.ReceiptImageRecognitionLLMConfig != nil {
|
||||||
|
clonedConfig.ReceiptImageRecognitionLLMConfig.OpenAIAPIKey = "****"
|
||||||
|
clonedConfig.ReceiptImageRecognitionLLMConfig.OpenAICompatibleAPIKey = "****"
|
||||||
|
clonedConfig.ReceiptImageRecognitionLLMConfig.OpenRouterAPIKey = "****"
|
||||||
|
}
|
||||||
|
|
||||||
return clonedConfig
|
return clonedConfig
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-1
@@ -268,6 +268,19 @@ var UserData = &cli.Command{
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "user-session-revoke",
|
||||||
|
Usage: "Revoke the specified user session",
|
||||||
|
Action: bindAction(revokeUserToken),
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "token",
|
||||||
|
Aliases: []string{"t"},
|
||||||
|
Required: false,
|
||||||
|
Usage: "Specific token content",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Name: "user-session-clear",
|
Name: "user-session-clear",
|
||||||
Usage: "Clear user all sessions",
|
Usage: "Clear user all sessions",
|
||||||
@@ -732,6 +745,26 @@ func createNewUserToken(c *core.CliContext) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func revokeUserToken(c *core.CliContext) error {
|
||||||
|
_, err := initializeSystem(c)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
token := c.String("token")
|
||||||
|
err = clis.UserData.RevokeUserToken(c, token)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.CliErrorf(c, "[user_data.revokeUserToken] error occurs when revoking user token")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.CliInfof(c, "[user_data.revokeUserToken] the specified user token has been revoked successfully")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func clearUserTokens(c *core.CliContext) error {
|
func clearUserTokens(c *core.CliContext) error {
|
||||||
_, err := initializeSystem(c)
|
_, err := initializeSystem(c)
|
||||||
|
|
||||||
@@ -913,15 +946,18 @@ func printUserInfo(user *models.User) {
|
|||||||
fmt.Printf("[DefaultCurrency] %s\n", user.DefaultCurrency)
|
fmt.Printf("[DefaultCurrency] %s\n", user.DefaultCurrency)
|
||||||
fmt.Printf("[FirstDayOfWeek] %s (%d)\n", user.FirstDayOfWeek, user.FirstDayOfWeek)
|
fmt.Printf("[FirstDayOfWeek] %s (%d)\n", user.FirstDayOfWeek, user.FirstDayOfWeek)
|
||||||
fmt.Printf("[FiscalYearStart] %s (%d)\n", user.FiscalYearStart, user.FiscalYearStart)
|
fmt.Printf("[FiscalYearStart] %s (%d)\n", user.FiscalYearStart, user.FiscalYearStart)
|
||||||
|
fmt.Printf("[CalendarDisplayType] %s (%d)\n", user.CalendarDisplayType, user.CalendarDisplayType)
|
||||||
|
fmt.Printf("[DateDisplayType] %s (%d)\n", user.DateDisplayType, user.DateDisplayType)
|
||||||
fmt.Printf("[LongDateFormat] %s (%d)\n", user.LongDateFormat, user.LongDateFormat)
|
fmt.Printf("[LongDateFormat] %s (%d)\n", user.LongDateFormat, user.LongDateFormat)
|
||||||
fmt.Printf("[ShortDateFormat] %s (%d)\n", user.ShortDateFormat, user.ShortDateFormat)
|
fmt.Printf("[ShortDateFormat] %s (%d)\n", user.ShortDateFormat, user.ShortDateFormat)
|
||||||
fmt.Printf("[LongTimeFormat] %s (%d)\n", user.LongTimeFormat, user.LongTimeFormat)
|
fmt.Printf("[LongTimeFormat] %s (%d)\n", user.LongTimeFormat, user.LongTimeFormat)
|
||||||
fmt.Printf("[ShortTimeFormat] %s (%d)\n", user.ShortTimeFormat, user.ShortTimeFormat)
|
fmt.Printf("[ShortTimeFormat] %s (%d)\n", user.ShortTimeFormat, user.ShortTimeFormat)
|
||||||
fmt.Printf("[FiscalYearFormat] %s (%d)\n", user.FiscalYearFormat, user.FiscalYearFormat)
|
fmt.Printf("[FiscalYearFormat] %s (%d)\n", user.FiscalYearFormat, user.FiscalYearFormat)
|
||||||
|
fmt.Printf("[CurrencyDisplayType] %s (%d)\n", user.CurrencyDisplayType, user.CurrencyDisplayType)
|
||||||
|
fmt.Printf("[NumeralSystem] %s (%d)\n", user.NumeralSystem, user.NumeralSystem)
|
||||||
fmt.Printf("[DecimalSeparator] %s (%d)\n", user.DecimalSeparator, user.DecimalSeparator)
|
fmt.Printf("[DecimalSeparator] %s (%d)\n", user.DecimalSeparator, user.DecimalSeparator)
|
||||||
fmt.Printf("[DigitGroupingSymbol] %s (%d)\n", user.DigitGroupingSymbol, user.DigitGroupingSymbol)
|
fmt.Printf("[DigitGroupingSymbol] %s (%d)\n", user.DigitGroupingSymbol, user.DigitGroupingSymbol)
|
||||||
fmt.Printf("[DigitGrouping] %s (%d)\n", user.DigitGrouping, user.DigitGrouping)
|
fmt.Printf("[DigitGrouping] %s (%d)\n", user.DigitGrouping, user.DigitGrouping)
|
||||||
fmt.Printf("[CurrencyDisplayType] %s (%d)\n", user.CurrencyDisplayType, user.CurrencyDisplayType)
|
|
||||||
fmt.Printf("[CoordinateDisplayType] %s (%d)\n", user.CoordinateDisplayType, user.CoordinateDisplayType)
|
fmt.Printf("[CoordinateDisplayType] %s (%d)\n", user.CoordinateDisplayType, user.CoordinateDisplayType)
|
||||||
fmt.Printf("[ExpenseAmountColor] %s (%d)\n", user.ExpenseAmountColor, user.ExpenseAmountColor)
|
fmt.Printf("[ExpenseAmountColor] %s (%d)\n", user.ExpenseAmountColor, user.ExpenseAmountColor)
|
||||||
fmt.Printf("[IncomeAmountColor] %s (%d)\n", user.IncomeAmountColor, user.IncomeAmountColor)
|
fmt.Printf("[IncomeAmountColor] %s (%d)\n", user.IncomeAmountColor, user.IncomeAmountColor)
|
||||||
|
|||||||
+2
-2
@@ -81,13 +81,13 @@ func sendTestMail(c *core.CliContext) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if !config.EnableSMTP || mail.Container.Current == nil {
|
if !config.EnableSMTP {
|
||||||
return errs.ErrSMTPServerNotEnabled
|
return errs.ErrSMTPServerNotEnabled
|
||||||
}
|
}
|
||||||
|
|
||||||
toAddress := c.String("to")
|
toAddress := c.String("to")
|
||||||
|
|
||||||
err = mail.Container.Current.SendMail(&mail.MailMessage{
|
err = mail.Container.SendMail(&mail.MailMessage{
|
||||||
To: toAddress,
|
To: toAddress,
|
||||||
Subject: "ezBookkeeping test e-mail",
|
Subject: "ezBookkeeping test e-mail",
|
||||||
Body: "This is a test e-mail",
|
Body: "This is a test e-mail",
|
||||||
|
|||||||
+15
-5
@@ -79,7 +79,7 @@ func startWebServer(c *core.CliContext) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
serverInfo := fmt.Sprintf("current server id is %d, current instance id is %d", requestid.Container.Current.GetCurrentServerUniqId(), requestid.Container.Current.GetCurrentInstanceUniqId())
|
serverInfo := fmt.Sprintf("current server id is %d, current instance id is %d", requestid.Container.GetCurrentServerUniqId(), requestid.Container.GetCurrentInstanceUniqId())
|
||||||
uuidServerInfo := ""
|
uuidServerInfo := ""
|
||||||
if config.UuidGeneratorType == settings.InternalUuidGeneratorType {
|
if config.UuidGeneratorType == settings.InternalUuidGeneratorType {
|
||||||
uuidServerInfo = fmt.Sprintf(", current uuid server id is %d", config.UuidServerId)
|
uuidServerInfo = fmt.Sprintf(", current uuid server id is %d", config.UuidServerId)
|
||||||
@@ -323,7 +323,9 @@ func startWebServer(c *core.CliContext) error {
|
|||||||
|
|
||||||
// Data
|
// Data
|
||||||
apiV1Route.GET("/data/statistics.json", bindApi(api.DataManagements.DataStatisticsHandler))
|
apiV1Route.GET("/data/statistics.json", bindApi(api.DataManagements.DataStatisticsHandler))
|
||||||
apiV1Route.POST("/data/clear.json", bindApi(api.DataManagements.ClearDataHandler))
|
apiV1Route.POST("/data/clear/all.json", bindApi(api.DataManagements.ClearAllDataHandler))
|
||||||
|
apiV1Route.POST("/data/clear/transactions.json", bindApi(api.DataManagements.ClearAllTransactionsHandler))
|
||||||
|
apiV1Route.POST("/data/clear/transactions/by_account.json", bindApi(api.DataManagements.ClearAllTransactionsByAccountHandler))
|
||||||
|
|
||||||
if config.EnableDataExport {
|
if config.EnableDataExport {
|
||||||
apiV1Route.GET("/data/export.csv", bindCsv(api.DataManagements.ExportDataToEzbookkeepingCSVHandler))
|
apiV1Route.GET("/data/export.csv", bindCsv(api.DataManagements.ExportDataToEzbookkeepingCSVHandler))
|
||||||
@@ -344,6 +346,7 @@ func startWebServer(c *core.CliContext) error {
|
|||||||
apiV1Route.GET("/transactions/count.json", bindApi(api.Transactions.TransactionCountHandler))
|
apiV1Route.GET("/transactions/count.json", bindApi(api.Transactions.TransactionCountHandler))
|
||||||
apiV1Route.GET("/transactions/list.json", bindApi(api.Transactions.TransactionListHandler))
|
apiV1Route.GET("/transactions/list.json", bindApi(api.Transactions.TransactionListHandler))
|
||||||
apiV1Route.GET("/transactions/list/by_month.json", bindApi(api.Transactions.TransactionMonthListHandler))
|
apiV1Route.GET("/transactions/list/by_month.json", bindApi(api.Transactions.TransactionMonthListHandler))
|
||||||
|
apiV1Route.GET("/transactions/reconciliation_statements.json", bindApi(api.Transactions.TransactionReconciliationStatementHandler))
|
||||||
apiV1Route.GET("/transactions/statistics.json", bindApi(api.Transactions.TransactionStatisticsHandler))
|
apiV1Route.GET("/transactions/statistics.json", bindApi(api.Transactions.TransactionStatisticsHandler))
|
||||||
apiV1Route.GET("/transactions/statistics/trends.json", bindApi(api.Transactions.TransactionStatisticsTrendsHandler))
|
apiV1Route.GET("/transactions/statistics/trends.json", bindApi(api.Transactions.TransactionStatisticsTrendsHandler))
|
||||||
apiV1Route.GET("/transactions/amounts.json", bindApi(api.Transactions.TransactionAmountsHandler))
|
apiV1Route.GET("/transactions/amounts.json", bindApi(api.Transactions.TransactionAmountsHandler))
|
||||||
@@ -394,6 +397,13 @@ func startWebServer(c *core.CliContext) error {
|
|||||||
apiV1Route.POST("/transaction/templates/move.json", bindApi(api.TransactionTemplates.TemplateMoveHandler))
|
apiV1Route.POST("/transaction/templates/move.json", bindApi(api.TransactionTemplates.TemplateMoveHandler))
|
||||||
apiV1Route.POST("/transaction/templates/delete.json", bindApi(api.TransactionTemplates.TemplateDeleteHandler))
|
apiV1Route.POST("/transaction/templates/delete.json", bindApi(api.TransactionTemplates.TemplateDeleteHandler))
|
||||||
|
|
||||||
|
// Large Language Models
|
||||||
|
if config.ReceiptImageRecognitionLLMConfig != nil && config.ReceiptImageRecognitionLLMConfig.LLMProvider != "" {
|
||||||
|
if config.TransactionFromAIImageRecognition {
|
||||||
|
apiV1Route.POST("/llm/transactions/recognize_receipt_image.json", bindApi(api.LargeLanguageModels.RecognizeReceiptImageHandler))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Exchange Rates
|
// Exchange Rates
|
||||||
apiV1Route.GET("/exchange_rates/latest.json", bindApi(api.ExchangeRates.LatestExchangeRateHandler))
|
apiV1Route.GET("/exchange_rates/latest.json", bindApi(api.ExchangeRates.LatestExchangeRateHandler))
|
||||||
apiV1Route.POST("/exchange_rates/user_custom/update.json", bindApi(api.ExchangeRates.UserCustomExchangeRateUpdateHandler))
|
apiV1Route.POST("/exchange_rates/user_custom/update.json", bindApi(api.ExchangeRates.UserCustomExchangeRateUpdateHandler))
|
||||||
@@ -521,7 +531,7 @@ func bindCachedJs(fn core.DataHandlerFunc, store persistence.CacheStore) gin.Han
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
utils.PrintDataErrorResult(c, "text/javascript", err)
|
utils.PrintDataErrorResult(c, "text/javascript", err)
|
||||||
} else {
|
} else {
|
||||||
utils.PrintDataSuccessResult(c, "text/javascript", "", result)
|
utils.PrintDataSuccessResult(c, "text/javascript; charset=utf-8", "", result)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -534,7 +544,7 @@ func bindCsv(fn core.DataHandlerFunc) gin.HandlerFunc {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
utils.PrintDataErrorResult(c, "text/text", err)
|
utils.PrintDataErrorResult(c, "text/text", err)
|
||||||
} else {
|
} else {
|
||||||
utils.PrintDataSuccessResult(c, "text/csv", fileName, result)
|
utils.PrintDataSuccessResult(c, "text/csv; charset=utf-8", fileName, result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -547,7 +557,7 @@ func bindTsv(fn core.DataHandlerFunc) gin.HandlerFunc {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
utils.PrintDataErrorResult(c, "text/text", err)
|
utils.PrintDataErrorResult(c, "text/text", err)
|
||||||
} else {
|
} else {
|
||||||
utils.PrintDataSuccessResult(c, "text/tab-separated-values", fileName, result)
|
utils.PrintDataSuccessResult(c, "text/tab-separated-values; charset=utf-8", fileName, result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+89
-12
@@ -37,6 +37,9 @@ enable_gzip = false
|
|||||||
# Set to true to log each request and execution time
|
# Set to true to log each request and execution time
|
||||||
log_request = true
|
log_request = true
|
||||||
|
|
||||||
|
# Add X-Request-Id header to response to track user request or error, default is true
|
||||||
|
request_id_header = true
|
||||||
|
|
||||||
[mcp]
|
[mcp]
|
||||||
# Set to true to enable MCP (Model Context Protocol) server (via http / https web server) for AI/LLM access
|
# Set to true to enable MCP (Model Context Protocol) server (via http / https web server) for AI/LLM access
|
||||||
enable_mcp = false
|
enable_mcp = false
|
||||||
@@ -115,7 +118,7 @@ log_file_max_size = 104857600
|
|||||||
log_file_max_days = 7
|
log_file_max_days = 7
|
||||||
|
|
||||||
[storage]
|
[storage]
|
||||||
# Object storage type, supports "local_filesystem" and "minio" currently
|
# Object storage type, supports "local_filesystem", "minio" and "webdav" currently
|
||||||
type = local_filesystem
|
type = local_filesystem
|
||||||
|
|
||||||
# For "local_filesystem" storage only, the storage root path (relative or absolute path)
|
# For "local_filesystem" storage only, the storage root path (relative or absolute path)
|
||||||
@@ -139,6 +142,82 @@ minio_bucket = ezbookkeeping
|
|||||||
# For "minio" storage only, the root path to store files in minio
|
# For "minio" storage only, the root path to store files in minio
|
||||||
minio_root_path = /
|
minio_root_path = /
|
||||||
|
|
||||||
|
# For "webdav" storage only, the webdav url
|
||||||
|
webdav_url =
|
||||||
|
|
||||||
|
# For "webdav" storage only, the webdav username
|
||||||
|
webdav_username =
|
||||||
|
|
||||||
|
# For "webdav" storage only, the webdav password
|
||||||
|
webdav_password =
|
||||||
|
|
||||||
|
# For "webdav" storage only, the webdav root path to store files
|
||||||
|
webdav_root_path = /
|
||||||
|
|
||||||
|
# For "webdav" storage only, requesting webdav url timeout (0 - 4294967295 milliseconds)
|
||||||
|
# Set to 0 to disable timeout for requesting webdav url, default is 10000 (10 seconds)
|
||||||
|
webdav_request_timeout = 10000
|
||||||
|
|
||||||
|
# For "webdav" storage only, proxy for requesting webdav url, supports "system" (use system proxy), "none" (do not use proxy), or proxy URL which starts with "http://", "https://" or "socks5://", default is "system"
|
||||||
|
webdav_proxy = system
|
||||||
|
|
||||||
|
# For "webdav" storage only, set to true to skip tls verification when connect webdav
|
||||||
|
webdav_skip_tls_verify = false
|
||||||
|
|
||||||
|
[llm]
|
||||||
|
# Set to true to enable creating transactions from AI image recognition results, requires "llm_provider" and its related model id to be configured properly in "llm_image_recognition" section
|
||||||
|
transaction_from_ai_image_recognition = false
|
||||||
|
|
||||||
|
# Maximum allowed AI recognition picture file size (1 - 4294967295 bytes)
|
||||||
|
max_ai_recognition_picture_size = 10485760
|
||||||
|
|
||||||
|
[llm_image_recognition]
|
||||||
|
# Large Language Model (LLM) provider for receipt image recognition, supports the following types: "openai", "openai_compatible", "openrouter", "ollama", "google_ai"
|
||||||
|
llm_provider =
|
||||||
|
|
||||||
|
# For "openai" llm provider only, OpenAI API secret key, please visit https://platform.openai.com/api-keys for more information
|
||||||
|
openai_api_key =
|
||||||
|
|
||||||
|
# For "openai" llm provider only, receipt image recognition model for creating transactions from images
|
||||||
|
openai_model_id =
|
||||||
|
|
||||||
|
# For "openai_compatible" llm provider only, OpenAI compatible API base url, e.g. "https://api.openai.com/v1/"
|
||||||
|
openai_compatible_base_url =
|
||||||
|
|
||||||
|
# For "openai_compatible" llm provider only, OpenAI compatible API secret key
|
||||||
|
openai_compatible_api_key =
|
||||||
|
|
||||||
|
# For "openai_compatible" llm provider only, receipt image recognition model for creating transactions from images
|
||||||
|
openai_compatible_model_id =
|
||||||
|
|
||||||
|
# For "openrouter" llm provider only, OpenRouter API key, please visit https://openrouter.ai/settings/keys for more information
|
||||||
|
openrouter_api_key =
|
||||||
|
|
||||||
|
# For "openrouter" llm provider only, receipt image recognition model for creating transactions from images
|
||||||
|
openrouter_model_id =
|
||||||
|
|
||||||
|
# For "ollama" llm provider only, Ollama server url, e.g. "http://127.0.0.1:11434/"
|
||||||
|
ollama_server_url =
|
||||||
|
|
||||||
|
# For "ollama" llm provider only, receipt image recognition model for creating transactions from images
|
||||||
|
ollama_model_id =
|
||||||
|
|
||||||
|
# For "google_ai" llm provider only, Google AI Studio API key, please visit https://aistudio.google.com/apikey for more information
|
||||||
|
google_ai_api_key =
|
||||||
|
|
||||||
|
# For "google_ai" llm provider only, receipt image recognition model for creating transactions from images
|
||||||
|
google_ai_model_id =
|
||||||
|
|
||||||
|
# Requesting large language model api timeout (0 - 4294967295 milliseconds)
|
||||||
|
# Set to 0 to disable timeout for requesting large language model api, default is 60000 (60 seconds)
|
||||||
|
request_timeout = 60000
|
||||||
|
|
||||||
|
# Proxy for ezbookkeeping server requesting large language model api, supports "system" (use system proxy), "none" (do not use proxy), or proxy URL which starts with "http://", "https://" or "socks5://", default is "system"
|
||||||
|
proxy = system
|
||||||
|
|
||||||
|
# Set to true to skip tls verification when request large language model api
|
||||||
|
skip_tls_verify = false
|
||||||
|
|
||||||
[uuid]
|
[uuid]
|
||||||
# Uuid generator type, supports "internal" currently
|
# Uuid generator type, supports "internal" currently
|
||||||
generator_type = internal
|
generator_type = internal
|
||||||
@@ -168,9 +247,6 @@ enable_create_scheduled_transaction = true
|
|||||||
# Used for signing, you must change it to keep your user data safe before you first run ezBookkeeping
|
# Used for signing, you must change it to keep your user data safe before you first run ezBookkeeping
|
||||||
secret_key =
|
secret_key =
|
||||||
|
|
||||||
# Set to true to enable two-factor authorization
|
|
||||||
enable_two_factor = true
|
|
||||||
|
|
||||||
# Token expired seconds (60 - 4294967295), default is 2592000 (30 days)
|
# Token expired seconds (60 - 4294967295), default is 2592000 (30 days)
|
||||||
token_expired_time = 2592000
|
token_expired_time = 2592000
|
||||||
|
|
||||||
@@ -193,8 +269,15 @@ max_failures_per_ip_per_minute = 5
|
|||||||
# Maximum count of password / token check failures (0 - 4294967295) per user per minute (use the above duplicate checker), default is 5, set to 0 to disable
|
# Maximum count of password / token check failures (0 - 4294967295) per user per minute (use the above duplicate checker), default is 5, set to 0 to disable
|
||||||
max_failures_per_user_per_minute = 5
|
max_failures_per_user_per_minute = 5
|
||||||
|
|
||||||
# Add X-Request-Id header to response to track user request or error, default is true
|
[auth]
|
||||||
request_id_header = true
|
# Set to true to enable two-factor authorization
|
||||||
|
enable_two_factor = true
|
||||||
|
|
||||||
|
# Set to true to allow users to reset password
|
||||||
|
enable_forget_password = true
|
||||||
|
|
||||||
|
# Set to true to require email must be verified when use forget password
|
||||||
|
forget_password_require_email_verify = false
|
||||||
|
|
||||||
[user]
|
[user]
|
||||||
# Set to true to allow users to register account by themselves
|
# Set to true to allow users to register account by themselves
|
||||||
@@ -206,12 +289,6 @@ enable_email_verify = false
|
|||||||
# Set to true to require email must be verified when login
|
# Set to true to require email must be verified when login
|
||||||
enable_force_email_verify = false
|
enable_force_email_verify = false
|
||||||
|
|
||||||
# Set to true to allow users to reset password
|
|
||||||
enable_forget_password = true
|
|
||||||
|
|
||||||
# Set to true to require email must be verified when use forget password
|
|
||||||
forget_password_require_email_verify = false
|
|
||||||
|
|
||||||
# Set to true to allow users to upload transaction pictures
|
# Set to true to allow users to upload transaction pictures
|
||||||
enable_transaction_picture = true
|
enable_transaction_picture = true
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -32,7 +32,7 @@ func main() {
|
|||||||
|
|
||||||
cmd := &cli.Command{
|
cmd := &cli.Command{
|
||||||
Name: "ezBookkeeping",
|
Name: "ezBookkeeping",
|
||||||
Usage: "A lightweight, self-hosted personal finance app with a sleek, user-friendly interface and powerful bookkeeping features.",
|
Usage: "A lightweight, self-hosted personal finance app with a user-friendly interface and powerful bookkeeping features.",
|
||||||
Version: GetFullVersion(),
|
Version: GetFullVersion(),
|
||||||
Commands: []*cli.Command{
|
Commands: []*cli.Command{
|
||||||
cmd.WebServer,
|
cmd.WebServer,
|
||||||
|
|||||||
@@ -1,34 +1,35 @@
|
|||||||
module github.com/mayswind/ezbookkeeping
|
module github.com/mayswind/ezbookkeeping
|
||||||
|
|
||||||
go 1.24
|
go 1.25
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/boombuler/barcode v1.0.2
|
github.com/boombuler/barcode v1.1.0
|
||||||
github.com/extrame/xls v0.0.2-0.20200426124601-4a6cf263071b
|
github.com/extrame/xls v0.0.2-0.20200426124601-4a6cf263071b
|
||||||
github.com/gin-contrib/cache v1.4.0
|
github.com/gin-contrib/cache v1.4.1
|
||||||
github.com/gin-contrib/gzip v1.2.3
|
github.com/gin-contrib/gzip v1.2.3
|
||||||
github.com/gin-gonic/gin v1.10.1
|
github.com/gin-gonic/gin v1.10.1
|
||||||
github.com/go-co-op/gocron/v2 v2.16.2
|
github.com/go-co-op/gocron/v2 v2.16.5
|
||||||
github.com/go-playground/validator/v10 v10.26.0
|
github.com/go-playground/validator/v10 v10.27.0
|
||||||
github.com/go-sql-driver/mysql v1.9.2
|
github.com/go-sql-driver/mysql v1.9.3
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.2
|
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||||
|
github.com/invopop/jsonschema v0.13.0
|
||||||
github.com/lib/pq v1.10.9
|
github.com/lib/pq v1.10.9
|
||||||
github.com/mattn/go-sqlite3 v1.14.28
|
github.com/mattn/go-sqlite3 v1.14.32
|
||||||
github.com/minio/minio-go/v7 v7.0.92
|
github.com/minio/minio-go/v7 v7.0.95
|
||||||
github.com/patrickmn/go-cache v2.1.0+incompatible
|
github.com/patrickmn/go-cache v2.1.0+incompatible
|
||||||
github.com/pquerna/otp v1.5.0
|
github.com/pquerna/otp v1.5.0
|
||||||
github.com/sirupsen/logrus v1.9.3
|
github.com/sirupsen/logrus v1.9.3
|
||||||
github.com/stretchr/testify v1.10.0
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/urfave/cli/v3 v3.3.3
|
github.com/urfave/cli/v3 v3.4.1
|
||||||
github.com/wk8/go-ordered-map/v2 v2.1.8
|
github.com/wk8/go-ordered-map/v2 v2.1.8
|
||||||
github.com/xuri/excelize/v2 v2.9.0
|
github.com/xuri/excelize/v2 v2.9.0
|
||||||
golang.org/x/crypto v0.38.0
|
golang.org/x/crypto v0.41.0
|
||||||
golang.org/x/net v0.40.0
|
golang.org/x/net v0.43.0
|
||||||
golang.org/x/text v0.25.0
|
golang.org/x/text v0.28.0
|
||||||
gopkg.in/ini.v1 v1.67.0
|
gopkg.in/ini.v1 v1.67.0
|
||||||
gopkg.in/mail.v2 v2.3.1
|
gopkg.in/mail.v2 v2.3.1
|
||||||
xorm.io/builder v0.3.13
|
xorm.io/builder v0.3.13
|
||||||
xorm.io/xorm v1.3.9
|
xorm.io/xorm v1.3.10
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -36,7 +37,7 @@ require (
|
|||||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||||
github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf // indirect
|
github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf // indirect
|
||||||
github.com/buger/jsonparser v1.1.1 // indirect
|
github.com/buger/jsonparser v1.1.1 // indirect
|
||||||
github.com/bytedance/sonic v1.13.2 // indirect
|
github.com/bytedance/sonic v1.13.3 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.2.4 // indirect
|
github.com/bytedance/sonic/loader v0.2.4 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
|
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
|
||||||
@@ -57,22 +58,21 @@ require (
|
|||||||
github.com/golang/snappy v0.0.4 // indirect
|
github.com/golang/snappy v0.0.4 // indirect
|
||||||
github.com/gomodule/redigo v1.9.2 // indirect
|
github.com/gomodule/redigo v1.9.2 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/invopop/jsonschema v0.13.0 // indirect
|
|
||||||
github.com/jonboulle/clockwork v0.5.0 // indirect
|
github.com/jonboulle/clockwork v0.5.0 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/compress v1.18.0 // indirect
|
github.com/klauspost/compress v1.18.0 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.11 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
github.com/mailru/easyjson v0.7.7 // indirect
|
github.com/mailru/easyjson v0.7.7 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/memcachier/mc/v3 v3.0.3 // indirect
|
github.com/memcachier/mc/v3 v3.0.3 // indirect
|
||||||
github.com/minio/crc64nvme v1.0.1 // indirect
|
github.com/minio/crc64nvme v1.0.2 // indirect
|
||||||
github.com/minio/md5-simd v1.1.2 // indirect
|
github.com/minio/md5-simd v1.1.2 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
|
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect
|
github.com/philhofer/fwd v1.2.0 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/richardlehane/mscfb v1.0.4 // indirect
|
github.com/richardlehane/mscfb v1.0.4 // indirect
|
||||||
github.com/richardlehane/msoleps v1.0.4 // indirect
|
github.com/richardlehane/msoleps v1.0.4 // indirect
|
||||||
@@ -85,13 +85,13 @@ require (
|
|||||||
github.com/tiendc/go-deepcopy v1.6.0 // indirect
|
github.com/tiendc/go-deepcopy v1.6.0 // indirect
|
||||||
github.com/tinylib/msgp v1.3.0 // indirect
|
github.com/tinylib/msgp v1.3.0 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
github.com/ugorji/go/codec v1.2.14 // indirect
|
||||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
|
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
|
||||||
github.com/xuri/efp v0.0.1 // indirect
|
github.com/xuri/efp v0.0.1 // indirect
|
||||||
github.com/xuri/nfp v0.0.1 // indirect
|
github.com/xuri/nfp v0.0.1 // indirect
|
||||||
golang.org/x/arch v0.17.0 // indirect
|
golang.org/x/arch v0.18.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect
|
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect
|
||||||
golang.org/x/sys v0.33.0 // indirect
|
golang.org/x/sys v0.35.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.6 // indirect
|
google.golang.org/protobuf v1.36.6 // indirect
|
||||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
|||||||
@@ -4,16 +4,16 @@ gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:EXuID2Zs0p
|
|||||||
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
|
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
|
||||||
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
|
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
|
||||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||||
github.com/boombuler/barcode v1.0.2 h1:79yrbttoZrLGkL/oOI8hBrUKucwOL0oOjUgEguGMcJ4=
|
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
|
||||||
github.com/boombuler/barcode v1.0.2/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||||
github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf h1:TqhNAT4zKbTdLa62d2HDBFdvgSbIGB3eJE8HqhgiL9I=
|
github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf h1:TqhNAT4zKbTdLa62d2HDBFdvgSbIGB3eJE8HqhgiL9I=
|
||||||
github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c=
|
github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c=
|
||||||
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
|
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
|
||||||
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
||||||
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
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.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM=
|
||||||
github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
|
github.com/bytedance/sonic v1.13.3 h1:MS8gmaH16Gtirygw7jV91pDCN33NyMrPbN7qiYhEsF0=
|
||||||
github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
|
github.com/bytedance/sonic v1.13.3/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
|
||||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||||
github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
|
github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
|
||||||
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||||
@@ -42,30 +42,30 @@ github.com/extrame/xls v0.0.2-0.20200426124601-4a6cf263071b/go.mod h1:iACcgahst7
|
|||||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
|
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
|
||||||
github.com/gin-contrib/cache v1.4.0 h1:d1FUqCE2+gJQKT0vJjr7jMn1htW9+cypk5oF7aoQcmE=
|
github.com/gin-contrib/cache v1.4.1 h1:HcLwLfw7p+FasNp5VAnFbbBj9SzB4bDtswvon7wYSg4=
|
||||||
github.com/gin-contrib/cache v1.4.0/go.mod h1:6d0UAPedInkublPl/uJUB4bqwsEgJI1y5QGszhqnyxg=
|
github.com/gin-contrib/cache v1.4.1/go.mod h1:tykDV+FgItJHYEO0eCasuRYsZKPPyb4BYhAjuTlG6RM=
|
||||||
github.com/gin-contrib/gzip v1.2.3 h1:dAhT722RuEG330ce2agAs75z7yB+NKvX/ZM1r8w0u2U=
|
github.com/gin-contrib/gzip v1.2.3 h1:dAhT722RuEG330ce2agAs75z7yB+NKvX/ZM1r8w0u2U=
|
||||||
github.com/gin-contrib/gzip v1.2.3/go.mod h1:ad72i4Bzmaypk8M762gNXa2wkxxjbz0icRNnuLJ9a/c=
|
github.com/gin-contrib/gzip v1.2.3/go.mod h1:ad72i4Bzmaypk8M762gNXa2wkxxjbz0icRNnuLJ9a/c=
|
||||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
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-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||||
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
|
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
|
||||||
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||||
github.com/go-co-op/gocron/v2 v2.16.2 h1:r08P663ikXiulLT9XaabkLypL/W9MoCIbqgQoAutyX4=
|
github.com/go-co-op/gocron/v2 v2.16.5 h1:j228Jxk7bb9CF8LKR3gS+bK3rcjRUINjlVI+ZMp26Ss=
|
||||||
github.com/go-co-op/gocron/v2 v2.16.2/go.mod h1:4YTLGCCAH75A5RlQ6q+h+VacO7CgjkgP0EJ+BEOXRSI=
|
github.com/go-co-op/gocron/v2 v2.16.5/go.mod h1:zAfC/GFQ668qHxOVl/D68Jh5Ce7sDqX6TJnSQyRkRBc=
|
||||||
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
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-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
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/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 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
|
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
||||||
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||||
github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU=
|
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||||
github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
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 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||||
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
||||||
@@ -87,8 +87,8 @@ github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zt
|
|||||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
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.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
@@ -101,16 +101,16 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0
|
|||||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
|
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
|
||||||
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
github.com/memcachier/mc/v3 v3.0.3 h1:qii+lDiPKi36O4Xg+HVKwHu6Oq+Gt17b+uEiA0Drwv4=
|
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/memcachier/mc/v3 v3.0.3/go.mod h1:GzjocBahcXPxt2cmqzknrgqCOmMxiSzhVKPOe90Tpug=
|
||||||
github.com/minio/crc64nvme v1.0.1 h1:DHQPrYPdqK7jQG/Ls5CTBZWeex/2FMS3G5XGkycuFrY=
|
github.com/minio/crc64nvme v1.0.2 h1:6uO1UxGAD+kwqWWp7mBFsi5gAse66C4NXO8cmcVculg=
|
||||||
github.com/minio/crc64nvme v1.0.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
|
github.com/minio/crc64nvme v1.0.2/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
|
||||||
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
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/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||||
github.com/minio/minio-go/v7 v7.0.92 h1:jpBFWyRS3p8P/9tsRc+NuvqoFi7qAmTCFPoRFmobbVw=
|
github.com/minio/minio-go/v7 v7.0.95 h1:ywOUPg+PebTMTzn9VDsoFJy32ZuARN9zhB+K3IYEvYU=
|
||||||
github.com/minio/minio-go/v7 v7.0.92/go.mod h1:vTIc8DNcnAZIhyFsk8EB90AbPjj3j68aWIEQCiPj7d0=
|
github.com/minio/minio-go/v7 v7.0.95/go.mod h1:wOOX3uxS334vImCNRVyIDdXX9OsXDm89ToynKgqUKlo=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
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 h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
@@ -125,8 +125,8 @@ github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaR
|
|||||||
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
|
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY=
|
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||||
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||||
@@ -153,8 +153,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
|||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
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.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
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 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE=
|
||||||
github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=
|
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 h1:+f8oFmvY8Gw1iUXzPk+kz+4GpbDZPK1FhPiQRd+ypgE=
|
||||||
@@ -164,10 +164,10 @@ github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww=
|
|||||||
github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
|
github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
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/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
github.com/ugorji/go/codec v1.2.14 h1:yOQvXCBc3Ij46LRkRoh4Yd5qK6LVOgi0bYOXfb7ifjw=
|
||||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
github.com/ugorji/go/codec v1.2.14/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||||
github.com/urfave/cli/v3 v3.3.3 h1:byCBaVdIXuLPIDm5CYZRVG6NvT7tv1ECqdU4YzlEa3I=
|
github.com/urfave/cli/v3 v3.4.1 h1:1M9UOCy5bLmGnuu1yn3t3CB4rG79Rtoxuv1sPhnm6qM=
|
||||||
github.com/urfave/cli/v3 v3.3.3/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo=
|
github.com/urfave/cli/v3 v3.4.1/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo=
|
||||||
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
|
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/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/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
||||||
@@ -178,23 +178,23 @@ github.com/xuri/excelize/v2 v2.9.0/go.mod h1:uqey4QBZ9gdMeWApPLdhm9x+9o2lq4iVmji
|
|||||||
github.com/xuri/nfp v0.0.1 h1:MDamSGatIvp8uOmDP8FnmjuQpu90NzdJxo7242ANR9Q=
|
github.com/xuri/nfp v0.0.1 h1:MDamSGatIvp8uOmDP8FnmjuQpu90NzdJxo7242ANR9Q=
|
||||||
github.com/xuri/nfp v0.0.1/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
|
github.com/xuri/nfp v0.0.1/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
|
||||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||||
golang.org/x/arch v0.17.0 h1:4O3dfLzd+lQewptAHqjewQZQDyEdejz3VwgeYwkZneU=
|
golang.org/x/arch v0.18.0 h1:WN9poc33zL4AzGxqf8VtpKUnGvMi8O9lhNyBMF/85qc=
|
||||||
golang.org/x/arch v0.17.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
golang.org/x/arch v0.18.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||||
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||||
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||||
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI=
|
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI=
|
||||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
|
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||||
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
|
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
|
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
|
||||||
@@ -215,5 +215,5 @@ nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYm
|
|||||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||||
xorm.io/builder v0.3.13 h1:a3jmiVVL19psGeXx8GIurTp7p0IIgqeDmwhcR6BAOAo=
|
xorm.io/builder v0.3.13 h1:a3jmiVVL19psGeXx8GIurTp7p0IIgqeDmwhcR6BAOAo=
|
||||||
xorm.io/builder v0.3.13/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE=
|
xorm.io/builder v0.3.13/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE=
|
||||||
xorm.io/xorm v1.3.9 h1:TUovzS0ko+IQ1XnNLfs5dqK1cJl1H5uHpWbWqAQ04nU=
|
xorm.io/xorm v1.3.10 h1:yR83hTT4mKIPyA/lvWFTzS35xjLwkiYnwdw0Qupeh0o=
|
||||||
xorm.io/xorm v1.3.9/go.mod h1:LsCCffeeYp63ssk0pKumP6l96WZcHix7ChpurcLNuMw=
|
xorm.io/xorm v1.3.10/go.mod h1:Lo7hmsFF0F0GbDE7ubX5ZKa+eCf0eCuiJAUG3oI5cxQ=
|
||||||
|
|||||||
Generated
+2780
-1811
File diff suppressed because it is too large
Load Diff
+36
-29
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ezbookkeeping",
|
"name": "ezbookkeeping",
|
||||||
"version": "0.10.0",
|
"version": "1.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@@ -21,63 +21,70 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mdi/js": "^7.4.47",
|
"@mdi/js": "^7.4.47",
|
||||||
"@vuepic/vue-datepicker": "^11.0.2",
|
"@vuepic/vue-datepicker": "^11.0.2",
|
||||||
"axios": "^1.9.0",
|
"axios": "^1.11.0",
|
||||||
"cbor-js": "^0.1.0",
|
"cbor-js": "^0.1.0",
|
||||||
"clipboard": "^2.0.11",
|
"clipboard": "^2.0.11",
|
||||||
"crypto-js": "^4.2.0",
|
"crypto-js": "^4.2.0",
|
||||||
"dom7": "^4.0.6",
|
"dom7": "^4.0.6",
|
||||||
"echarts": "^5.6.0",
|
"echarts": "^5.5.1",
|
||||||
"framework7": "^8.3.4",
|
"framework7": "^8.3.4",
|
||||||
"framework7-icons": "^5.0.5",
|
"framework7-icons": "^5.0.5",
|
||||||
"framework7-vue": "^8.3.4",
|
"framework7-vue": "^8.3.4",
|
||||||
|
"jalaali-js": "^1.2.8",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"line-awesome": "^1.3.0",
|
"line-awesome": "^1.3.0",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"moment-timezone": "^0.6.0",
|
"moment-timezone": "^0.6.0",
|
||||||
"pinia": "^3.0.2",
|
"pinia": "^3.0.3",
|
||||||
"register-service-worker": "^1.7.2",
|
"register-service-worker": "^1.7.2",
|
||||||
"skeleton-elements": "^4.0.1",
|
"skeleton-elements": "^4.0.1",
|
||||||
"swiper": "^10.2.0",
|
"swiper": "^10.2.0",
|
||||||
"ua-parser-js": "^1.0.39",
|
"ua-parser-js": "^1.0.39",
|
||||||
"vue": "^3.5.16",
|
"vue": "^3.5.21",
|
||||||
"vue-echarts": "^7.0.3",
|
"vue-echarts": "^7.0.3",
|
||||||
"vue-i18n": "^11.1.5",
|
"vue-i18n": "^11.1.12",
|
||||||
"vue-router": "^4.5.1",
|
"vue-router": "^4.5.1",
|
||||||
"vue3-perfect-scrollbar": "^2.0.0",
|
"vue3-perfect-scrollbar": "^2.0.0",
|
||||||
"vuedraggable": "^4.1.0",
|
"vuedraggable": "^4.1.0",
|
||||||
"vuetify": "^3.8.7"
|
"vuetify": "^3.9.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@jest/globals": "^29.7.0",
|
"@jest/globals": "^30.1.2",
|
||||||
"@tsconfig/node22": "^22.0.2",
|
"@tsconfig/node24": "^24.0.1",
|
||||||
"@types/cbor-js": "^0.1.1",
|
"@types/cbor-js": "^0.1.1",
|
||||||
"@types/crypto-js": "^4.2.2",
|
"@types/crypto-js": "^4.2.2",
|
||||||
"@types/git-rev-sync": "^2.0.2",
|
"@types/git-rev-sync": "^2.0.2",
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jalaali-js": "^1.2.0",
|
||||||
"@types/node": "^22.15.29",
|
"@types/jest": "^30.0.0",
|
||||||
|
"@types/node": "^24.3.1",
|
||||||
"@types/ua-parser-js": "^0.7.39",
|
"@types/ua-parser-js": "^0.7.39",
|
||||||
"@vitejs/plugin-vue": "^5.2.4",
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
"@vue/eslint-config-typescript": "^14.5.0",
|
"@vue/eslint-config-typescript": "^14.6.0",
|
||||||
"@vue/tsconfig": "^0.7.0",
|
"@vue/tsconfig": "^0.8.1",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^10.0.0",
|
||||||
"eslint": "^9.28.0",
|
"eslint": "^9.35.0",
|
||||||
"eslint-plugin-vue": "^10.1.0",
|
"eslint-plugin-vue": "^10.4.0",
|
||||||
"git-rev-sync": "^3.0.2",
|
"git-rev-sync": "^3.0.2",
|
||||||
"jest": "^29.7.0",
|
"jest": "^30.1.3",
|
||||||
"postcss-preset-env": "^10.2.0",
|
"postcss-preset-env": "^10.3.1",
|
||||||
"sass": "^1.89.1",
|
"sass": "^1.92.1",
|
||||||
"ts-jest": "^29.3.4",
|
"ts-jest": "^29.4.1",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.9.2",
|
||||||
"vite": "^6.3.5",
|
"vite": "^7.1.4",
|
||||||
"vite-plugin-checker": "^0.9.3",
|
"vite-plugin-checker": "^0.10.3",
|
||||||
"vite-plugin-pwa": "^1.0.0",
|
"vite-plugin-pwa": "^1.0.3",
|
||||||
"vite-plugin-vuetify": "^2.1.1",
|
"vite-plugin-vuetify": "^2.1.2",
|
||||||
"vue-tsc": "^2.2.10"
|
"vue-tsc": "^3.0.6"
|
||||||
},
|
},
|
||||||
"browserslist": [
|
"browserslist": [
|
||||||
"> 1%",
|
"last 5 Chrome versions",
|
||||||
"last 2 versions",
|
"last 5 Firefox versions",
|
||||||
|
"last 5 Safari versions",
|
||||||
|
"last 5 Edge versions",
|
||||||
|
"last 5 ChromeAndroid versions",
|
||||||
|
"last 5 iOS versions",
|
||||||
|
"not IE <= 11",
|
||||||
"not dead"
|
"not dead"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-10
@@ -23,7 +23,7 @@ type ApiUsingConfig struct {
|
|||||||
|
|
||||||
// CurrentConfig returns the current config
|
// CurrentConfig returns the current config
|
||||||
func (a *ApiUsingConfig) CurrentConfig() *settings.Config {
|
func (a *ApiUsingConfig) CurrentConfig() *settings.Config {
|
||||||
return a.container.Current
|
return a.container.GetCurrentConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTransactionPictureInfoResponse returns the view-object of transaction picture basic info according to the transaction picture model
|
// GetTransactionPictureInfoResponse returns the view-object of transaction picture basic info according to the transaction picture model
|
||||||
@@ -53,15 +53,15 @@ func (a *ApiUsingConfig) GetAfterRegisterNotificationContent(userLanguage string
|
|||||||
language = clientLanguage
|
language = clientLanguage
|
||||||
}
|
}
|
||||||
|
|
||||||
if !a.container.Current.AfterRegisterNotification.Enabled {
|
if !a.CurrentConfig().AfterRegisterNotification.Enabled {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
if multiLanguageContent, exists := a.container.Current.AfterRegisterNotification.MultiLanguageContent[language]; exists {
|
if multiLanguageContent, exists := a.CurrentConfig().AfterRegisterNotification.MultiLanguageContent[language]; exists {
|
||||||
return multiLanguageContent
|
return multiLanguageContent
|
||||||
}
|
}
|
||||||
|
|
||||||
return a.container.Current.AfterRegisterNotification.DefaultContent
|
return a.CurrentConfig().AfterRegisterNotification.DefaultContent
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAfterLoginNotificationContent returns the notification content displayed each time users log in
|
// GetAfterLoginNotificationContent returns the notification content displayed each time users log in
|
||||||
@@ -72,15 +72,15 @@ func (a *ApiUsingConfig) GetAfterLoginNotificationContent(userLanguage string, c
|
|||||||
language = clientLanguage
|
language = clientLanguage
|
||||||
}
|
}
|
||||||
|
|
||||||
if !a.container.Current.AfterLoginNotification.Enabled {
|
if !a.CurrentConfig().AfterLoginNotification.Enabled {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
if multiLanguageContent, exists := a.container.Current.AfterLoginNotification.MultiLanguageContent[language]; exists {
|
if multiLanguageContent, exists := a.CurrentConfig().AfterLoginNotification.MultiLanguageContent[language]; exists {
|
||||||
return multiLanguageContent
|
return multiLanguageContent
|
||||||
}
|
}
|
||||||
|
|
||||||
return a.container.Current.AfterLoginNotification.DefaultContent
|
return a.CurrentConfig().AfterLoginNotification.DefaultContent
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAfterOpenNotificationContent returns the notification content displayed each time users open the app
|
// GetAfterOpenNotificationContent returns the notification content displayed each time users open the app
|
||||||
@@ -91,15 +91,15 @@ func (a *ApiUsingConfig) GetAfterOpenNotificationContent(userLanguage string, cl
|
|||||||
language = clientLanguage
|
language = clientLanguage
|
||||||
}
|
}
|
||||||
|
|
||||||
if !a.container.Current.AfterOpenNotification.Enabled {
|
if !a.CurrentConfig().AfterOpenNotification.Enabled {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
if multiLanguageContent, exists := a.container.Current.AfterOpenNotification.MultiLanguageContent[language]; exists {
|
if multiLanguageContent, exists := a.CurrentConfig().AfterOpenNotification.MultiLanguageContent[language]; exists {
|
||||||
return multiLanguageContent
|
return multiLanguageContent
|
||||||
}
|
}
|
||||||
|
|
||||||
return a.container.Current.AfterOpenNotification.DefaultContent
|
return a.CurrentConfig().AfterOpenNotification.DefaultContent
|
||||||
}
|
}
|
||||||
|
|
||||||
// ApiUsingDuplicateChecker represents an api that need to use duplicate checker
|
// ApiUsingDuplicateChecker represents an api that need to use duplicate checker
|
||||||
|
|||||||
+107
-11
@@ -15,6 +15,7 @@ import (
|
|||||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const pageCountForClearTransactions = 1000
|
||||||
const pageCountForDataExport = 1000
|
const pageCountForDataExport = 1000
|
||||||
|
|
||||||
// DataManagementsApi represents data management api
|
// DataManagementsApi represents data management api
|
||||||
@@ -124,13 +125,13 @@ func (a *DataManagementsApi) DataStatisticsHandler(c *core.WebContext) (any, *er
|
|||||||
return dataStatisticsResp, nil
|
return dataStatisticsResp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearDataHandler deletes all user data
|
// ClearAllDataHandler deletes all user data
|
||||||
func (a *DataManagementsApi) ClearDataHandler(c *core.WebContext) (any, *errs.Error) {
|
func (a *DataManagementsApi) ClearAllDataHandler(c *core.WebContext) (any, *errs.Error) {
|
||||||
var clearDataReq models.ClearDataRequest
|
var clearDataReq models.ClearDataRequest
|
||||||
err := c.ShouldBindJSON(&clearDataReq)
|
err := c.ShouldBindJSON(&clearDataReq)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warnf(c, "[data_managements.ClearDataHandler] parse request failed, because %s", err.Error())
|
log.Warnf(c, "[data_managements.ClearAllDataHandler] parse request failed, because %s", err.Error())
|
||||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,7 +140,7 @@ func (a *DataManagementsApi) ClearDataHandler(c *core.WebContext) (any, *errs.Er
|
|||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !errs.IsCustomError(err) {
|
if !errs.IsCustomError(err) {
|
||||||
log.Warnf(c, "[data_managements.ClearDataHandler] failed to get user for user \"uid:%d\", because %s", uid, err.Error())
|
log.Warnf(c, "[data_managements.ClearAllDataHandler] failed to get user for user \"uid:%d\", because %s", uid, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, errs.ErrUserNotFound
|
return nil, errs.ErrUserNotFound
|
||||||
@@ -156,39 +157,134 @@ func (a *DataManagementsApi) ClearDataHandler(c *core.WebContext) (any, *errs.Er
|
|||||||
err = a.templates.DeleteAllTemplates(c, uid)
|
err = a.templates.DeleteAllTemplates(c, uid)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf(c, "[data_managements.ClearDataHandler] failed to delete all transaction templates, because %s", err.Error())
|
log.Errorf(c, "[data_managements.ClearAllDataHandler] failed to delete all transaction templates, because %s", err.Error())
|
||||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = a.transactions.DeleteAllTransactions(c, uid)
|
err = a.transactions.DeleteAllTransactions(c, uid, true)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf(c, "[data_managements.ClearDataHandler] failed to delete all transactions, because %s", err.Error())
|
log.Errorf(c, "[data_managements.ClearAllDataHandler] failed to delete all transactions, because %s", err.Error())
|
||||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = a.categories.DeleteAllCategories(c, uid)
|
err = a.categories.DeleteAllCategories(c, uid)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf(c, "[data_managements.ClearDataHandler] failed to delete all transaction categories, because %s", err.Error())
|
log.Errorf(c, "[data_managements.ClearAllDataHandler] failed to delete all transaction categories, because %s", err.Error())
|
||||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = a.tags.DeleteAllTags(c, uid)
|
err = a.tags.DeleteAllTags(c, uid)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf(c, "[data_managements.ClearDataHandler] failed to delete all transaction tags, because %s", err.Error())
|
log.Errorf(c, "[data_managements.ClearAllDataHandler] failed to delete all transaction tags, because %s", err.Error())
|
||||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = a.userCustomExchangeRates.DeleteAllCustomExchangeRates(c, uid)
|
err = a.userCustomExchangeRates.DeleteAllCustomExchangeRates(c, uid)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf(c, "[data_managements.ClearDataHandler] failed to delete all user custom exchange rates, because %s", err.Error())
|
log.Errorf(c, "[data_managements.ClearAllDataHandler] failed to delete all user custom exchange rates, because %s", err.Error())
|
||||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Infof(c, "[data_managements.ClearDataHandler] user \"uid:%d\" has cleared all data", uid)
|
log.Infof(c, "[data_managements.ClearAllDataHandler] user \"uid:%d\" has cleared all data", uid)
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearAllTransactionsHandler deletes all transactions
|
||||||
|
func (a *DataManagementsApi) ClearAllTransactionsHandler(c *core.WebContext) (any, *errs.Error) {
|
||||||
|
var clearDataReq models.ClearDataRequest
|
||||||
|
err := c.ShouldBindJSON(&clearDataReq)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf(c, "[data_managements.ClearAllTransactionsHandler] parse request failed, because %s", err.Error())
|
||||||
|
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
uid := c.GetCurrentUid()
|
||||||
|
user, err := a.users.GetUserById(c, uid)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if !errs.IsCustomError(err) {
|
||||||
|
log.Warnf(c, "[data_managements.ClearAllTransactionsHandler] failed to get user for user \"uid:%d\", because %s", uid, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, errs.ErrUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if !a.users.IsPasswordEqualsUserPassword(clearDataReq.Password, user) {
|
||||||
|
return nil, errs.ErrUserPasswordWrong
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_CLEAR_ALL_DATA) {
|
||||||
|
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||||
|
}
|
||||||
|
|
||||||
|
err = a.transactions.DeleteAllTransactions(c, uid, false)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf(c, "[data_managements.ClearAllTransactionsHandler] failed to delete all transactions, because %s", err.Error())
|
||||||
|
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Infof(c, "[data_managements.ClearAllTransactionsHandler] user \"uid:%d\" has cleared all transactions", uid)
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearAllTransactionsByAccountHandler deletes all transactions of specified account
|
||||||
|
func (a *DataManagementsApi) ClearAllTransactionsByAccountHandler(c *core.WebContext) (any, *errs.Error) {
|
||||||
|
var clearDataReq models.ClearAccountTransactionsRequest
|
||||||
|
err := c.ShouldBindJSON(&clearDataReq)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf(c, "[data_managements.ClearAllTransactionsByAccountHandler] parse request failed, because %s", err.Error())
|
||||||
|
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
uid := c.GetCurrentUid()
|
||||||
|
user, err := a.users.GetUserById(c, uid)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if !errs.IsCustomError(err) {
|
||||||
|
log.Warnf(c, "[data_managements.ClearAllTransactionsByAccountHandler] failed to get user for user \"uid:%d\", because %s", uid, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, errs.ErrUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if !a.users.IsPasswordEqualsUserPassword(clearDataReq.Password, user) {
|
||||||
|
return nil, errs.ErrUserPasswordWrong
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_CLEAR_ALL_DATA) {
|
||||||
|
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||||
|
}
|
||||||
|
|
||||||
|
account, err := a.accounts.GetAccountByAccountId(c, uid, clearDataReq.AccountId)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf(c, "[data_managements.ClearAllTransactionsByAccountHandler] failed to get account \"id:%d\" for user \"uid:%d\", because %s", uid, clearDataReq.AccountId, err.Error())
|
||||||
|
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
if account.Hidden {
|
||||||
|
return nil, errs.ErrCannotDeleteTransactionInHiddenAccount
|
||||||
|
}
|
||||||
|
|
||||||
|
if account.Type == models.ACCOUNT_TYPE_MULTI_SUB_ACCOUNTS {
|
||||||
|
return nil, errs.ErrCannotDeleteTransactionInParentAccount
|
||||||
|
}
|
||||||
|
|
||||||
|
err = a.transactions.DeleteAllTransactionsOfAccount(c, uid, account.AccountId, pageCountForClearTransactions)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf(c, "[data_managements.ClearAllTransactionsByAccountHandler] failed to delete all transactions in account \"id:%d\", because %s", account.AccountId, err.Error())
|
||||||
|
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Infof(c, "[data_managements.ClearAllTransactionsByAccountHandler] user \"uid:%d\" has cleared all transactions in account \"id:%d\"", uid, account.AccountId)
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,13 +30,7 @@ var (
|
|||||||
|
|
||||||
// LatestExchangeRateHandler returns latest exchange rate data
|
// LatestExchangeRateHandler returns latest exchange rate data
|
||||||
func (a *ExchangeRatesApi) LatestExchangeRateHandler(c *core.WebContext) (any, *errs.Error) {
|
func (a *ExchangeRatesApi) LatestExchangeRateHandler(c *core.WebContext) (any, *errs.Error) {
|
||||||
dataSource := exchangerates.Container.Current
|
exchangeRateResponse, err := exchangerates.Container.GetLatestExchangeRates(c, c.GetCurrentUid(), a.CurrentConfig())
|
||||||
|
|
||||||
if dataSource == nil {
|
|
||||||
return nil, errs.ErrInvalidExchangeRatesDataSource
|
|
||||||
}
|
|
||||||
|
|
||||||
exchangeRateResponse, err := dataSource.GetLatestExchangeRates(c, c.GetCurrentUid(), a.container.Current)
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||||
|
|||||||
@@ -0,0 +1,374 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/llm"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/llm/data"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/services"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/templates"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LargeLanguageModelsApi represents large language models api
|
||||||
|
type LargeLanguageModelsApi struct {
|
||||||
|
ApiUsingConfig
|
||||||
|
transactionCategories *services.TransactionCategoryService
|
||||||
|
transactionTags *services.TransactionTagService
|
||||||
|
accounts *services.AccountService
|
||||||
|
users *services.UserService
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize a large language models api singleton instance
|
||||||
|
var (
|
||||||
|
LargeLanguageModels = &LargeLanguageModelsApi{
|
||||||
|
ApiUsingConfig: ApiUsingConfig{
|
||||||
|
container: settings.Container,
|
||||||
|
},
|
||||||
|
transactionCategories: services.TransactionCategories,
|
||||||
|
transactionTags: services.TransactionTags,
|
||||||
|
accounts: services.Accounts,
|
||||||
|
users: services.Users,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// RecognizeReceiptImageHandler returns the recognized receipt image result
|
||||||
|
func (a *LargeLanguageModelsApi) RecognizeReceiptImageHandler(c *core.WebContext) (any, *errs.Error) {
|
||||||
|
if a.CurrentConfig().ReceiptImageRecognitionLLMConfig == nil || a.CurrentConfig().ReceiptImageRecognitionLLMConfig.LLMProvider == "" || !a.CurrentConfig().TransactionFromAIImageRecognition {
|
||||||
|
return nil, errs.ErrLargeLanguageModelProviderNotEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
utcOffset, err := c.GetClientTimezoneOffset()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf(c, "[large_language_models.RecognizeReceiptImageHandler] cannot get client timezone offset, because %s", err.Error())
|
||||||
|
return nil, errs.ErrClientTimezoneOffsetInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
timezone := time.FixedZone("Client Timezone", int(utcOffset)*60)
|
||||||
|
uid := c.GetCurrentUid()
|
||||||
|
user, err := a.users.GetUserById(c, uid)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if !errs.IsCustomError(err) {
|
||||||
|
log.Warnf(c, "[large_language_models.RecognizeReceiptImageHandler] failed to get user for user \"uid:%d\", because %s", uid, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, errs.ErrUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_CREATE_TRANSACTION_FROM_AI_IMAGE_RECOGNITION) {
|
||||||
|
return false, errs.ErrNotPermittedToPerformThisAction
|
||||||
|
}
|
||||||
|
|
||||||
|
form, err := c.MultipartForm()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf(c, "[large_language_models.RecognizeReceiptImageHandler] failed to get multi-part form data for user \"uid:%d\", because %s", uid, err.Error())
|
||||||
|
return nil, errs.ErrParameterInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
imageFiles := form.File["image"]
|
||||||
|
|
||||||
|
if len(imageFiles) < 1 {
|
||||||
|
log.Warnf(c, "[large_language_models.RecognizeReceiptImageHandler] there is no image in request for user \"uid:%d\"", uid)
|
||||||
|
return nil, errs.ErrNoAIRecognitionImage
|
||||||
|
}
|
||||||
|
|
||||||
|
if imageFiles[0].Size < 1 {
|
||||||
|
log.Warnf(c, "[large_language_models.RecognizeReceiptImageHandler] the size of image in request is zero for user \"uid:%d\"", uid)
|
||||||
|
return nil, errs.ErrAIRecognitionImageIsEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
if imageFiles[0].Size > int64(a.CurrentConfig().MaxAIRecognitionPictureFileSize) {
|
||||||
|
log.Warnf(c, "[large_language_models.RecognizeReceiptImageHandler] the upload file size \"%d\" exceeds the maximum size \"%d\" of image for user \"uid:%d\"", imageFiles[0].Size, a.CurrentConfig().MaxAIRecognitionPictureFileSize, uid)
|
||||||
|
return nil, errs.ErrExceedMaxAIRecognitionImageFileSize
|
||||||
|
}
|
||||||
|
|
||||||
|
fileExtension := utils.GetFileNameExtension(imageFiles[0].Filename)
|
||||||
|
contentType := utils.GetImageContentType(fileExtension)
|
||||||
|
|
||||||
|
if contentType == "" {
|
||||||
|
log.Warnf(c, "[large_language_models.RecognizeReceiptImageHandler] the file extension \"%s\" of image in request is not supported for user \"uid:%d\"", fileExtension, uid)
|
||||||
|
return nil, errs.ErrImageTypeNotSupported
|
||||||
|
}
|
||||||
|
|
||||||
|
imageFile, err := imageFiles[0].Open()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf(c, "[large_language_models.RecognizeReceiptImageHandler] failed to get image file from request for user \"uid:%d\", because %s", uid, err.Error())
|
||||||
|
return nil, errs.ErrOperationFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
defer imageFile.Close()
|
||||||
|
|
||||||
|
imageData, err := io.ReadAll(imageFile)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf(c, "[large_language_models.RecognizeReceiptImageHandler] failed to read image file from request for user \"uid:%d\", because %s", uid, err.Error())
|
||||||
|
return nil, errs.ErrOperationFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
accounts, err := a.accounts.GetAllAccountsByUid(c, uid)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf(c, "[large_language_models.RecognizeReceiptImageHandler] failed to get all accounts for user \"uid:%d\", because %s", uid, err.Error())
|
||||||
|
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
accountMap := a.accounts.GetVisibleAccountNameMapByList(accounts)
|
||||||
|
accountNames := make([]string, 0, len(accounts))
|
||||||
|
|
||||||
|
for i := 0; i < len(accounts); i++ {
|
||||||
|
if accounts[i].Hidden || accounts[i].Type == models.ACCOUNT_TYPE_MULTI_SUB_ACCOUNTS {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
accountNames = append(accountNames, accounts[i].Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
categories, err := a.transactionCategories.GetAllCategoriesByUid(c, uid, 0, -1)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf(c, "[large_language_models.RecognizeReceiptImageHandler] failed to get categories for user \"uid:%d\", because %s", uid, err.Error())
|
||||||
|
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
incomeCategoryMap := make(map[string]*models.TransactionCategory)
|
||||||
|
incomeCategoryNames := make([]string, 0)
|
||||||
|
|
||||||
|
expenseCategoryMap := make(map[string]*models.TransactionCategory)
|
||||||
|
expenseCategoryNames := make([]string, 0)
|
||||||
|
|
||||||
|
transferCategoryMap := make(map[string]*models.TransactionCategory)
|
||||||
|
transferCategoryNames := make([]string, 0)
|
||||||
|
|
||||||
|
for i := 0; i < len(categories); i++ {
|
||||||
|
category := categories[i]
|
||||||
|
|
||||||
|
if category.Hidden || category.ParentCategoryId == models.LevelOneTransactionCategoryParentId {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if category.Type == models.CATEGORY_TYPE_INCOME {
|
||||||
|
incomeCategoryMap[category.Name] = category
|
||||||
|
incomeCategoryNames = append(incomeCategoryNames, category.Name)
|
||||||
|
} else if category.Type == models.CATEGORY_TYPE_EXPENSE {
|
||||||
|
expenseCategoryMap[category.Name] = category
|
||||||
|
expenseCategoryNames = append(expenseCategoryNames, category.Name)
|
||||||
|
} else if category.Type == models.CATEGORY_TYPE_TRANSFER {
|
||||||
|
transferCategoryMap[category.Name] = category
|
||||||
|
transferCategoryNames = append(transferCategoryNames, category.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tags, err := a.transactionTags.GetAllTagsByUid(c, uid)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf(c, "[large_language_models.RecognizeReceiptImageHandler] failed to get tags for user \"uid:%d\", because %s", uid, err.Error())
|
||||||
|
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
tagMap := a.transactionTags.GetVisibleTagNameMapByList(tags)
|
||||||
|
tagNames := make([]string, 0, len(tags))
|
||||||
|
|
||||||
|
for i := 0; i < len(tags); i++ {
|
||||||
|
if tags[i].Hidden {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
tagNames = append(tagNames, tags[i].Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
systemPrompt, err := templates.GetTemplate(templates.SYSTEM_PROMPT_RECEIPT_IMAGE_RECOGNITION)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
systemPromptParams := map[string]any{
|
||||||
|
"CurrentDateTime": utils.FormatUnixTimeToLongDateTime(time.Now().Unix(), timezone),
|
||||||
|
"AllExpenseCategoryNames": strings.Join(expenseCategoryNames, "\n"),
|
||||||
|
"AllIncomeCategoryNames": strings.Join(incomeCategoryNames, "\n"),
|
||||||
|
"AllTransferCategoryNames": strings.Join(transferCategoryNames, "\n"),
|
||||||
|
"AllAccountNames": strings.Join(accountNames, "\n"),
|
||||||
|
"AllTagNames": strings.Join(tagNames, "\n"),
|
||||||
|
}
|
||||||
|
|
||||||
|
var bodyBuffer bytes.Buffer
|
||||||
|
err = systemPrompt.Execute(&bodyBuffer, systemPromptParams)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
llmRequest := &data.LargeLanguageModelRequest{
|
||||||
|
Stream: false,
|
||||||
|
SystemPrompt: strings.ReplaceAll(bodyBuffer.String(), "\r\n", "\n"),
|
||||||
|
UserPrompt: imageData,
|
||||||
|
UserPromptType: data.LARGE_LANGUAGE_MODEL_REQUEST_PROMPT_TYPE_IMAGE_URL,
|
||||||
|
UserPromptContentType: contentType,
|
||||||
|
}
|
||||||
|
|
||||||
|
llmResponse, err := llm.Container.GetJsonResponseByReceiptImageRecognitionModel(c, c.GetCurrentUid(), a.CurrentConfig(), llmRequest)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
if llmResponse == nil || len(llmResponse.Content) == 0 || strings.HasPrefix(llmResponse.Content, "{}") {
|
||||||
|
return nil, errs.ErrNoTransactionInformationInImage
|
||||||
|
}
|
||||||
|
|
||||||
|
var result *models.RecognizedReceiptImageResult
|
||||||
|
|
||||||
|
if err := json.Unmarshal([]byte(llmResponse.Content), &result); err != nil {
|
||||||
|
log.Errorf(c, "[large_language_models.RecognizeReceiptImageHandler] failed to unmarshal recognized receipt image result from llm response \"%s\" for user \"uid:%d\", because %s", llmResponse.Content, uid, err.Error())
|
||||||
|
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
return a.parseRecognizedReceiptImageResponse(c, uid, utcOffset, result, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *LargeLanguageModelsApi) parseRecognizedReceiptImageResponse(c *core.WebContext, uid int64, utcOffset int16, recognizedResult *models.RecognizedReceiptImageResult, accountMap map[string]*models.Account, expenseCategoryMap map[string]*models.TransactionCategory, incomeCategoryMap map[string]*models.TransactionCategory, transferCategoryMap map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) (*models.RecognizedReceiptImageResponse, *errs.Error) {
|
||||||
|
recognizedReceiptImageResponse := &models.RecognizedReceiptImageResponse{
|
||||||
|
Type: models.TRANSACTION_TYPE_EXPENSE,
|
||||||
|
}
|
||||||
|
|
||||||
|
if recognizedResult == nil {
|
||||||
|
log.Errorf(c, "[large_language_models.parseRecognizedReceiptImageResponse] recoginzed result is null")
|
||||||
|
return nil, errs.ErrNoTransactionInformationInImage
|
||||||
|
}
|
||||||
|
|
||||||
|
if recognizedResult.Type == "income" {
|
||||||
|
recognizedReceiptImageResponse.Type = models.TRANSACTION_TYPE_INCOME
|
||||||
|
|
||||||
|
if len(recognizedResult.CategoryName) > 0 {
|
||||||
|
category, exists := incomeCategoryMap[recognizedResult.CategoryName]
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
recognizedReceiptImageResponse.CategoryId = category.CategoryId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if recognizedResult.Type == "expense" {
|
||||||
|
recognizedReceiptImageResponse.Type = models.TRANSACTION_TYPE_EXPENSE
|
||||||
|
|
||||||
|
if len(recognizedResult.CategoryName) > 0 {
|
||||||
|
category, exists := expenseCategoryMap[recognizedResult.CategoryName]
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
recognizedReceiptImageResponse.CategoryId = category.CategoryId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if recognizedResult.Type == "transfer" {
|
||||||
|
recognizedReceiptImageResponse.Type = models.TRANSACTION_TYPE_TRANSFER
|
||||||
|
|
||||||
|
if len(recognizedResult.CategoryName) > 0 {
|
||||||
|
category, exists := transferCategoryMap[recognizedResult.CategoryName]
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
recognizedReceiptImageResponse.CategoryId = category.CategoryId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if len(recognizedResult.Type) == 0 {
|
||||||
|
return nil, errs.ErrNoTransactionInformationInImage
|
||||||
|
} else {
|
||||||
|
log.Errorf(c, "[large_language_models.parseRecognizedReceiptImageResponse] recoginzed transaction type \"%s\" is invalid", recognizedResult.Type)
|
||||||
|
return nil, errs.ErrOperationFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(recognizedResult.Time) > 0 {
|
||||||
|
longDateTime := a.getLongDateTime(recognizedResult.Time)
|
||||||
|
timestamp, err := utils.ParseFromLongDateTime(longDateTime, utcOffset)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf(c, "[large_language_models.parseRecognizedReceiptImageResponse] recoginzed time \"%s\" is invalid", recognizedResult.Time)
|
||||||
|
} else {
|
||||||
|
recognizedReceiptImageResponse.Time = timestamp.Unix()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(recognizedResult.Amount) > 0 {
|
||||||
|
amount, err := utils.ParseAmount(recognizedResult.Amount)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf(c, "[large_language_models.parseRecognizedReceiptImageResponse] recoginzed amount \"%s\" is invalid", recognizedResult.Amount)
|
||||||
|
return nil, errs.ErrOperationFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
recognizedReceiptImageResponse.SourceAmount = amount
|
||||||
|
|
||||||
|
if recognizedReceiptImageResponse.Type == models.TRANSACTION_TYPE_TRANSFER && len(recognizedResult.DestinationAmount) > 0 {
|
||||||
|
destinationAmount, err := utils.ParseAmount(recognizedResult.DestinationAmount)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf(c, "[large_language_models.parseRecognizedReceiptImageResponse] recoginzed destination amount \"%s\" is invalid", recognizedResult.DestinationAmount)
|
||||||
|
return nil, errs.ErrOperationFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
recognizedReceiptImageResponse.DestinationAmount = destinationAmount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(recognizedResult.AccountName) > 0 {
|
||||||
|
account, exists := accountMap[recognizedResult.AccountName]
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
recognizedReceiptImageResponse.SourceAccountId = account.AccountId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(recognizedResult.DestinationAccountName) > 0 {
|
||||||
|
account, exists := accountMap[recognizedResult.DestinationAccountName]
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
recognizedReceiptImageResponse.DestinationAccountId = account.AccountId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(recognizedResult.TagNames) > 0 {
|
||||||
|
tagIds := make([]string, 0, len(recognizedResult.TagNames))
|
||||||
|
|
||||||
|
for i := 0; i < len(recognizedResult.TagNames); i++ {
|
||||||
|
tagName := recognizedResult.TagNames[i]
|
||||||
|
tag, exists := tagMap[tagName]
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
tagIds = append(tagIds, utils.Int64ToString(tag.TagId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
recognizedReceiptImageResponse.TagIds = tagIds
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(recognizedResult.Description) > 0 {
|
||||||
|
recognizedReceiptImageResponse.Comment = recognizedResult.Description
|
||||||
|
}
|
||||||
|
|
||||||
|
return recognizedReceiptImageResponse, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *LargeLanguageModelsApi) getLongDateTime(dateTime string) string {
|
||||||
|
if utils.IsValidLongDateTimeFormat(dateTime) {
|
||||||
|
return dateTime
|
||||||
|
}
|
||||||
|
|
||||||
|
if utils.IsValidLongDateTimeWithoutSecondFormat(dateTime) {
|
||||||
|
return dateTime + ":00"
|
||||||
|
}
|
||||||
|
|
||||||
|
if utils.IsValidLongDateFormat(dateTime) {
|
||||||
|
return dateTime + " 00:00:00"
|
||||||
|
}
|
||||||
|
|
||||||
|
return dateTime
|
||||||
|
}
|
||||||
@@ -3,8 +3,6 @@ package api
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
|
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||||
@@ -233,7 +231,7 @@ func (a *ModelContextProtocolAPI) CallToolHandler(c *core.WebContext, jsonRPCReq
|
|||||||
|
|
||||||
// PingHandler return the ping response for model context protocol
|
// PingHandler return the ping response for model context protocol
|
||||||
func (a *ModelContextProtocolAPI) PingHandler(c *core.WebContext, jsonRPCRequest *core.JSONRPCRequest) (any, *errs.Error) {
|
func (a *ModelContextProtocolAPI) PingHandler(c *core.WebContext, jsonRPCRequest *core.JSONRPCRequest) (any, *errs.Error) {
|
||||||
return gin.H{}, nil
|
return core.O{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTransactionService implements the MCPAvailableServices interface
|
// GetTransactionService implements the MCPAvailableServices interface
|
||||||
|
|||||||
@@ -47,6 +47,12 @@ func (a *ServerSettingsApi) ServerSettingsJavascriptHandler(c *core.WebContext)
|
|||||||
a.appendBooleanSetting(builder, "mcp", config.EnableMCPServer)
|
a.appendBooleanSetting(builder, "mcp", config.EnableMCPServer)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if config.ReceiptImageRecognitionLLMConfig != nil && config.ReceiptImageRecognitionLLMConfig.LLMProvider != "" {
|
||||||
|
if config.TransactionFromAIImageRecognition {
|
||||||
|
a.appendBooleanSetting(builder, "llmt", config.TransactionFromAIImageRecognition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if config.LoginPageTips.Enabled {
|
if config.LoginPageTips.Enabled {
|
||||||
a.appendMultiLanguageTipSetting(builder, "lpt", config.LoginPageTips)
|
a.appendMultiLanguageTipSetting(builder, "lpt", config.LoginPageTips)
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-1
@@ -130,7 +130,13 @@ func (a *TokensApi) TokenGenerateMCPHandler(c *core.WebContext) (any, *errs.Erro
|
|||||||
|
|
||||||
// TokenRevokeCurrentHandler revokes current token of current user
|
// TokenRevokeCurrentHandler revokes current token of current user
|
||||||
func (a *TokensApi) TokenRevokeCurrentHandler(c *core.WebContext) (any, *errs.Error) {
|
func (a *TokensApi) TokenRevokeCurrentHandler(c *core.WebContext) (any, *errs.Error) {
|
||||||
_, claims, err := a.tokens.ParseTokenByHeader(c)
|
tokenString := c.GetTokenStringFromHeader()
|
||||||
|
|
||||||
|
if tokenString == "" {
|
||||||
|
return false, errs.ErrTokenIsEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
_, claims, err := a.tokens.ParseToken(c, tokenString)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errs.Or(err, errs.NewIncompleteOrIncorrectSubmissionError(err))
|
return nil, errs.Or(err, errs.NewIncompleteOrIncorrectSubmissionError(err))
|
||||||
|
|||||||
+141
-4
@@ -22,6 +22,8 @@ import (
|
|||||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const pageCountForAccountStatement = 1000
|
||||||
|
|
||||||
// TransactionsApi represents transaction api
|
// TransactionsApi represents transaction api
|
||||||
type TransactionsApi struct {
|
type TransactionsApi struct {
|
||||||
ApiUsingConfig
|
ApiUsingConfig
|
||||||
@@ -286,6 +288,114 @@ func (a *TransactionsApi) TransactionMonthListHandler(c *core.WebContext) (any,
|
|||||||
return transactionResps, nil
|
return transactionResps, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TransactionReconciliationStatementHandler returns transaction reconciliation statement list of current user
|
||||||
|
func (a *TransactionsApi) TransactionReconciliationStatementHandler(c *core.WebContext) (any, *errs.Error) {
|
||||||
|
var reconciliationStatementRequest models.TransactionReconciliationStatementRequest
|
||||||
|
err := c.ShouldBindQuery(&reconciliationStatementRequest)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf(c, "[transactions.TransactionReconciliationStatementHandler] parse request failed, because %s", err.Error())
|
||||||
|
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
utcOffset, err := c.GetClientTimezoneOffset()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf(c, "[transactions.TransactionReconciliationStatementHandler] cannot get client timezone offset, 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.TransactionReconciliationStatementHandler] failed to get user, because %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, errs.ErrUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
account, err := a.accounts.GetAccountByAccountId(c, uid, reconciliationStatementRequest.AccountId)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf(c, "[transactions.TransactionReconciliationStatementHandler] failed to get account \"id:%d\" for user \"uid:%d\", because %s", reconciliationStatementRequest.AccountId, uid, err.Error())
|
||||||
|
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
if account.Type != models.ACCOUNT_TYPE_SINGLE_ACCOUNT {
|
||||||
|
log.Errorf(c, "[transactions.TransactionReconciliationStatementHandler] account \"id:%d\" for user \"uid:%d\" is not a single account", reconciliationStatementRequest.AccountId, uid)
|
||||||
|
return nil, errs.ErrAccountTypeInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
maxTransactionTime := int64(0)
|
||||||
|
|
||||||
|
if reconciliationStatementRequest.EndTime > 0 {
|
||||||
|
maxTransactionTime = utils.GetMaxTransactionTimeFromUnixTime(reconciliationStatementRequest.EndTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
minTransactionTime := int64(0)
|
||||||
|
|
||||||
|
if reconciliationStatementRequest.StartTime > 0 {
|
||||||
|
minTransactionTime = utils.GetMinTransactionTimeFromUnixTime(reconciliationStatementRequest.StartTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
transactionsWithAccountBalance, totalInflows, totalOutflows, openingBalance, closingBalance, err := a.transactions.GetAllTransactionsWithAccountBalanceByMaxTime(c, uid, pageCountForAccountStatement, maxTransactionTime, minTransactionTime, reconciliationStatementRequest.AccountId, account.Category)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf(c, "[transactions.TransactionReconciliationStatementHandler] failed to get transactions from \"%d\" to \"%d\" for user \"uid:%d\", because %s", reconciliationStatementRequest.StartTime, reconciliationStatementRequest.EndTime, uid, err.Error())
|
||||||
|
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
transactions := make([]*models.Transaction, len(transactionsWithAccountBalance))
|
||||||
|
transactionAccountBalanceMap := make(map[int64]*models.TransactionWithAccountBalance, len(transactionsWithAccountBalance))
|
||||||
|
|
||||||
|
for i := 0; i < len(transactionsWithAccountBalance); i++ {
|
||||||
|
transactionWithBalance := transactionsWithAccountBalance[i]
|
||||||
|
transactions[i] = transactionWithBalance.Transaction
|
||||||
|
transactionAccountBalanceMap[transactionWithBalance.TransactionId] = transactionWithBalance
|
||||||
|
transactionAccountBalanceMap[transactionWithBalance.RelatedId] = transactionWithBalance
|
||||||
|
}
|
||||||
|
|
||||||
|
transactionResult, err := a.getTransactionResponseListResult(c, user, transactions, utcOffset, false, true, true, true)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf(c, "[transactions.TransactionReconciliationStatementHandler] failed to assemble transaction result for user \"uid:%d\", because %s", uid, err.Error())
|
||||||
|
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
responseItems := make([]*models.TransactionReconciliationStatementResponseItem, len(transactionResult))
|
||||||
|
|
||||||
|
for i := 0; i < len(transactionResult); i++ {
|
||||||
|
transactionResult := transactionResult[i]
|
||||||
|
accountOpeningBalance := int64(0)
|
||||||
|
accountClosingBalance := int64(0)
|
||||||
|
|
||||||
|
if transactionWithBalance, exists := transactionAccountBalanceMap[transactionResult.Id]; exists {
|
||||||
|
accountOpeningBalance = transactionWithBalance.AccountOpeningBalance
|
||||||
|
accountClosingBalance = transactionWithBalance.AccountClosingBalance
|
||||||
|
} else {
|
||||||
|
log.Warnf(c, "[transactions.TransactionReconciliationStatementHandler] missing account balance for transaction \"id:%d\" of user \"uid:%d\"", transactionResult.Id, uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
responseItems[i] = &models.TransactionReconciliationStatementResponseItem{
|
||||||
|
TransactionInfoResponse: transactionResult,
|
||||||
|
AccountOpeningBalance: accountOpeningBalance,
|
||||||
|
AccountClosingBalance: accountClosingBalance,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reconciliationStatementResp := &models.TransactionReconciliationStatementResponse{
|
||||||
|
Transactions: responseItems,
|
||||||
|
TotalInflows: totalInflows,
|
||||||
|
TotalOutflows: totalOutflows,
|
||||||
|
OpeningBalance: openingBalance,
|
||||||
|
ClosingBalance: closingBalance,
|
||||||
|
}
|
||||||
|
|
||||||
|
return reconciliationStatementResp, nil
|
||||||
|
}
|
||||||
|
|
||||||
// TransactionStatisticsHandler returns transaction statistics of current user
|
// TransactionStatisticsHandler returns transaction statistics of current user
|
||||||
func (a *TransactionsApi) TransactionStatisticsHandler(c *core.WebContext) (any, *errs.Error) {
|
func (a *TransactionsApi) TransactionStatisticsHandler(c *core.WebContext) (any, *errs.Error) {
|
||||||
var statisticReq models.TransactionStatisticRequest
|
var statisticReq models.TransactionStatisticRequest
|
||||||
@@ -439,6 +549,25 @@ func (a *TransactionsApi) TransactionAmountsHandler(c *core.WebContext) (any, *e
|
|||||||
return nil, errs.ErrQueryItemsTooMuch
|
return nil, errs.ErrQueryItemsTooMuch
|
||||||
}
|
}
|
||||||
|
|
||||||
|
excludeAccountIds := make([]int64, 0)
|
||||||
|
excludeCategoryIds := make([]int64, 0)
|
||||||
|
|
||||||
|
if transactionAmountsReq.ExcludeAccountIds != "" {
|
||||||
|
excludeAccountIds, err = utils.StringArrayToInt64Array(strings.Split(transactionAmountsReq.ExcludeAccountIds, ","))
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, errs.ErrAccountIdInvalid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if transactionAmountsReq.ExcludeCategoryIds != "" {
|
||||||
|
excludeCategoryIds, err = utils.StringArrayToInt64Array(strings.Split(transactionAmountsReq.ExcludeCategoryIds, ","))
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, errs.ErrTransactionCategoryIdInvalid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
utcOffset, err := c.GetClientTimezoneOffset()
|
utcOffset, err := c.GetClientTimezoneOffset()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -461,7 +590,7 @@ func (a *TransactionsApi) TransactionAmountsHandler(c *core.WebContext) (any, *e
|
|||||||
for i := 0; i < len(requestItems); i++ {
|
for i := 0; i < len(requestItems); i++ {
|
||||||
requestItem := requestItems[i]
|
requestItem := requestItems[i]
|
||||||
|
|
||||||
incomeAmounts, expenseAmounts, err := a.transactions.GetAccountsTotalIncomeAndExpense(c, uid, requestItem.StartTime, requestItem.EndTime, utcOffset, transactionAmountsReq.UseTransactionTimezone)
|
incomeAmounts, expenseAmounts, err := a.transactions.GetAccountsTotalIncomeAndExpense(c, uid, requestItem.StartTime, requestItem.EndTime, excludeAccountIds, excludeCategoryIds, utcOffset, transactionAmountsReq.UseTransactionTimezone)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf(c, "[transactions.TransactionAmountsHandler] failed to get transaction amounts item for user \"uid:%d\", because %s", uid, err.Error())
|
log.Errorf(c, "[transactions.TransactionAmountsHandler] failed to get transaction amounts item for user \"uid:%d\", because %s", uid, err.Error())
|
||||||
@@ -699,7 +828,7 @@ func (a *TransactionsApi) TransactionCreateHandler(c *core.WebContext) (any, *er
|
|||||||
return nil, errs.ErrTransactionTypeInvalid
|
return nil, errs.ErrTransactionTypeInvalid
|
||||||
}
|
}
|
||||||
|
|
||||||
if transactionCreateReq.Type == models.TRANSACTION_TYPE_MODIFY_BALANCE && transactionCreateReq.CategoryId > 0 {
|
if transactionCreateReq.Type == models.TRANSACTION_TYPE_MODIFY_BALANCE && transactionCreateReq.CategoryId != 0 {
|
||||||
log.Warnf(c, "[transactions.TransactionCreateHandler] balance modification transaction cannot set category id")
|
log.Warnf(c, "[transactions.TransactionCreateHandler] balance modification transaction cannot set category id")
|
||||||
return nil, errs.ErrBalanceModificationTransactionCannotSetCategory
|
return nil, errs.ErrBalanceModificationTransactionCannotSetCategory
|
||||||
}
|
}
|
||||||
@@ -847,6 +976,14 @@ func (a *TransactionsApi) TransactionModifyHandler(c *core.WebContext) (any, *er
|
|||||||
return nil, errs.ErrTransactionTypeInvalid
|
return nil, errs.ErrTransactionTypeInvalid
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if transaction.Type == models.TRANSACTION_DB_TYPE_MODIFY_BALANCE && transactionModifyReq.CategoryId != 0 {
|
||||||
|
log.Warnf(c, "[transactions.TransactionModifyHandler] balance modification transaction cannot set category id")
|
||||||
|
return nil, errs.ErrBalanceModificationTransactionCannotSetCategory
|
||||||
|
} else if transaction.Type != models.TRANSACTION_DB_TYPE_MODIFY_BALANCE && transactionModifyReq.CategoryId == 0 {
|
||||||
|
log.Warnf(c, "[transactions.TransactionModifyHandler] non-balance modification transaction must set category id")
|
||||||
|
return nil, errs.ErrIncompleteOrIncorrectSubmission
|
||||||
|
}
|
||||||
|
|
||||||
allTransactionTagIds, err := a.transactionTags.GetAllTagIdsOfTransactions(c, uid, []int64{transaction.TransactionId})
|
allTransactionTagIds, err := a.transactionTags.GetAllTagIdsOfTransactions(c, uid, []int64{transaction.TransactionId})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1310,7 +1447,7 @@ func (a *TransactionsApi) TransactionParseImportFileHandler(c *core.WebContext)
|
|||||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||||
}
|
}
|
||||||
|
|
||||||
tagMap := a.transactionTags.GetTagNameMapByList(tags)
|
tagMap := a.transactionTags.GetVisibleTagNameMapByList(tags)
|
||||||
|
|
||||||
parsedTransactions, _, _, _, _, _, err := dataImporter.ParseImportedData(c, user, fileData, utcOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
|
parsedTransactions, _, _, _, _, _, err := dataImporter.ParseImportedData(c, user, fileData, utcOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
|
||||||
|
|
||||||
@@ -1388,7 +1525,7 @@ func (a *TransactionsApi) TransactionImportHandler(c *core.WebContext) (any, *er
|
|||||||
return nil, errs.ErrTransactionTypeInvalid
|
return nil, errs.ErrTransactionTypeInvalid
|
||||||
}
|
}
|
||||||
|
|
||||||
if transactionCreateReq.Type == models.TRANSACTION_TYPE_MODIFY_BALANCE && transactionCreateReq.CategoryId > 0 {
|
if transactionCreateReq.Type == models.TRANSACTION_TYPE_MODIFY_BALANCE && transactionCreateReq.CategoryId != 0 {
|
||||||
log.Warnf(c, "[transactions.TransactionImportHandler] balance modification transaction \"index:%d\" cannot set category id", i)
|
log.Warnf(c, "[transactions.TransactionImportHandler] balance modification transaction \"index:%d\" cannot set category id", i)
|
||||||
return nil, errs.ErrBalanceModificationTransactionCannotSetCategory
|
return nil, errs.ErrBalanceModificationTransactionCannotSetCategory
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||||
@@ -74,115 +75,129 @@ func (a *UserApplicationCloudSettingsApi) ApplicationSettingsUpdateHandler(c *co
|
|||||||
return false, errs.ErrNotPermittedToPerformThisAction
|
return false, errs.ErrNotPermittedToPerformThisAction
|
||||||
}
|
}
|
||||||
|
|
||||||
userApplicationCloudSettings, err := a.userAppCloudSettings.GetUserApplicationCloudSettingsByUid(c, uid)
|
var userApplicationCloudSettings *models.UserApplicationCloudSetting
|
||||||
|
|
||||||
if err != nil {
|
// Retry up to 3 times
|
||||||
log.Errorf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] failed to get latest user application cloud settings for user \"uid:%d\", because %s", uid, err.Error())
|
for i := 0; i < 3; i++ {
|
||||||
return false, errs.Or(err, errs.ErrOperationFailed)
|
userApplicationCloudSettings, err = a.userAppCloudSettings.GetUserApplicationCloudSettingsByUid(c, uid)
|
||||||
}
|
|
||||||
|
|
||||||
oldApplicationCloudSettingsMap := make(map[string]models.ApplicationCloudSetting)
|
if err != nil {
|
||||||
|
log.Errorf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] failed to get latest user application cloud settings for user \"uid:%d\" (try count %d), because %s", uid, i+1, err.Error())
|
||||||
if userApplicationCloudSettings != nil {
|
return false, errs.Or(err, errs.ErrOperationFailed)
|
||||||
for _, setting := range userApplicationCloudSettings.Settings {
|
|
||||||
oldApplicationCloudSettingsMap[setting.SettingKey] = setting
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the full update settings are the same as the existing settings
|
oldApplicationCloudSettingsMap := make(map[string]models.ApplicationCloudSetting)
|
||||||
if userAppCloudSettingUpdateReq.FullUpdate {
|
lastUpdateTime := int64(0)
|
||||||
if len(userAppCloudSettingUpdateReq.Settings) == len(oldApplicationCloudSettingsMap) {
|
|
||||||
needUpdate := false
|
if userApplicationCloudSettings != nil {
|
||||||
|
for _, setting := range userApplicationCloudSettings.Settings {
|
||||||
|
oldApplicationCloudSettingsMap[setting.SettingKey] = setting
|
||||||
|
}
|
||||||
|
|
||||||
|
lastUpdateTime = userApplicationCloudSettings.UpdatedUnixTime
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the full update settings are the same as the existing settings
|
||||||
|
if userAppCloudSettingUpdateReq.FullUpdate {
|
||||||
|
if len(userAppCloudSettingUpdateReq.Settings) == len(oldApplicationCloudSettingsMap) {
|
||||||
|
needUpdate := false
|
||||||
|
|
||||||
|
for _, setting := range userAppCloudSettingUpdateReq.Settings {
|
||||||
|
oldSetting, exists := oldApplicationCloudSettingsMap[setting.SettingKey]
|
||||||
|
|
||||||
|
if !exists || oldSetting.SettingValue != setting.SettingValue {
|
||||||
|
needUpdate = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !needUpdate {
|
||||||
|
return false, errs.ErrNothingWillBeUpdated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else { // Check if the partial update settings are the same as the existing settings or the settings to update are not set to sync
|
||||||
|
needUpdate := true
|
||||||
|
|
||||||
for _, setting := range userAppCloudSettingUpdateReq.Settings {
|
for _, setting := range userAppCloudSettingUpdateReq.Settings {
|
||||||
oldSetting, exists := oldApplicationCloudSettingsMap[setting.SettingKey]
|
cloudSetting, exists := oldApplicationCloudSettingsMap[setting.SettingKey]
|
||||||
|
|
||||||
if !exists || oldSetting.SettingValue != setting.SettingValue {
|
if !exists {
|
||||||
needUpdate = true
|
needUpdate = false
|
||||||
break
|
log.Infof(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" is not set to sync (try count %d)", setting.SettingKey, i+1)
|
||||||
|
} else if cloudSetting.SettingValue == setting.SettingValue {
|
||||||
|
needUpdate = false
|
||||||
|
log.Infof(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" value \"%s\" is not changed, no need to update (try count %d)", setting.SettingKey, setting.SettingValue, i+1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !needUpdate {
|
if !needUpdate {
|
||||||
return false, errs.ErrNothingWillBeUpdated
|
log.Infof(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] no user application cloud settings need to update for user \"uid:%d\" (try count %d)", uid, i+1)
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
newApplicationCloudSettingsMap := make(map[string]models.ApplicationCloudSetting)
|
||||||
|
var newApplicationCloudSettingSlice models.ApplicationCloudSettingSlice
|
||||||
|
|
||||||
|
if userAppCloudSettingUpdateReq.FullUpdate {
|
||||||
|
log.Infof(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user \"uid:%d\" application cloud settings force update, will overwrite all existing settings (try count %d)", uid, i+1)
|
||||||
|
} else {
|
||||||
|
if len(oldApplicationCloudSettingsMap) > 0 {
|
||||||
|
log.Infof(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user \"uid:%d\" application cloud settings exists, try to merge it with request settings (try count %d)", uid, i+1)
|
||||||
|
newApplicationCloudSettingsMap = oldApplicationCloudSettingsMap
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else { // Check if the partial update settings are the same as the existing settings or the settings to update are not set to sync
|
|
||||||
needUpdate := true
|
|
||||||
|
|
||||||
for _, setting := range userAppCloudSettingUpdateReq.Settings {
|
for _, setting := range userAppCloudSettingUpdateReq.Settings {
|
||||||
cloudSetting, exists := oldApplicationCloudSettingsMap[setting.SettingKey]
|
newApplicationCloudSettingsMap[setting.SettingKey] = setting
|
||||||
|
}
|
||||||
|
|
||||||
|
for settingKey, setting := range newApplicationCloudSettingsMap {
|
||||||
|
settingType, exists := models.ALL_ALLOWED_CLOUD_SYNC_APP_SETTING_KEY_TYPES[settingKey]
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
needUpdate = false
|
log.Warnf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" is not supported to sync (try count %d)", settingKey, i+1)
|
||||||
log.Infof(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" is not set to sync", setting.SettingKey)
|
|
||||||
} else if cloudSetting.SettingValue == setting.SettingValue {
|
|
||||||
needUpdate = false
|
|
||||||
log.Infof(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" value \"%s\" is not changed, no need to update", setting.SettingKey, setting.SettingValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !needUpdate {
|
|
||||||
log.Infof(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] no user application cloud settings need to update for user \"uid:%d\"", uid)
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
newApplicationCloudSettingsMap := make(map[string]models.ApplicationCloudSetting)
|
|
||||||
var newApplicationCloudSettingSlice models.ApplicationCloudSettingSlice
|
|
||||||
|
|
||||||
if userAppCloudSettingUpdateReq.FullUpdate {
|
|
||||||
log.Infof(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user \"uid:%d\" application cloud settings force update, will overwrite all existing settings", uid)
|
|
||||||
} else {
|
|
||||||
if len(oldApplicationCloudSettingsMap) > 0 {
|
|
||||||
log.Infof(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user \"uid:%d\" application cloud settings exists, try to merge it with request settings", uid)
|
|
||||||
newApplicationCloudSettingsMap = oldApplicationCloudSettingsMap
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, setting := range userAppCloudSettingUpdateReq.Settings {
|
|
||||||
newApplicationCloudSettingsMap[setting.SettingKey] = setting
|
|
||||||
}
|
|
||||||
|
|
||||||
for settingKey, setting := range newApplicationCloudSettingsMap {
|
|
||||||
settingType, exists := models.ALL_ALLOWED_CLOUD_SYNC_APP_SETTING_KEY_TYPES[settingKey]
|
|
||||||
|
|
||||||
if !exists {
|
|
||||||
log.Warnf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" is not supported to sync", settingKey)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if settingType == models.USER_APPLICATION_CLOUD_SETTING_TYPE_STRING {
|
|
||||||
// Do Nothing
|
|
||||||
} else if settingType == models.USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER {
|
|
||||||
_, err := utils.StringToFloat64(setting.SettingValue)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Warnf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" has invalid number value \"%s\"", settingKey, setting.SettingValue)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
} else if settingType == models.USER_APPLICATION_CLOUD_SETTING_TYPE_BOOLEAN {
|
|
||||||
if setting.SettingValue != "true" && setting.SettingValue != "false" {
|
|
||||||
log.Warnf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" has invalid boolean value \"%s\"", settingKey, setting.SettingValue)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
} else if settingType == models.USER_APPLICATION_CLOUD_SETTING_TYPE_STRING_BOOLEAN_MAP {
|
|
||||||
var settingValueMap map[string]bool
|
|
||||||
err := json.Unmarshal([]byte(setting.SettingValue), &settingValueMap)
|
|
||||||
|
|
||||||
if err != nil {
|
if settingType == models.USER_APPLICATION_CLOUD_SETTING_TYPE_STRING {
|
||||||
log.Warnf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" has invalid map value \"%s\", because %s", settingKey, setting.SettingValue, err.Error())
|
// Do Nothing
|
||||||
|
} else if settingType == models.USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER {
|
||||||
|
_, err := utils.StringToFloat64(setting.SettingValue)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" has invalid number value \"%s\" (try count %d)", settingKey, setting.SettingValue, i+1)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else if settingType == models.USER_APPLICATION_CLOUD_SETTING_TYPE_BOOLEAN {
|
||||||
|
if setting.SettingValue != "true" && setting.SettingValue != "false" {
|
||||||
|
log.Warnf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" has invalid boolean value \"%s\" (try count %d)", settingKey, setting.SettingValue, i+1)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else if settingType == models.USER_APPLICATION_CLOUD_SETTING_TYPE_STRING_BOOLEAN_MAP {
|
||||||
|
var settingValueMap map[string]bool
|
||||||
|
err := json.Unmarshal([]byte(setting.SettingValue), &settingValueMap)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" has invalid map value \"%s\" (try count %d), because %s", settingKey, setting.SettingValue, i+1, err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Warnf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" has unknown type \"%s\" (try count %d)", settingKey, settingType, i+1)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
log.Warnf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" has unknown type \"%s\"", settingKey, settingType)
|
newApplicationCloudSettingSlice = append(newApplicationCloudSettingSlice, setting)
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
newApplicationCloudSettingSlice = append(newApplicationCloudSettingSlice, setting)
|
err = a.userAppCloudSettings.UpdateUserApplicationCloudSettings(c, uid, newApplicationCloudSettingSlice, userAppCloudSettingUpdateReq.FullUpdate, lastUpdateTime)
|
||||||
}
|
|
||||||
|
|
||||||
err = a.userAppCloudSettings.UpdateUserApplicationCloudSettings(c, uid, newApplicationCloudSettingSlice)
|
if err == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(100 * time.Millisecond) // Wait for 100 milliseconds before retrying
|
||||||
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] failed to update user application cloud settings for user \"uid:%d\", because %s", uid, err.Error())
|
log.Errorf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] failed to update user application cloud settings for user \"uid:%d\", because %s", uid, err.Error())
|
||||||
|
|||||||
+36
-9
@@ -359,6 +359,24 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro
|
|||||||
userNew.FiscalYearStart = core.FISCAL_YEAR_START_INVALID
|
userNew.FiscalYearStart = core.FISCAL_YEAR_START_INVALID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if userUpdateReq.CalendarDisplayType != nil && *userUpdateReq.CalendarDisplayType != user.CalendarDisplayType {
|
||||||
|
user.CalendarDisplayType = *userUpdateReq.CalendarDisplayType
|
||||||
|
userNew.CalendarDisplayType = *userUpdateReq.CalendarDisplayType
|
||||||
|
modifyProfileBasicInfo = true
|
||||||
|
anythingUpdate = true
|
||||||
|
} else {
|
||||||
|
userNew.CalendarDisplayType = core.CALENDAR_DISPLAY_TYPE_INVALID
|
||||||
|
}
|
||||||
|
|
||||||
|
if userUpdateReq.DateDisplayType != nil && *userUpdateReq.DateDisplayType != user.DateDisplayType {
|
||||||
|
user.DateDisplayType = *userUpdateReq.DateDisplayType
|
||||||
|
userNew.DateDisplayType = *userUpdateReq.DateDisplayType
|
||||||
|
modifyProfileBasicInfo = true
|
||||||
|
anythingUpdate = true
|
||||||
|
} else {
|
||||||
|
userNew.DateDisplayType = core.DATE_DISPLAY_TYPE_INVALID
|
||||||
|
}
|
||||||
|
|
||||||
if userUpdateReq.LongDateFormat != nil && *userUpdateReq.LongDateFormat != user.LongDateFormat {
|
if userUpdateReq.LongDateFormat != nil && *userUpdateReq.LongDateFormat != user.LongDateFormat {
|
||||||
user.LongDateFormat = *userUpdateReq.LongDateFormat
|
user.LongDateFormat = *userUpdateReq.LongDateFormat
|
||||||
userNew.LongDateFormat = *userUpdateReq.LongDateFormat
|
userNew.LongDateFormat = *userUpdateReq.LongDateFormat
|
||||||
@@ -404,6 +422,24 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro
|
|||||||
userNew.FiscalYearFormat = core.FISCAL_YEAR_FORMAT_INVALID
|
userNew.FiscalYearFormat = core.FISCAL_YEAR_FORMAT_INVALID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if userUpdateReq.CurrencyDisplayType != nil && *userUpdateReq.CurrencyDisplayType != user.CurrencyDisplayType {
|
||||||
|
user.CurrencyDisplayType = *userUpdateReq.CurrencyDisplayType
|
||||||
|
userNew.CurrencyDisplayType = *userUpdateReq.CurrencyDisplayType
|
||||||
|
modifyProfileBasicInfo = true
|
||||||
|
anythingUpdate = true
|
||||||
|
} else {
|
||||||
|
userNew.CurrencyDisplayType = core.CURRENCY_DISPLAY_TYPE_INVALID
|
||||||
|
}
|
||||||
|
|
||||||
|
if userUpdateReq.NumeralSystem != nil && *userUpdateReq.NumeralSystem != user.NumeralSystem {
|
||||||
|
user.NumeralSystem = *userUpdateReq.NumeralSystem
|
||||||
|
userNew.NumeralSystem = *userUpdateReq.NumeralSystem
|
||||||
|
modifyProfileBasicInfo = true
|
||||||
|
anythingUpdate = true
|
||||||
|
} else {
|
||||||
|
userNew.NumeralSystem = core.NUMERAL_SYSTEM_INVALID
|
||||||
|
}
|
||||||
|
|
||||||
if userUpdateReq.DecimalSeparator != nil && *userUpdateReq.DecimalSeparator != user.DecimalSeparator {
|
if userUpdateReq.DecimalSeparator != nil && *userUpdateReq.DecimalSeparator != user.DecimalSeparator {
|
||||||
user.DecimalSeparator = *userUpdateReq.DecimalSeparator
|
user.DecimalSeparator = *userUpdateReq.DecimalSeparator
|
||||||
userNew.DecimalSeparator = *userUpdateReq.DecimalSeparator
|
userNew.DecimalSeparator = *userUpdateReq.DecimalSeparator
|
||||||
@@ -431,15 +467,6 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro
|
|||||||
userNew.DigitGrouping = core.DIGIT_GROUPING_TYPE_INVALID
|
userNew.DigitGrouping = core.DIGIT_GROUPING_TYPE_INVALID
|
||||||
}
|
}
|
||||||
|
|
||||||
if userUpdateReq.CurrencyDisplayType != nil && *userUpdateReq.CurrencyDisplayType != user.CurrencyDisplayType {
|
|
||||||
user.CurrencyDisplayType = *userUpdateReq.CurrencyDisplayType
|
|
||||||
userNew.CurrencyDisplayType = *userUpdateReq.CurrencyDisplayType
|
|
||||||
modifyProfileBasicInfo = true
|
|
||||||
anythingUpdate = true
|
|
||||||
} else {
|
|
||||||
userNew.CurrencyDisplayType = core.CURRENCY_DISPLAY_TYPE_INVALID
|
|
||||||
}
|
|
||||||
|
|
||||||
if userUpdateReq.CoordinateDisplayType != nil && *userUpdateReq.CoordinateDisplayType != user.CoordinateDisplayType {
|
if userUpdateReq.CoordinateDisplayType != nil && *userUpdateReq.CoordinateDisplayType != user.CoordinateDisplayType {
|
||||||
user.CoordinateDisplayType = *userUpdateReq.CoordinateDisplayType
|
user.CoordinateDisplayType = *userUpdateReq.CoordinateDisplayType
|
||||||
userNew.CoordinateDisplayType = *userUpdateReq.CoordinateDisplayType
|
userNew.CoordinateDisplayType = *userUpdateReq.CoordinateDisplayType
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
|
|
||||||
// AvatarProviderContainer contains the current user avatar provider
|
// AvatarProviderContainer contains the current user avatar provider
|
||||||
type AvatarProviderContainer struct {
|
type AvatarProviderContainer struct {
|
||||||
Current AvatarProvider
|
current AvatarProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize a user avatar provider container singleton instance
|
// Initialize a user avatar provider container singleton instance
|
||||||
@@ -20,13 +20,13 @@ var (
|
|||||||
// InitializeAvatarProvider initializes the current user avatar provider according to the config
|
// InitializeAvatarProvider initializes the current user avatar provider according to the config
|
||||||
func InitializeAvatarProvider(config *settings.Config) error {
|
func InitializeAvatarProvider(config *settings.Config) error {
|
||||||
if config.AvatarProvider == core.USER_AVATAR_PROVIDER_INTERNAL {
|
if config.AvatarProvider == core.USER_AVATAR_PROVIDER_INTERNAL {
|
||||||
Container.Current = NewInternalStorageAvatarProvider(config)
|
Container.current = NewInternalStorageAvatarProvider(config)
|
||||||
return nil
|
return nil
|
||||||
} else if config.AvatarProvider == core.USER_AVATAR_PROVIDER_GRAVATAR {
|
} else if config.AvatarProvider == core.USER_AVATAR_PROVIDER_GRAVATAR {
|
||||||
Container.Current = NewGravatarAvatarProvider()
|
Container.current = NewGravatarAvatarProvider()
|
||||||
return nil
|
return nil
|
||||||
} else if config.AvatarProvider == "" {
|
} else if config.AvatarProvider == "" {
|
||||||
Container.Current = NewNullAvatarProvider()
|
Container.current = NewNullAvatarProvider()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,5 +35,9 @@ func InitializeAvatarProvider(config *settings.Config) error {
|
|||||||
|
|
||||||
// GetAvatarUrl returns the avatar url by the current user avatar provider
|
// GetAvatarUrl returns the avatar url by the current user avatar provider
|
||||||
func (p *AvatarProviderContainer) GetAvatarUrl(user *models.User) string {
|
func (p *AvatarProviderContainer) GetAvatarUrl(user *models.User) string {
|
||||||
return p.Current.GetAvatarUrl(user)
|
if p.current == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.current.GetAvatarUrl(user)
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -9,5 +9,5 @@ type CliUsingConfig struct {
|
|||||||
|
|
||||||
// CurrentConfig returns the current config
|
// CurrentConfig returns the current config
|
||||||
func (l *CliUsingConfig) CurrentConfig() *settings.Config {
|
func (l *CliUsingConfig) CurrentConfig() *settings.Config {
|
||||||
return l.container.Current
|
return l.container.GetCurrentConfig()
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-1
@@ -445,6 +445,39 @@ func (l *UserDataCli) CreateNewUserToken(c *core.CliContext, username string, to
|
|||||||
return tokenRecord, token, nil
|
return tokenRecord, token, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RevokeUserToken revokes the specified token of the user
|
||||||
|
func (l *UserDataCli) RevokeUserToken(c *core.CliContext, token string) error {
|
||||||
|
_, claims, err := l.tokens.ParseToken(c, token)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.CliErrorf(c, "[user_data.RevokeUserToken] failed to parse token, because %s", err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
userTokenId, err := utils.StringToInt64(claims.UserTokenId)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.CliErrorf(c, "[user_data.RevokeUserToken] failed to get user token id, because %s", err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenRecord := &models.TokenRecord{
|
||||||
|
Uid: claims.Uid,
|
||||||
|
UserTokenId: userTokenId,
|
||||||
|
CreatedUnixTime: claims.IssuedAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenId := l.tokens.GenerateTokenId(tokenRecord)
|
||||||
|
err = l.tokens.DeleteToken(c, tokenRecord)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf(c, "[user_data.RevokeUserToken] failed to revoke token \"id:%s\" for user \"uid:%d\", because %s", tokenId, claims.Uid, err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ClearUserTokens clears all tokens of the specified user
|
// ClearUserTokens clears all tokens of the specified user
|
||||||
func (l *UserDataCli) ClearUserTokens(c *core.CliContext, username string) error {
|
func (l *UserDataCli) ClearUserTokens(c *core.CliContext, username string) error {
|
||||||
if username == "" {
|
if username == "" {
|
||||||
@@ -924,7 +957,7 @@ func (l *UserDataCli) getUserEssentialDataForImport(c *core.CliContext, uid int6
|
|||||||
return nil, nil, nil, nil, nil, err
|
return nil, nil, nil, nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
tagMap = l.tags.GetTagNameMapByList(tags)
|
tagMap = l.tags.GetVisibleTagNameMapByList(tags)
|
||||||
|
|
||||||
return accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap, nil
|
return accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,21 +2,17 @@ package alipay
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/csv"
|
|
||||||
"io"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"golang.org/x/text/encoding/simplifiedchinese"
|
"golang.org/x/text/encoding/simplifiedchinese"
|
||||||
"golang.org/x/text/transform"
|
"golang.org/x/text/transform"
|
||||||
|
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/converters/converter"
|
"github.com/mayswind/ezbookkeeping/pkg/converters/converter"
|
||||||
csvdatatable "github.com/mayswind/ezbookkeeping/pkg/converters/csv"
|
"github.com/mayswind/ezbookkeeping/pkg/converters/csv"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
|
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var alipayTransactionSupportedColumns = map[datatable.TransactionDataTableColumn]bool{
|
var alipayTransactionSupportedColumns = map[datatable.TransactionDataTableColumn]bool{
|
||||||
@@ -61,7 +57,13 @@ func (c *alipayTransactionDataCsvFileImporter) ParseImportedData(ctx core.Contex
|
|||||||
enc := simplifiedchinese.GB18030
|
enc := simplifiedchinese.GB18030
|
||||||
reader := transform.NewReader(bytes.NewReader(data), enc.NewDecoder())
|
reader := transform.NewReader(bytes.NewReader(data), enc.NewDecoder())
|
||||||
|
|
||||||
dataTable, err := c.createNewAlipayBasicDataTable(ctx, reader, c.fileHeaderLine, c.dataHeaderStartContent, c.dataBottomEndLineRune)
|
csvDataTable, err := csv.CreateNewCsvBasicDataTable(ctx, reader, false)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, nil, nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dataTable, err := createNewAlipayTransactionBasicDataTable(ctx, csvDataTable, c.fileHeaderLine, c.dataHeaderStartContent, c.dataBottomEndLineRune)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, nil, nil, nil, nil, err
|
return nil, nil, nil, nil, nil, nil, err
|
||||||
@@ -83,80 +85,3 @@ func (c *alipayTransactionDataCsvFileImporter) ParseImportedData(ctx core.Contex
|
|||||||
|
|
||||||
return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezoneOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
|
return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezoneOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *alipayTransactionDataCsvFileImporter) createNewAlipayBasicDataTable(ctx core.Context, reader io.Reader, fileHeaderLine string, dataHeaderStartContent string, dataBottomEndLineRune rune) (datatable.BasicDataTable, error) {
|
|
||||||
csvReader := csv.NewReader(reader)
|
|
||||||
csvReader.FieldsPerRecord = -1
|
|
||||||
|
|
||||||
allOriginalLines := make([][]string, 0)
|
|
||||||
hasFileHeader := false
|
|
||||||
foundContentBeforeDataHeaderLine := false
|
|
||||||
|
|
||||||
for {
|
|
||||||
items, err := csvReader.Read()
|
|
||||||
|
|
||||||
if err == io.EOF {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf(ctx, "[alipay_transaction_data_csv_file_importer.createNewAlipayBasicDataTable] cannot parse alipay csv data, because %s", err.Error())
|
|
||||||
return nil, errs.ErrInvalidCSVFile
|
|
||||||
}
|
|
||||||
|
|
||||||
if !hasFileHeader {
|
|
||||||
if len(items) <= 0 {
|
|
||||||
continue
|
|
||||||
} else if strings.Index(items[0], fileHeaderLine) == 0 {
|
|
||||||
hasFileHeader = true
|
|
||||||
continue
|
|
||||||
} else {
|
|
||||||
log.Warnf(ctx, "[alipay_transaction_data_csv_file_importer.createNewAlipayBasicDataTable] read unexpected line before read file header, line content is %s", strings.Join(items, ","))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !foundContentBeforeDataHeaderLine {
|
|
||||||
if len(items) <= 0 {
|
|
||||||
continue
|
|
||||||
} else if strings.Index(items[0], dataHeaderStartContent) >= 0 {
|
|
||||||
foundContentBeforeDataHeaderLine = true
|
|
||||||
continue
|
|
||||||
} else {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if foundContentBeforeDataHeaderLine {
|
|
||||||
if len(items) <= 0 {
|
|
||||||
continue
|
|
||||||
} else if len(items) == 1 && dataBottomEndLineRune > 0 && utils.ContainsOnlyOneRune(items[0], dataBottomEndLineRune) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := 0; i < len(items); i++ {
|
|
||||||
items[i] = strings.Trim(items[i], " ")
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(allOriginalLines) > 0 && len(items) < len(allOriginalLines[0]) {
|
|
||||||
log.Errorf(ctx, "[alipay_transaction_data_csv_file_importer.createNewAlipayBasicDataTable] cannot parse row \"index:%d\", because may missing some columns (column count %d in data row is less than header column count %d)", len(allOriginalLines), len(items), len(allOriginalLines[0]))
|
|
||||||
return nil, errs.ErrFewerFieldsInDataRowThanInHeaderRow
|
|
||||||
}
|
|
||||||
|
|
||||||
allOriginalLines = append(allOriginalLines, items)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !hasFileHeader || !foundContentBeforeDataHeaderLine {
|
|
||||||
return nil, errs.ErrInvalidFileHeader
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(allOriginalLines) < 2 {
|
|
||||||
log.Errorf(ctx, "[alipay_transaction_data_csv_file_importer.createNewAlipayBasicDataTable] cannot parse import data, because data table row count is less 1")
|
|
||||||
return nil, errs.ErrNotFoundTransactionDataInFile
|
|
||||||
}
|
|
||||||
|
|
||||||
dataTable := csvdatatable.CreateNewCustomCsvBasicDataTable(allOriginalLines)
|
|
||||||
|
|
||||||
return dataTable, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ func TestAlipayCsvFileImporterParseImportedData_ParseRefundTransaction(t *testin
|
|||||||
DefaultCurrency: "CNY",
|
DefaultCurrency: "CNY",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// refund
|
||||||
data1, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
|
data1, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
|
||||||
"账号:[xxx@xxx.xxx]\n" +
|
"账号:[xxx@xxx.xxx]\n" +
|
||||||
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
|
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
|
||||||
@@ -121,6 +122,7 @@ func TestAlipayCsvFileImporterParseImportedData_ParseRefundTransaction(t *testin
|
|||||||
assert.Equal(t, "", allNewTransactions[0].OriginalSourceAccountName)
|
assert.Equal(t, "", allNewTransactions[0].OriginalSourceAccountName)
|
||||||
assert.Equal(t, "", allNewTransactions[0].OriginalCategoryName)
|
assert.Equal(t, "", allNewTransactions[0].OriginalCategoryName)
|
||||||
|
|
||||||
|
// tax refund
|
||||||
data2, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
|
data2, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
|
||||||
"账号:[xxx@xxx.xxx]\n" +
|
"账号:[xxx@xxx.xxx]\n" +
|
||||||
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
|
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
|
||||||
@@ -141,6 +143,46 @@ func TestAlipayCsvFileImporterParseImportedData_ParseRefundTransaction(t *testin
|
|||||||
assert.Equal(t, "", allNewTransactions[0].OriginalCategoryName)
|
assert.Equal(t, "", allNewTransactions[0].OriginalCategoryName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAlipayCsvFileImporterParseImportedData_ParseInvestmentRefundTransaction(t *testing.T) {
|
||||||
|
converter := AlipayAppTransactionDataCsvFileImporter
|
||||||
|
context := core.NewNullContext()
|
||||||
|
|
||||||
|
user := &models.User{
|
||||||
|
Uid: 1234567890,
|
||||||
|
DefaultCurrency: "CNY",
|
||||||
|
}
|
||||||
|
|
||||||
|
data1, err := simplifiedchinese.GB18030.NewEncoder().String("------------------------------------------------------------------------------------\n" +
|
||||||
|
"导出信息:\n" +
|
||||||
|
"姓名:xxx\n" +
|
||||||
|
"支付宝账户:xxx@xxx.xxx\n" +
|
||||||
|
"起始时间:[2024-01-01 00:00:00] 终止时间:[2024-09-01 23:59:59]\n" +
|
||||||
|
"导出交易类型:[全部]\n" +
|
||||||
|
"------------------------支付宝(中国)网络技术有限公司 电子客户回单------------------------\n" +
|
||||||
|
"交易时间,交易对方,商品说明,收/支,金额,收/付款方式,交易状态,\n" +
|
||||||
|
"2024-09-01 01:00:00,Test Account2,xxx-买入,不计收支,0.01,Test Account,退款成功,\n" +
|
||||||
|
"2024-09-01 02:00:00,Test Account2,xxx-买入退款,不计收支,0.01,Test Account,退款成功,\n")
|
||||||
|
|
||||||
|
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(data1), 0, nil, nil, nil, nil, nil)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 2, len(allNewTransactions))
|
||||||
|
|
||||||
|
assert.Equal(t, int64(1234567890), allNewTransactions[0].Uid)
|
||||||
|
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[0].Type)
|
||||||
|
assert.Equal(t, "2024-09-01 01:00:00", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime), time.UTC))
|
||||||
|
assert.Equal(t, int64(1), allNewTransactions[0].Amount)
|
||||||
|
assert.Equal(t, "Test Account", allNewTransactions[0].OriginalSourceAccountName)
|
||||||
|
assert.Equal(t, "Test Account2", allNewTransactions[0].OriginalDestinationAccountName)
|
||||||
|
|
||||||
|
assert.Equal(t, int64(1234567890), allNewTransactions[1].Uid)
|
||||||
|
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[1].Type)
|
||||||
|
assert.Equal(t, "2024-09-01 02:00:00", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[1].TransactionTime), time.UTC))
|
||||||
|
assert.Equal(t, int64(1), allNewTransactions[1].Amount)
|
||||||
|
assert.Equal(t, "Test Account2", allNewTransactions[1].OriginalSourceAccountName)
|
||||||
|
assert.Equal(t, "Test Account", allNewTransactions[1].OriginalDestinationAccountName)
|
||||||
|
}
|
||||||
|
|
||||||
func TestAlipayCsvFileImporterParseImportedData_ParseInvalidTime(t *testing.T) {
|
func TestAlipayCsvFileImporterParseImportedData_ParseInvalidTime(t *testing.T) {
|
||||||
converter := AlipayWebTransactionDataCsvFileImporter
|
converter := AlipayWebTransactionDataCsvFileImporter
|
||||||
context := core.NewNullContext()
|
context := core.NewNullContext()
|
||||||
@@ -380,38 +422,110 @@ func TestAlipayCsvFileImporterParseImportedData_ParseRelatedAccount(t *testing.T
|
|||||||
"起始时间:[2024-01-01 00:00:00] 终止时间:[2024-09-01 23:59:59]\n" +
|
"起始时间:[2024-01-01 00:00:00] 终止时间:[2024-09-01 23:59:59]\n" +
|
||||||
"导出交易类型:[全部]\n" +
|
"导出交易类型:[全部]\n" +
|
||||||
"------------------------支付宝(中国)网络技术有限公司 电子客户回单------------------------\n" +
|
"------------------------支付宝(中国)网络技术有限公司 电子客户回单------------------------\n" +
|
||||||
"交易时间,商品说明,收/支,金额,收/付款方式,交易状态,\n" +
|
"交易时间,交易对方,商品说明,收/支,金额,收/付款方式,交易状态,备注,\n" +
|
||||||
"2024-09-01 03:45:07,余额宝-单次转入,不计收支,0.01,Test Account,交易成功,\n" +
|
"2024-09-01 00:00:00,xxx,xxx-收益发放,不计收支,0.01,Test Account,交易成功,earning,\n" +
|
||||||
"2024-09-01 05:07:29,信用卡还款,不计收支,0.02,Test Account2,交易成功,\n")
|
"2024-09-01 01:00:00,Test Account2,xxx-买入,不计收支,0.01,Test Account,交易成功,purchase investment,\n" +
|
||||||
|
"2024-09-01 02:00:00,Test Account2,xxx-卖出至xxx,不计收支,0.01,Test Account,交易成功,sell investment,\n" +
|
||||||
|
"2024-09-01 03:00:00,xxx,充值-普通充值,不计收支,0.01,Test Account,交易成功,transfer to alipay wallet,\n" +
|
||||||
|
"2024-09-01 04:00:00,Test Account3,提现-实时提现,不计收支,0.01,Test Account,交易成功,transfer from alipay wallet,\n" +
|
||||||
|
"2024-09-01 05:00:00,Test Account3,xxx-单次转入,不计收支,0.01,Test Account,交易成功,transfer in,\n" +
|
||||||
|
"2024-09-01 06:00:00,Test Account3,xxx-转出到银行卡,不计收支,0.01,Test Account,交易成功,transfer out,\n" +
|
||||||
|
"2024-09-01 07:00:00,Test Account3,转账xxx,不计收支,0.01,Test Account,交易成功,transfer,\n" +
|
||||||
|
"2024-09-01 08:00:00,Test Account4,信用卡还款,不计收支,0.01,Test Account,还款成功,repayment,\n")
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
|
|
||||||
allNewTransactions, allNewAccounts, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(data1), 0, nil, nil, nil, nil, nil)
|
allNewTransactions, allNewAccounts, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(data1), 0, nil, nil, nil, nil, nil)
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
|
|
||||||
assert.Equal(t, 2, len(allNewTransactions))
|
assert.Equal(t, 9, len(allNewTransactions))
|
||||||
assert.Equal(t, 3, len(allNewAccounts))
|
assert.Equal(t, 6, len(allNewAccounts))
|
||||||
|
|
||||||
|
assert.Equal(t, models.TRANSACTION_DB_TYPE_INCOME, allNewTransactions[0].Type)
|
||||||
assert.Equal(t, int64(1234567890), allNewTransactions[0].Uid)
|
assert.Equal(t, int64(1234567890), allNewTransactions[0].Uid)
|
||||||
assert.Equal(t, int64(1), allNewTransactions[0].Amount)
|
assert.Equal(t, int64(1), allNewTransactions[0].Amount)
|
||||||
assert.Equal(t, "Test Account", allNewTransactions[0].OriginalSourceAccountName)
|
assert.Equal(t, "Test Account", allNewTransactions[0].OriginalSourceAccountName)
|
||||||
assert.Equal(t, "", allNewTransactions[0].OriginalDestinationAccountName)
|
assert.Equal(t, "", allNewTransactions[0].OriginalDestinationAccountName)
|
||||||
|
assert.Equal(t, "earning", allNewTransactions[0].Comment)
|
||||||
|
|
||||||
|
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[1].Type)
|
||||||
assert.Equal(t, int64(1234567890), allNewTransactions[1].Uid)
|
assert.Equal(t, int64(1234567890), allNewTransactions[1].Uid)
|
||||||
assert.Equal(t, int64(2), allNewTransactions[1].Amount)
|
assert.Equal(t, int64(1), allNewTransactions[1].Amount)
|
||||||
assert.Equal(t, "Test Account2", allNewTransactions[1].OriginalSourceAccountName)
|
assert.Equal(t, "Test Account", allNewTransactions[1].OriginalSourceAccountName)
|
||||||
assert.Equal(t, "", allNewTransactions[1].OriginalDestinationAccountName)
|
assert.Equal(t, "Test Account2", allNewTransactions[1].OriginalDestinationAccountName)
|
||||||
|
assert.Equal(t, "purchase investment", allNewTransactions[1].Comment)
|
||||||
|
|
||||||
|
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[2].Type)
|
||||||
|
assert.Equal(t, int64(1234567890), allNewTransactions[2].Uid)
|
||||||
|
assert.Equal(t, int64(1), allNewTransactions[2].Amount)
|
||||||
|
assert.Equal(t, "Test Account2", allNewTransactions[2].OriginalSourceAccountName)
|
||||||
|
assert.Equal(t, "Test Account", allNewTransactions[2].OriginalDestinationAccountName)
|
||||||
|
assert.Equal(t, "sell investment", allNewTransactions[2].Comment)
|
||||||
|
|
||||||
|
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[3].Type)
|
||||||
|
assert.Equal(t, int64(1234567890), allNewTransactions[3].Uid)
|
||||||
|
assert.Equal(t, int64(1), allNewTransactions[3].Amount)
|
||||||
|
assert.Equal(t, "", allNewTransactions[3].OriginalSourceAccountName)
|
||||||
|
assert.Equal(t, "Alipay", allNewTransactions[3].OriginalDestinationAccountName)
|
||||||
|
assert.Equal(t, "transfer to alipay wallet", allNewTransactions[3].Comment)
|
||||||
|
|
||||||
|
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[4].Type)
|
||||||
|
assert.Equal(t, int64(1234567890), allNewTransactions[4].Uid)
|
||||||
|
assert.Equal(t, int64(1), allNewTransactions[4].Amount)
|
||||||
|
assert.Equal(t, "Alipay", allNewTransactions[4].OriginalSourceAccountName)
|
||||||
|
assert.Equal(t, "Test Account3", allNewTransactions[4].OriginalDestinationAccountName)
|
||||||
|
assert.Equal(t, "transfer from alipay wallet", allNewTransactions[4].Comment)
|
||||||
|
|
||||||
|
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[5].Type)
|
||||||
|
assert.Equal(t, int64(1234567890), allNewTransactions[5].Uid)
|
||||||
|
assert.Equal(t, int64(1), allNewTransactions[5].Amount)
|
||||||
|
assert.Equal(t, "Test Account", allNewTransactions[5].OriginalSourceAccountName)
|
||||||
|
assert.Equal(t, "Test Account3", allNewTransactions[5].OriginalDestinationAccountName)
|
||||||
|
assert.Equal(t, "transfer in", allNewTransactions[5].Comment)
|
||||||
|
|
||||||
|
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[6].Type)
|
||||||
|
assert.Equal(t, int64(1234567890), allNewTransactions[6].Uid)
|
||||||
|
assert.Equal(t, int64(1), allNewTransactions[6].Amount)
|
||||||
|
assert.Equal(t, "Test Account", allNewTransactions[6].OriginalSourceAccountName)
|
||||||
|
assert.Equal(t, "Test Account3", allNewTransactions[6].OriginalDestinationAccountName)
|
||||||
|
assert.Equal(t, "transfer out", allNewTransactions[6].Comment)
|
||||||
|
|
||||||
|
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[7].Type)
|
||||||
|
assert.Equal(t, int64(1234567890), allNewTransactions[7].Uid)
|
||||||
|
assert.Equal(t, int64(1), allNewTransactions[7].Amount)
|
||||||
|
assert.Equal(t, "Test Account", allNewTransactions[7].OriginalSourceAccountName)
|
||||||
|
assert.Equal(t, "Test Account3", allNewTransactions[7].OriginalDestinationAccountName)
|
||||||
|
assert.Equal(t, "transfer", allNewTransactions[7].Comment)
|
||||||
|
|
||||||
|
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[8].Type)
|
||||||
|
assert.Equal(t, int64(1234567890), allNewTransactions[8].Uid)
|
||||||
|
assert.Equal(t, int64(1), allNewTransactions[8].Amount)
|
||||||
|
assert.Equal(t, "Test Account", allNewTransactions[8].OriginalSourceAccountName)
|
||||||
|
assert.Equal(t, "Test Account4", allNewTransactions[8].OriginalDestinationAccountName)
|
||||||
|
assert.Equal(t, "repayment", allNewTransactions[8].Comment)
|
||||||
|
|
||||||
assert.Equal(t, int64(1234567890), allNewAccounts[0].Uid)
|
assert.Equal(t, int64(1234567890), allNewAccounts[0].Uid)
|
||||||
assert.Equal(t, "Test Account", allNewAccounts[0].Name)
|
assert.Equal(t, "Test Account", allNewAccounts[0].Name)
|
||||||
assert.Equal(t, "CNY", allNewAccounts[0].Currency)
|
assert.Equal(t, "CNY", allNewAccounts[0].Currency)
|
||||||
|
|
||||||
assert.Equal(t, int64(1234567890), allNewAccounts[1].Uid)
|
assert.Equal(t, int64(1234567890), allNewAccounts[1].Uid)
|
||||||
assert.Equal(t, "", allNewAccounts[1].Name)
|
assert.Equal(t, "Test Account2", allNewAccounts[1].Name)
|
||||||
assert.Equal(t, "CNY", allNewAccounts[1].Currency)
|
assert.Equal(t, "CNY", allNewAccounts[1].Currency)
|
||||||
|
|
||||||
assert.Equal(t, int64(1234567890), allNewAccounts[2].Uid)
|
assert.Equal(t, int64(1234567890), allNewAccounts[2].Uid)
|
||||||
assert.Equal(t, "Test Account2", allNewAccounts[2].Name)
|
assert.Equal(t, "", allNewAccounts[2].Name)
|
||||||
assert.Equal(t, "CNY", allNewAccounts[2].Currency)
|
assert.Equal(t, "CNY", allNewAccounts[2].Currency)
|
||||||
|
|
||||||
|
assert.Equal(t, int64(1234567890), allNewAccounts[3].Uid)
|
||||||
|
assert.Equal(t, "Alipay", allNewAccounts[3].Name)
|
||||||
|
assert.Equal(t, "CNY", allNewAccounts[3].Currency)
|
||||||
|
|
||||||
|
assert.Equal(t, int64(1234567890), allNewAccounts[4].Uid)
|
||||||
|
assert.Equal(t, "Test Account3", allNewAccounts[4].Name)
|
||||||
|
assert.Equal(t, "CNY", allNewAccounts[4].Currency)
|
||||||
|
|
||||||
|
assert.Equal(t, int64(1234567890), allNewAccounts[5].Uid)
|
||||||
|
assert.Equal(t, "Test Account4", allNewAccounts[5].Name)
|
||||||
|
assert.Equal(t, "CNY", allNewAccounts[5].Currency)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlipayCsvFileImporterParseImportedData_ParseDescription(t *testing.T) {
|
func TestAlipayCsvFileImporterParseImportedData_ParseDescription(t *testing.T) {
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package alipay
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/converters/csv"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
func createNewAlipayTransactionBasicDataTable(ctx core.Context, originalDataTable datatable.BasicDataTable, fileHeaderLine string, dataHeaderStartContent string, dataBottomEndLineRune rune) (datatable.BasicDataTable, error) {
|
||||||
|
iterator := originalDataTable.DataRowIterator()
|
||||||
|
allOriginalLines := make([][]string, 0)
|
||||||
|
hasFileHeader := false
|
||||||
|
foundContentBeforeDataHeaderLine := false
|
||||||
|
|
||||||
|
for iterator.HasNext() {
|
||||||
|
row := iterator.Next()
|
||||||
|
|
||||||
|
if !hasFileHeader {
|
||||||
|
if row.ColumnCount() <= 0 {
|
||||||
|
continue
|
||||||
|
} else if strings.Index(row.GetData(0), fileHeaderLine) == 0 {
|
||||||
|
hasFileHeader = true
|
||||||
|
continue
|
||||||
|
} else {
|
||||||
|
log.Warnf(ctx, "[alipay_transaction_data_extrator.createNewAlipayTransactionBasicDataTable] read unexpected line in row \"%s\" before read file header", iterator.CurrentRowId())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !foundContentBeforeDataHeaderLine {
|
||||||
|
if row.ColumnCount() <= 0 {
|
||||||
|
continue
|
||||||
|
} else if strings.Index(row.GetData(0), dataHeaderStartContent) >= 0 {
|
||||||
|
foundContentBeforeDataHeaderLine = true
|
||||||
|
continue
|
||||||
|
} else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if foundContentBeforeDataHeaderLine {
|
||||||
|
if row.ColumnCount() <= 0 {
|
||||||
|
continue
|
||||||
|
} else if row.ColumnCount() == 1 && dataBottomEndLineRune > 0 && utils.ContainsOnlyOneRune(row.GetData(0), dataBottomEndLineRune) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]string, row.ColumnCount())
|
||||||
|
|
||||||
|
for i := 0; i < row.ColumnCount(); i++ {
|
||||||
|
items[i] = strings.Trim(row.GetData(i), " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(allOriginalLines) > 0 && len(items) < len(allOriginalLines[0]) {
|
||||||
|
log.Errorf(ctx, "[alipay_transaction_data_extrator.createNewAlipayTransactionBasicDataTable] cannot parse row \"%s\", because may missing some columns (column count %d in data row is less than header column count %d)", iterator.CurrentRowId(), len(items), len(allOriginalLines[0]))
|
||||||
|
return nil, errs.ErrFewerFieldsInDataRowThanInHeaderRow
|
||||||
|
}
|
||||||
|
|
||||||
|
allOriginalLines = append(allOriginalLines, items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasFileHeader || !foundContentBeforeDataHeaderLine {
|
||||||
|
return nil, errs.ErrInvalidFileHeader
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(allOriginalLines) < 2 {
|
||||||
|
log.Errorf(ctx, "[alipay_transaction_data_extrator.createNewAlipayTransactionBasicDataTable] cannot parse import data, because data table row count is less 1")
|
||||||
|
return nil, errs.ErrNotFoundTransactionDataInFile
|
||||||
|
}
|
||||||
|
|
||||||
|
return csv.CreateNewCustomCsvBasicDataTable(allOriginalLines, true), nil
|
||||||
|
}
|
||||||
@@ -18,10 +18,15 @@ const alipayTransactionDataStatusClosedName = "交易关闭"
|
|||||||
const alipayTransactionDataStatusRefundSuccessName = "退款成功"
|
const alipayTransactionDataStatusRefundSuccessName = "退款成功"
|
||||||
const alipayTransactionDataStatusTaxRefundSuccessName = "退税成功"
|
const alipayTransactionDataStatusTaxRefundSuccessName = "退税成功"
|
||||||
|
|
||||||
|
const alipayTransactionDataProductNameEarningText = "-收益发放"
|
||||||
|
const alipayTransactionDataProductNamePurchaseInvestmentText = "-买入"
|
||||||
|
const alipayTransactionDataProductNamePurchaseInvestmentRefundText = "-买入退款"
|
||||||
|
const alipayTransactionDataProductNameSellInvestmentRefundText = "-卖出"
|
||||||
const alipayTransactionDataProductNameTransferToAlipayPrefix = "充值-"
|
const alipayTransactionDataProductNameTransferToAlipayPrefix = "充值-"
|
||||||
const alipayTransactionDataProductNameTransferFromAlipayPrefix = "提现-"
|
const alipayTransactionDataProductNameTransferFromAlipayPrefix = "提现-"
|
||||||
const alipayTransactionDataProductNameTransferInText = "转入"
|
const alipayTransactionDataProductNameTransferInText = "转入"
|
||||||
const alipayTransactionDataProductNameTransferOutText = "转出"
|
const alipayTransactionDataProductNameTransferOutText = "转出"
|
||||||
|
const alipayTransactionDataProductNameTransferText = "转账"
|
||||||
const alipayTransactionDataProductNameRepaymentText = "还款"
|
const alipayTransactionDataProductNameRepaymentText = "还款"
|
||||||
|
|
||||||
// alipayTransactionDataRowParser defines the structure of alipay transaction data row parser
|
// alipayTransactionDataRowParser defines the structure of alipay transaction data row parser
|
||||||
@@ -127,11 +132,29 @@ func (p *alipayTransactionDataRowParser) Parse(ctx core.Context, user *models.Us
|
|||||||
}
|
}
|
||||||
|
|
||||||
if statusName == alipayTransactionDataStatusRefundSuccessName {
|
if statusName == alipayTransactionDataStatusRefundSuccessName {
|
||||||
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME]
|
if len(productName) > len(alipayTransactionDataProductNamePurchaseInvestmentText) && strings.Index(productName, alipayTransactionDataProductNamePurchaseInvestmentText) == len(productName)-len(alipayTransactionDataProductNamePurchaseInvestmentText) { // purchase investment
|
||||||
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = relatedAccountName
|
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = relatedAccountName
|
||||||
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = ""
|
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = targetName
|
||||||
|
} else if len(productName) > len(alipayTransactionDataProductNamePurchaseInvestmentRefundText) && strings.Index(productName, alipayTransactionDataProductNamePurchaseInvestmentRefundText) == len(productName)-len(alipayTransactionDataProductNamePurchaseInvestmentRefundText) { // purchase investment refund
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = targetName
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = relatedAccountName
|
||||||
|
} else {
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME]
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = relatedAccountName
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = ""
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
if strings.Index(productName, alipayTransactionDataProductNameTransferToAlipayPrefix) == 0 { // transfer to alipay wallet
|
if len(productName) > len(alipayTransactionDataProductNameEarningText) && strings.Index(productName, alipayTransactionDataProductNameEarningText) == len(productName)-len(alipayTransactionDataProductNameEarningText) { // earning
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME]
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = relatedAccountName
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = targetName
|
||||||
|
} else if len(productName) > len(alipayTransactionDataProductNamePurchaseInvestmentText) && strings.Index(productName, alipayTransactionDataProductNamePurchaseInvestmentText) == len(productName)-len(alipayTransactionDataProductNamePurchaseInvestmentText) { // purchase investment
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = relatedAccountName
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = targetName
|
||||||
|
} else if strings.Index(productName, alipayTransactionDataProductNameSellInvestmentRefundText) >= 0 { // sell investment
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = targetName
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = relatedAccountName
|
||||||
|
} else if strings.Index(productName, alipayTransactionDataProductNameTransferToAlipayPrefix) == 0 { // transfer to alipay wallet
|
||||||
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = ""
|
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = ""
|
||||||
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = localeTextItems.DataConverterTextItems.Alipay
|
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = localeTextItems.DataConverterTextItems.Alipay
|
||||||
} else if strings.Index(productName, alipayTransactionDataProductNameTransferFromAlipayPrefix) == 0 { // transfer from alipay wallet
|
} else if strings.Index(productName, alipayTransactionDataProductNameTransferFromAlipayPrefix) == 0 { // transfer from alipay wallet
|
||||||
@@ -143,6 +166,9 @@ func (p *alipayTransactionDataRowParser) Parse(ctx core.Context, user *models.Us
|
|||||||
} else if strings.Index(productName, alipayTransactionDataProductNameTransferOutText) >= 0 { // transfer out
|
} else if strings.Index(productName, alipayTransactionDataProductNameTransferOutText) >= 0 { // transfer out
|
||||||
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = relatedAccountName
|
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = relatedAccountName
|
||||||
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = targetName
|
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = targetName
|
||||||
|
} else if strings.Index(productName, alipayTransactionDataProductNameTransferText) >= 0 { // transfer
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = relatedAccountName
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = targetName
|
||||||
} else if strings.Index(productName, alipayTransactionDataProductNameRepaymentText) >= 0 { // repayment
|
} else if strings.Index(productName, alipayTransactionDataProductNameRepaymentText) >= 0 { // repayment
|
||||||
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = relatedAccountName
|
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = relatedAccountName
|
||||||
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = targetName
|
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = targetName
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
package beancount
|
package beancount
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"math/big"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const maxAllowedDecimalCount = 6
|
||||||
|
const normalizeFactor = int64(1000000)
|
||||||
|
const normalizedDecimalsMaxZeroString = "000000"
|
||||||
|
const normalizedNumberToAmountFactor = int64(10000) // 1000000 / 100
|
||||||
|
|
||||||
var operatorPriority = map[rune]int{
|
var operatorPriority = map[rune]int{
|
||||||
'+': 1,
|
'+': 1,
|
||||||
'-': 1,
|
'-': 1,
|
||||||
@@ -17,6 +22,44 @@ var operatorPriority = map[rune]int{
|
|||||||
'/': 2,
|
'/': 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeNumber(textualNumber string) (*big.Int, error) {
|
||||||
|
decimalSeparatorPos := strings.Index(textualNumber, ".")
|
||||||
|
|
||||||
|
if decimalSeparatorPos < 0 {
|
||||||
|
result := big.NewInt(0)
|
||||||
|
_, ok := result.SetString(textualNumber+normalizedDecimalsMaxZeroString, 10)
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
return nil, errs.ErrAmountInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
integer := utils.SubString(textualNumber, 0, decimalSeparatorPos)
|
||||||
|
decimals := utils.SubString(textualNumber, decimalSeparatorPos+1, len(textualNumber))
|
||||||
|
|
||||||
|
if len(decimals) > maxAllowedDecimalCount {
|
||||||
|
return nil, errs.ErrAmountInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
paddedDecimals := utils.SubString(decimals+normalizedDecimalsMaxZeroString, 0, maxAllowedDecimalCount)
|
||||||
|
result := big.NewInt(0)
|
||||||
|
_, ok := result.SetString(integer+paddedDecimals, 10)
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
return nil, errs.ErrAmountInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func denormalizeNumberToTextualAmount(num *big.Int) string {
|
||||||
|
result := big.NewInt(0).Add(num, big.NewInt(0)) // make a copy of num
|
||||||
|
result = result.Div(result, big.NewInt(normalizedNumberToAmountFactor))
|
||||||
|
return utils.FormatAmount(result.Int64())
|
||||||
|
}
|
||||||
|
|
||||||
func toPostfixExprTokens(ctx core.Context, expr string) ([]string, error) {
|
func toPostfixExprTokens(ctx core.Context, expr string) ([]string, error) {
|
||||||
finalTokens := make([]string, 0)
|
finalTokens := make([]string, 0)
|
||||||
operatorStack := make([]rune, 0)
|
operatorStack := make([]rune, 0)
|
||||||
@@ -117,8 +160,8 @@ func toPostfixExprTokens(ctx core.Context, expr string) ([]string, error) {
|
|||||||
return finalTokens, nil
|
return finalTokens, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func evaluatePostfixExpr(ctx core.Context, tokens []string) (float64, error) {
|
func evaluatePostfixExpr(ctx core.Context, tokens []string) (*big.Int, error) {
|
||||||
stack := make([]float64, 0)
|
stack := make([]*big.Int, 0)
|
||||||
|
|
||||||
for i := 0; i < len(tokens); i++ {
|
for i := 0; i < len(tokens); i++ {
|
||||||
token := tokens[i]
|
token := tokens[i]
|
||||||
@@ -127,7 +170,7 @@ func evaluatePostfixExpr(ctx core.Context, tokens []string) (float64, error) {
|
|||||||
case "+", "-", "*", "/": // operators
|
case "+", "-", "*", "/": // operators
|
||||||
if len(stack) < 2 {
|
if len(stack) < 2 {
|
||||||
log.Warnf(ctx, "[beancount_amount_expression_evaluator.evaluatePostfixExpr] cannot evaluate expression \"%s\", because not enough operands", strings.Join(tokens, " "))
|
log.Warnf(ctx, "[beancount_amount_expression_evaluator.evaluatePostfixExpr] cannot evaluate expression \"%s\", because not enough operands", strings.Join(tokens, " "))
|
||||||
return 0, errs.ErrInvalidAmountExpression
|
return nil, errs.ErrInvalidAmountExpression
|
||||||
}
|
}
|
||||||
|
|
||||||
// pop the top two operands
|
// pop the top two operands
|
||||||
@@ -138,39 +181,41 @@ func evaluatePostfixExpr(ctx core.Context, tokens []string) (float64, error) {
|
|||||||
stack = stack[:len(stack)-1]
|
stack = stack[:len(stack)-1]
|
||||||
|
|
||||||
// evaluate the operation
|
// evaluate the operation
|
||||||
var result float64
|
result := big.NewInt(0)
|
||||||
switch token {
|
switch token {
|
||||||
case "+":
|
case "+":
|
||||||
result = a + b
|
result.Add(a, b)
|
||||||
case "-":
|
case "-":
|
||||||
result = a - b
|
result.Sub(a, b)
|
||||||
case "*":
|
case "*":
|
||||||
result = a * b
|
result.Mul(a, b)
|
||||||
|
result.Div(result, big.NewInt(normalizeFactor))
|
||||||
case "/":
|
case "/":
|
||||||
if b == 0 {
|
if b.Int64() == 0 {
|
||||||
log.Warnf(ctx, "[beancount_amount_expression_evaluator.evaluatePostfixExpr] cannot evaluate expression \"%s\", because division by zero", strings.Join(tokens, " "))
|
log.Warnf(ctx, "[beancount_amount_expression_evaluator.evaluatePostfixExpr] cannot evaluate expression \"%s\", because division by zero", strings.Join(tokens, " "))
|
||||||
return 0, errs.ErrInvalidAmountExpression
|
return nil, errs.ErrInvalidAmountExpression
|
||||||
}
|
}
|
||||||
result = a / b
|
result.Mul(a, big.NewInt(normalizeFactor))
|
||||||
|
result.Div(result, b)
|
||||||
}
|
}
|
||||||
|
|
||||||
// push the result back to the stack
|
// push the result back to the stack
|
||||||
stack = append(stack, result)
|
stack = append(stack, result)
|
||||||
default: // operands
|
default: // operands
|
||||||
num, err := strconv.ParseFloat(token, 64)
|
normalizedNum, err := normalizeNumber(token)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warnf(ctx, "[beancount_amount_expression_evaluator.evaluatePostfixExpr] cannot evaluate expression \"%s\", because containing invalid number", strings.Join(tokens, " "))
|
log.Warnf(ctx, "[beancount_amount_expression_evaluator.evaluatePostfixExpr] cannot evaluate expression \"%s\", because containing invalid number", strings.Join(tokens, " "))
|
||||||
return 0, errs.ErrInvalidAmountExpression
|
return nil, errs.ErrInvalidAmountExpression
|
||||||
}
|
}
|
||||||
|
|
||||||
stack = append(stack, num)
|
stack = append(stack, normalizedNum)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(stack) != 1 {
|
if len(stack) != 1 {
|
||||||
log.Warnf(ctx, "[beancount_amount_expression_evaluator.evaluatePostfixExpr] cannot evaluate expression \"%s\", because missing operator", strings.Join(tokens, " "))
|
log.Warnf(ctx, "[beancount_amount_expression_evaluator.evaluatePostfixExpr] cannot evaluate expression \"%s\", because missing operator", strings.Join(tokens, " "))
|
||||||
return 0, errs.ErrInvalidAmountExpression
|
return nil, errs.ErrInvalidAmountExpression
|
||||||
}
|
}
|
||||||
|
|
||||||
return stack[0], nil
|
return stack[0], nil
|
||||||
@@ -193,5 +238,5 @@ func evaluateBeancountAmountExpression(ctx core.Context, expr string) (string, e
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Sprintf("%.2f", result), nil
|
return denormalizeNumberToTextualAmount(result), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package beancount
|
package beancount
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"math/big"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -97,23 +98,23 @@ func TestEvaluatePostfixExpr_ValidExpression(t *testing.T) {
|
|||||||
|
|
||||||
result, err := evaluatePostfixExpr(context, []string{"1", "2", "+"})
|
result, err := evaluatePostfixExpr(context, []string{"1", "2", "+"})
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
assert.Equal(t, float64(3), result)
|
assert.Equal(t, big.NewInt(3000000), result)
|
||||||
|
|
||||||
result, err = evaluatePostfixExpr(context, []string{"5", "3", "-"})
|
result, err = evaluatePostfixExpr(context, []string{"5", "3", "-"})
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
assert.Equal(t, float64(2), result)
|
assert.Equal(t, big.NewInt(2000000), result)
|
||||||
|
|
||||||
result, err = evaluatePostfixExpr(context, []string{"4", "3", "*"})
|
result, err = evaluatePostfixExpr(context, []string{"4", "3", "*"})
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
assert.Equal(t, float64(12), result)
|
assert.Equal(t, big.NewInt(12000000), result)
|
||||||
|
|
||||||
result, err = evaluatePostfixExpr(context, []string{"6", "2", "/"})
|
result, err = evaluatePostfixExpr(context, []string{"6", "2", "/"})
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
assert.Equal(t, float64(3), result)
|
assert.Equal(t, big.NewInt(3000000), result)
|
||||||
|
|
||||||
result, err = evaluatePostfixExpr(context, []string{"1", "2", "3", "*", "+", "4", "2", "/", "-"})
|
result, err = evaluatePostfixExpr(context, []string{"1", "2", "3", "*", "+", "4", "2", "/", "-"})
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
assert.Equal(t, float64(5), result)
|
assert.Equal(t, big.NewInt(5000000), result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEvaluatePostfixExpr_InvalidExpression(t *testing.T) {
|
func TestEvaluatePostfixExpr_InvalidExpression(t *testing.T) {
|
||||||
@@ -179,6 +180,18 @@ func TestEvaluateBeancountAmountExpression_ValidExpression(t *testing.T) {
|
|||||||
result, err = evaluateBeancountAmountExpression(context, "(((2+3)))*(((((-5+7)))))")
|
result, err = evaluateBeancountAmountExpression(context, "(((2+3)))*(((((-5+7)))))")
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
assert.Equal(t, "10.00", result)
|
assert.Equal(t, "10.00", result)
|
||||||
|
|
||||||
|
result, err = evaluateBeancountAmountExpression(context, "3.5+0.1")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, "3.60", result)
|
||||||
|
|
||||||
|
result, err = evaluateBeancountAmountExpression(context, "3.55+0.11")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, "3.66", result)
|
||||||
|
|
||||||
|
result, err = evaluateBeancountAmountExpression(context, "3.555+0.111")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, "3.66", result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEvaluateBeancountAmountExpression_InvalidExpression(t *testing.T) {
|
func TestEvaluateBeancountAmountExpression_InvalidExpression(t *testing.T) {
|
||||||
@@ -213,4 +226,10 @@ func TestEvaluateBeancountAmountExpression_InvalidExpression(t *testing.T) {
|
|||||||
|
|
||||||
_, err = evaluateBeancountAmountExpression(context, "1)*(2")
|
_, err = evaluateBeancountAmountExpression(context, "1)*(2")
|
||||||
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
|
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
|
||||||
|
|
||||||
|
_, err = evaluateBeancountAmountExpression(context, "0.abcd+1")
|
||||||
|
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
|
||||||
|
|
||||||
|
_, err = evaluateBeancountAmountExpression(context, "0.1234567+1")
|
||||||
|
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import (
|
|||||||
|
|
||||||
// CsvFileBasicDataTable defines the structure of csv data table
|
// CsvFileBasicDataTable defines the structure of csv data table
|
||||||
type CsvFileBasicDataTable struct {
|
type CsvFileBasicDataTable struct {
|
||||||
allLines [][]string
|
allLines [][]string
|
||||||
|
hasTitleLine bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// CsvFileBasicDataTableRow defines the structure of csv data table row
|
// CsvFileBasicDataTableRow defines the structure of csv data table row
|
||||||
@@ -34,7 +35,11 @@ func (t *CsvFileBasicDataTable) DataRowCount() int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
return len(t.allLines) - 1
|
if t.hasTitleLine {
|
||||||
|
return len(t.allLines) - 1
|
||||||
|
} else {
|
||||||
|
return len(t.allLines)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HeaderColumnNames returns the header column name list
|
// HeaderColumnNames returns the header column name list
|
||||||
@@ -43,14 +48,24 @@ func (t *CsvFileBasicDataTable) HeaderColumnNames() []string {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return t.allLines[0]
|
if t.hasTitleLine {
|
||||||
|
return t.allLines[0]
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DataRowIterator returns the iterator of data row
|
// DataRowIterator returns the iterator of data row
|
||||||
func (t *CsvFileBasicDataTable) DataRowIterator() datatable.BasicDataTableRowIterator {
|
func (t *CsvFileBasicDataTable) DataRowIterator() datatable.BasicDataTableRowIterator {
|
||||||
|
startIndex := -1
|
||||||
|
|
||||||
|
if t.hasTitleLine {
|
||||||
|
startIndex = 0
|
||||||
|
}
|
||||||
|
|
||||||
return &CsvFileBasicDataTableRowIterator{
|
return &CsvFileBasicDataTableRowIterator{
|
||||||
dataTable: t,
|
dataTable: t,
|
||||||
currentIndex: 0,
|
currentIndex: startIndex,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,18 +110,19 @@ func (t *CsvFileBasicDataTableRowIterator) Next() datatable.BasicDataTableRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CreateNewCsvBasicDataTable returns comma separated values data table by io readers
|
// CreateNewCsvBasicDataTable returns comma separated values data table by io readers
|
||||||
func CreateNewCsvBasicDataTable(ctx core.Context, reader io.Reader) (datatable.BasicDataTable, error) {
|
func CreateNewCsvBasicDataTable(ctx core.Context, reader io.Reader, hasTitleLine bool) (datatable.BasicDataTable, error) {
|
||||||
return createNewCsvFileBasicDataTable(ctx, reader, ',')
|
return createNewCsvFileBasicDataTable(ctx, reader, ',', hasTitleLine)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateNewCustomCsvBasicDataTable returns character separated values data table by io readers
|
// CreateNewCustomCsvBasicDataTable returns character separated values data table by io readers
|
||||||
func CreateNewCustomCsvBasicDataTable(allLines [][]string) datatable.BasicDataTable {
|
func CreateNewCustomCsvBasicDataTable(allLines [][]string, hasTitleLine bool) datatable.BasicDataTable {
|
||||||
return &CsvFileBasicDataTable{
|
return &CsvFileBasicDataTable{
|
||||||
allLines: allLines,
|
allLines: allLines,
|
||||||
|
hasTitleLine: hasTitleLine,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func createNewCsvFileBasicDataTable(ctx core.Context, reader io.Reader, separator rune) (*CsvFileBasicDataTable, error) {
|
func createNewCsvFileBasicDataTable(ctx core.Context, reader io.Reader, separator rune, hasTitleLine bool) (*CsvFileBasicDataTable, error) {
|
||||||
csvReader := csv.NewReader(reader)
|
csvReader := csv.NewReader(reader)
|
||||||
csvReader.Comma = separator
|
csvReader.Comma = separator
|
||||||
csvReader.FieldsPerRecord = -1
|
csvReader.FieldsPerRecord = -1
|
||||||
@@ -133,6 +149,7 @@ func createNewCsvFileBasicDataTable(ctx core.Context, reader io.Reader, separato
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &CsvFileBasicDataTable{
|
return &CsvFileBasicDataTable{
|
||||||
allLines: allLines,
|
allLines: allLines,
|
||||||
|
hasTitleLine: hasTitleLine,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,17 @@ func TestCsvFileBasicDataTableDataRowCount(t *testing.T) {
|
|||||||
{"A1", "B1", "C1"},
|
{"A1", "B1", "C1"},
|
||||||
{"A2", "B2", "C2"},
|
{"A2", "B2", "C2"},
|
||||||
{"A3", "B3", "C3"},
|
{"A3", "B3", "C3"},
|
||||||
})
|
}, false)
|
||||||
|
|
||||||
|
assert.Equal(t, 3, datatable.DataRowCount())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCsvFileBasicDataTableDataRowCount_HasTitleLine(t *testing.T) {
|
||||||
|
datatable := CreateNewCustomCsvBasicDataTable([][]string{
|
||||||
|
{"A1", "B1", "C1"},
|
||||||
|
{"A2", "B2", "C2"},
|
||||||
|
{"A3", "B3", "C3"},
|
||||||
|
}, true)
|
||||||
|
|
||||||
assert.Equal(t, 2, datatable.DataRowCount())
|
assert.Equal(t, 2, datatable.DataRowCount())
|
||||||
}
|
}
|
||||||
@@ -22,14 +32,16 @@ func TestCsvFileBasicDataTableDataRowCount(t *testing.T) {
|
|||||||
func TestCsvFileBasicDataTableDataRowCount_OnlyHeaderLine(t *testing.T) {
|
func TestCsvFileBasicDataTableDataRowCount_OnlyHeaderLine(t *testing.T) {
|
||||||
datatable := CreateNewCustomCsvBasicDataTable([][]string{
|
datatable := CreateNewCustomCsvBasicDataTable([][]string{
|
||||||
{"A1", "B1", "C1"},
|
{"A1", "B1", "C1"},
|
||||||
})
|
}, true)
|
||||||
|
|
||||||
assert.Equal(t, 0, datatable.DataRowCount())
|
assert.Equal(t, 0, datatable.DataRowCount())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCsvFileBasicDataTableDataRowCount_EmptyContent(t *testing.T) {
|
func TestCsvFileBasicDataTableDataRowCount_EmptyContent(t *testing.T) {
|
||||||
datatable := CreateNewCustomCsvBasicDataTable([][]string{})
|
datatable := CreateNewCustomCsvBasicDataTable([][]string{}, false)
|
||||||
|
assert.Equal(t, 0, datatable.DataRowCount())
|
||||||
|
|
||||||
|
datatable = CreateNewCustomCsvBasicDataTable([][]string{}, true)
|
||||||
assert.Equal(t, 0, datatable.DataRowCount())
|
assert.Equal(t, 0, datatable.DataRowCount())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,14 +50,16 @@ func TestCsvFileBasicDataTableHeaderColumnNames(t *testing.T) {
|
|||||||
{"A1", "B1", "C1"},
|
{"A1", "B1", "C1"},
|
||||||
{"A2", "B2", "C2"},
|
{"A2", "B2", "C2"},
|
||||||
{"A3", "B3", "C3"},
|
{"A3", "B3", "C3"},
|
||||||
})
|
}, true)
|
||||||
|
|
||||||
assert.EqualValues(t, []string{"A1", "B1", "C1"}, datatable.HeaderColumnNames())
|
assert.EqualValues(t, []string{"A1", "B1", "C1"}, datatable.HeaderColumnNames())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCsvFileBasicDataTableHeaderColumnNames_EmptyContent(t *testing.T) {
|
func TestCsvFileBasicDataTableHeaderColumnNames_EmptyContent(t *testing.T) {
|
||||||
datatable := CreateNewCustomCsvBasicDataTable([][]string{})
|
datatable := CreateNewCustomCsvBasicDataTable([][]string{}, false)
|
||||||
|
assert.Nil(t, datatable.HeaderColumnNames())
|
||||||
|
|
||||||
|
datatable = CreateNewCustomCsvBasicDataTable([][]string{}, true)
|
||||||
assert.Nil(t, datatable.HeaderColumnNames())
|
assert.Nil(t, datatable.HeaderColumnNames())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +68,34 @@ func TestCsvFileBasicDataTableRowIterator(t *testing.T) {
|
|||||||
{"A1", "B1", "C1"},
|
{"A1", "B1", "C1"},
|
||||||
{"A2", "B2", "C2"},
|
{"A2", "B2", "C2"},
|
||||||
{"A3", "B3", "C3"},
|
{"A3", "B3", "C3"},
|
||||||
})
|
}, false)
|
||||||
|
|
||||||
|
iterator := datatable.DataRowIterator()
|
||||||
|
assert.True(t, iterator.HasNext())
|
||||||
|
|
||||||
|
// data row 1
|
||||||
|
assert.NotNil(t, iterator.Next())
|
||||||
|
assert.True(t, iterator.HasNext())
|
||||||
|
|
||||||
|
// data row 2
|
||||||
|
assert.NotNil(t, iterator.Next())
|
||||||
|
assert.True(t, iterator.HasNext())
|
||||||
|
|
||||||
|
// data row 3
|
||||||
|
assert.NotNil(t, iterator.Next())
|
||||||
|
assert.False(t, iterator.HasNext())
|
||||||
|
|
||||||
|
// not existed data row 4
|
||||||
|
assert.Nil(t, iterator.Next())
|
||||||
|
assert.False(t, iterator.HasNext())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCsvFileBasicDataTableRowIterator_HasTitleLine(t *testing.T) {
|
||||||
|
datatable := CreateNewCustomCsvBasicDataTable([][]string{
|
||||||
|
{"A1", "B1", "C1"},
|
||||||
|
{"A2", "B2", "C2"},
|
||||||
|
{"A3", "B3", "C3"},
|
||||||
|
}, true)
|
||||||
|
|
||||||
iterator := datatable.DataRowIterator()
|
iterator := datatable.DataRowIterator()
|
||||||
assert.True(t, iterator.HasNext())
|
assert.True(t, iterator.HasNext())
|
||||||
@@ -81,7 +122,7 @@ func TestCsvFileBasicDataTableRowColumnCount(t *testing.T) {
|
|||||||
{"A1", "B1", "C1"},
|
{"A1", "B1", "C1"},
|
||||||
{"A2", "B2", "C2"},
|
{"A2", "B2", "C2"},
|
||||||
{"A3", "B3", "C3"},
|
{"A3", "B3", "C3"},
|
||||||
})
|
}, true)
|
||||||
|
|
||||||
iterator := datatable.DataRowIterator()
|
iterator := datatable.DataRowIterator()
|
||||||
|
|
||||||
@@ -97,7 +138,32 @@ func TestCsvFileBasicDataTableRowGetData(t *testing.T) {
|
|||||||
{"A1", "B1", "C1"},
|
{"A1", "B1", "C1"},
|
||||||
{"A2", "B2", "C2"},
|
{"A2", "B2", "C2"},
|
||||||
{"A3", "B3", "C3"},
|
{"A3", "B3", "C3"},
|
||||||
})
|
}, false)
|
||||||
|
|
||||||
|
iterator := datatable.DataRowIterator()
|
||||||
|
|
||||||
|
row1 := iterator.Next()
|
||||||
|
assert.Equal(t, "A1", row1.GetData(0))
|
||||||
|
assert.Equal(t, "B1", row1.GetData(1))
|
||||||
|
assert.Equal(t, "C1", row1.GetData(2))
|
||||||
|
|
||||||
|
row2 := iterator.Next()
|
||||||
|
assert.Equal(t, "A2", row2.GetData(0))
|
||||||
|
assert.Equal(t, "B2", row2.GetData(1))
|
||||||
|
assert.Equal(t, "C2", row2.GetData(2))
|
||||||
|
|
||||||
|
row3 := iterator.Next()
|
||||||
|
assert.Equal(t, "A3", row3.GetData(0))
|
||||||
|
assert.Equal(t, "B3", row3.GetData(1))
|
||||||
|
assert.Equal(t, "C3", row3.GetData(2))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCsvFileBasicDataTableRowGetData_HasTitleLine(t *testing.T) {
|
||||||
|
datatable := CreateNewCustomCsvBasicDataTable([][]string{
|
||||||
|
{"A1", "B1", "C1"},
|
||||||
|
{"A2", "B2", "C2"},
|
||||||
|
{"A3", "B3", "C3"},
|
||||||
|
}, true)
|
||||||
|
|
||||||
iterator := datatable.DataRowIterator()
|
iterator := datatable.DataRowIterator()
|
||||||
|
|
||||||
@@ -117,7 +183,7 @@ func TestCsvFileBasicDataTableRowGetData_GetNotExistedColumnData(t *testing.T) {
|
|||||||
{"A1", "B1", "C1"},
|
{"A1", "B1", "C1"},
|
||||||
{"A2", "B2", "C2"},
|
{"A2", "B2", "C2"},
|
||||||
{"A3", "B3", "C3"},
|
{"A3", "B3", "C3"},
|
||||||
})
|
}, true)
|
||||||
|
|
||||||
iterator := datatable.DataRowIterator()
|
iterator := datatable.DataRowIterator()
|
||||||
|
|
||||||
@@ -130,7 +196,7 @@ func TestCreateNewCsvBasicDataTable(t *testing.T) {
|
|||||||
reader := bytes.NewReader([]byte("A1,B1,C1\n" +
|
reader := bytes.NewReader([]byte("A1,B1,C1\n" +
|
||||||
"A2,B2,C2\n" +
|
"A2,B2,C2\n" +
|
||||||
"A3,B3,C3\n"))
|
"A3,B3,C3\n"))
|
||||||
datatable, err := CreateNewCsvBasicDataTable(context, reader)
|
datatable, err := CreateNewCsvBasicDataTable(context, reader, true)
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
|
|
||||||
assert.Equal(t, 2, datatable.DataRowCount())
|
assert.Equal(t, 2, datatable.DataRowCount())
|
||||||
@@ -160,7 +226,7 @@ func TestCreateNewCsvBasicDataTable_SkipBlankLine(t *testing.T) {
|
|||||||
"A2,B2,C2\n" +
|
"A2,B2,C2\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"A3,B3,C3\n"))
|
"A3,B3,C3\n"))
|
||||||
datatable, err := CreateNewCsvBasicDataTable(context, reader)
|
datatable, err := CreateNewCsvBasicDataTable(context, reader, true)
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
|
|
||||||
assert.Equal(t, 2, datatable.DataRowCount())
|
assert.Equal(t, 2, datatable.DataRowCount())
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package datatable
|
||||||
|
|
||||||
|
type testBasicDataTable struct {
|
||||||
|
headerColumns []string
|
||||||
|
rows []*testBasicDataTableRow
|
||||||
|
}
|
||||||
|
|
||||||
|
type testBasicDataTableRow struct {
|
||||||
|
rowId string
|
||||||
|
rowColumns []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type testBasicDataTableRowIterator struct {
|
||||||
|
rows []*testBasicDataTableRow
|
||||||
|
currentIndex int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *testBasicDataTable) HeaderColumnNames() []string {
|
||||||
|
return t.headerColumns
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *testBasicDataTable) DataRowCount() int {
|
||||||
|
return len(t.rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *testBasicDataTable) DataRowIterator() BasicDataTableRowIterator {
|
||||||
|
return &testBasicDataTableRowIterator{
|
||||||
|
rows: t.rows,
|
||||||
|
currentIndex: -1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *testBasicDataTableRow) ColumnCount() int {
|
||||||
|
return len(r.rowColumns)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *testBasicDataTableRow) GetData(columnIndex int) string {
|
||||||
|
if columnIndex < 0 || columnIndex >= len(r.rowColumns) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.rowColumns[columnIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *testBasicDataTableRowIterator) HasNext() bool {
|
||||||
|
return t.currentIndex+1 < len(t.rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *testBasicDataTableRowIterator) CurrentRowId() string {
|
||||||
|
if t.currentIndex >= len(t.rows) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return t.rows[t.currentIndex].rowId
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *testBasicDataTableRowIterator) Next() BasicDataTableRow {
|
||||||
|
if t.currentIndex+1 >= len(t.rows) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
t.currentIndex++
|
||||||
|
row := t.rows[t.currentIndex]
|
||||||
|
return row
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package datatable
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBasicDataTableToCommonDataTableWrapper_HeaderColumnCount(t *testing.T) {
|
||||||
|
columns := []string{"Col1", "Col2", "Col3"}
|
||||||
|
basicDataTable := &testBasicDataTable{
|
||||||
|
headerColumns: columns,
|
||||||
|
rows: []*testBasicDataTableRow{},
|
||||||
|
}
|
||||||
|
|
||||||
|
commonDataTable := CreateNewCommonDataTableFromBasicDataTable(basicDataTable)
|
||||||
|
assert.Equal(t, len(columns), commonDataTable.HeaderColumnCount())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBasicDataTableToCommonDataTableWrapper_HasColumn(t *testing.T) {
|
||||||
|
columns := []string{"Col1", "Col2", "Col3"}
|
||||||
|
basicDataTable := &testBasicDataTable{
|
||||||
|
headerColumns: columns,
|
||||||
|
rows: []*testBasicDataTableRow{},
|
||||||
|
}
|
||||||
|
|
||||||
|
commonDataTable := CreateNewCommonDataTableFromBasicDataTable(basicDataTable)
|
||||||
|
|
||||||
|
assert.True(t, commonDataTable.HasColumn("Col1"))
|
||||||
|
assert.True(t, commonDataTable.HasColumn("Col2"))
|
||||||
|
assert.True(t, commonDataTable.HasColumn("Col3"))
|
||||||
|
|
||||||
|
assert.False(t, commonDataTable.HasColumn("Col4"))
|
||||||
|
assert.False(t, commonDataTable.HasColumn(""))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBasicDataTableToCommonDataTableWrapper_DataRowCount(t *testing.T) {
|
||||||
|
columns := []string{"Col1", "Col2", "Col3"}
|
||||||
|
rows := []*testBasicDataTableRow{
|
||||||
|
{
|
||||||
|
rowId: "1",
|
||||||
|
rowColumns: []string{"A1", "B1", "C1"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rowId: "2",
|
||||||
|
rowColumns: []string{"A2", "B2", "C2"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rowId: "3",
|
||||||
|
rowColumns: []string{"A3", "B3", "C3"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
basicDataTable := &testBasicDataTable{
|
||||||
|
headerColumns: columns,
|
||||||
|
rows: rows,
|
||||||
|
}
|
||||||
|
|
||||||
|
commonDataTable := CreateNewCommonDataTableFromBasicDataTable(basicDataTable)
|
||||||
|
assert.Equal(t, len(rows), commonDataTable.DataRowCount())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBasicDataTableToCommonDataTableWrapper_DataRowIterator(t *testing.T) {
|
||||||
|
columns := []string{"Col1", "Col2", "Col3"}
|
||||||
|
rows := []*testBasicDataTableRow{
|
||||||
|
{
|
||||||
|
rowId: "1",
|
||||||
|
rowColumns: []string{"A1", "B1", "C1"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rowId: "2",
|
||||||
|
rowColumns: []string{"A2", "B2", "C2"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
basicDataTable := &testBasicDataTable{
|
||||||
|
headerColumns: columns,
|
||||||
|
rows: rows,
|
||||||
|
}
|
||||||
|
|
||||||
|
commonDataTable := CreateNewCommonDataTableFromBasicDataTable(basicDataTable)
|
||||||
|
iterator := commonDataTable.DataRowIterator()
|
||||||
|
|
||||||
|
assert.True(t, iterator.HasNext())
|
||||||
|
firstRow := iterator.Next()
|
||||||
|
assert.NotNil(t, firstRow)
|
||||||
|
assert.Equal(t, len(columns), firstRow.ColumnCount())
|
||||||
|
assert.True(t, firstRow.HasData("Col1"))
|
||||||
|
assert.True(t, firstRow.HasData("Col2"))
|
||||||
|
assert.True(t, firstRow.HasData("Col3"))
|
||||||
|
assert.Equal(t, "A1", firstRow.GetData("Col1"))
|
||||||
|
assert.Equal(t, "B1", firstRow.GetData("Col2"))
|
||||||
|
assert.Equal(t, "C1", firstRow.GetData("Col3"))
|
||||||
|
|
||||||
|
assert.True(t, iterator.HasNext())
|
||||||
|
secondRow := iterator.Next()
|
||||||
|
assert.NotNil(t, secondRow)
|
||||||
|
assert.Equal(t, len(columns), secondRow.ColumnCount())
|
||||||
|
assert.True(t, secondRow.HasData("Col1"))
|
||||||
|
assert.True(t, secondRow.HasData("Col2"))
|
||||||
|
assert.True(t, secondRow.HasData("Col3"))
|
||||||
|
assert.Equal(t, "A2", secondRow.GetData("Col1"))
|
||||||
|
assert.Equal(t, "B2", secondRow.GetData("Col2"))
|
||||||
|
assert.Equal(t, "C2", secondRow.GetData("Col3"))
|
||||||
|
|
||||||
|
assert.False(t, iterator.HasNext())
|
||||||
|
assert.Nil(t, iterator.Next())
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
package datatable
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||||
|
)
|
||||||
|
|
||||||
|
type testTransactionDataRowParser struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *testTransactionDataRowParser) Parse(rowData map[TransactionDataTableColumn]string) (map[TransactionDataTableColumn]string, bool, error) {
|
||||||
|
rowData[TRANSACTION_DATA_TABLE_DESCRIPTION] = "Test Description"
|
||||||
|
return rowData, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *testTransactionDataRowParser) GetAddedColumns() []TransactionDataTableColumn {
|
||||||
|
return []TransactionDataTableColumn{TRANSACTION_DATA_TABLE_DESCRIPTION}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBasicDataTableToTransactionDataTableWrapper_HasColumn(t *testing.T) {
|
||||||
|
columns := []string{"TransactionTime", "TransactionType", "Amount"}
|
||||||
|
basicDataTable := &testBasicDataTable{
|
||||||
|
headerColumns: columns,
|
||||||
|
rows: []*testBasicDataTableRow{},
|
||||||
|
}
|
||||||
|
|
||||||
|
columnMapping := map[TransactionDataTableColumn]string{
|
||||||
|
TRANSACTION_DATA_TABLE_TRANSACTION_TIME: "TransactionTime",
|
||||||
|
TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: "TransactionType",
|
||||||
|
TRANSACTION_DATA_TABLE_AMOUNT: "Amount",
|
||||||
|
}
|
||||||
|
|
||||||
|
transactionDataTable := CreateNewTransactionDataTableFromBasicDataTable(basicDataTable, columnMapping)
|
||||||
|
|
||||||
|
assert.True(t, transactionDataTable.HasColumn(TRANSACTION_DATA_TABLE_TRANSACTION_TIME))
|
||||||
|
assert.True(t, transactionDataTable.HasColumn(TRANSACTION_DATA_TABLE_TRANSACTION_TYPE))
|
||||||
|
assert.True(t, transactionDataTable.HasColumn(TRANSACTION_DATA_TABLE_AMOUNT))
|
||||||
|
|
||||||
|
assert.False(t, transactionDataTable.HasColumn(TRANSACTION_DATA_TABLE_DESCRIPTION))
|
||||||
|
assert.False(t, transactionDataTable.HasColumn(TRANSACTION_DATA_TABLE_CATEGORY))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBasicDataTableToTransactionDataTableWrapper_TransactionRowCount(t *testing.T) {
|
||||||
|
columns := []string{"TransactionTime", "TransactionType", "Amount"}
|
||||||
|
rows := []*testBasicDataTableRow{
|
||||||
|
{
|
||||||
|
rowId: "1",
|
||||||
|
rowColumns: []string{"2024-01-01", "1", "100"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rowId: "2",
|
||||||
|
rowColumns: []string{"2024-01-02", "2", "200"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rowId: "3",
|
||||||
|
rowColumns: []string{"2024-01-03", "1", "300"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
basicDataTable := &testBasicDataTable{
|
||||||
|
headerColumns: columns,
|
||||||
|
rows: rows,
|
||||||
|
}
|
||||||
|
|
||||||
|
columnMapping := map[TransactionDataTableColumn]string{
|
||||||
|
TRANSACTION_DATA_TABLE_TRANSACTION_TIME: "TransactionTime",
|
||||||
|
TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: "TransactionType",
|
||||||
|
TRANSACTION_DATA_TABLE_AMOUNT: "Amount",
|
||||||
|
}
|
||||||
|
|
||||||
|
transactionDataTable := CreateNewTransactionDataTableFromBasicDataTable(basicDataTable, columnMapping)
|
||||||
|
assert.Equal(t, len(rows), transactionDataTable.TransactionRowCount())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBasicDataTableToTransactionDataTableWrapper_TransactionRowIterator(t *testing.T) {
|
||||||
|
columns := []string{"TransactionTime", "TransactionType", "Amount"}
|
||||||
|
rows := []*testBasicDataTableRow{
|
||||||
|
{
|
||||||
|
rowId: "1",
|
||||||
|
rowColumns: []string{"2024-01-01", "1", "100"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rowId: "2",
|
||||||
|
rowColumns: []string{"2024-01-02", "2", "200"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
basicDataTable := &testBasicDataTable{
|
||||||
|
headerColumns: columns,
|
||||||
|
rows: rows,
|
||||||
|
}
|
||||||
|
|
||||||
|
columnMapping := map[TransactionDataTableColumn]string{
|
||||||
|
TRANSACTION_DATA_TABLE_TRANSACTION_TIME: "TransactionTime",
|
||||||
|
TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: "TransactionType",
|
||||||
|
TRANSACTION_DATA_TABLE_AMOUNT: "Amount",
|
||||||
|
}
|
||||||
|
|
||||||
|
transactionDataTable := CreateNewTransactionDataTableFromBasicDataTable(basicDataTable, columnMapping)
|
||||||
|
iterator := transactionDataTable.TransactionRowIterator()
|
||||||
|
|
||||||
|
assert.True(t, iterator.HasNext())
|
||||||
|
firstRow, err := iterator.Next(nil, nil)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.NotNil(t, firstRow)
|
||||||
|
assert.True(t, firstRow.IsValid())
|
||||||
|
assert.Equal(t, "2024-01-01", firstRow.GetData(TRANSACTION_DATA_TABLE_TRANSACTION_TIME))
|
||||||
|
assert.Equal(t, "1", firstRow.GetData(TRANSACTION_DATA_TABLE_TRANSACTION_TYPE))
|
||||||
|
assert.Equal(t, "100", firstRow.GetData(TRANSACTION_DATA_TABLE_AMOUNT))
|
||||||
|
|
||||||
|
assert.True(t, iterator.HasNext())
|
||||||
|
secondRow, err := iterator.Next(nil, nil)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.NotNil(t, secondRow)
|
||||||
|
assert.True(t, secondRow.IsValid())
|
||||||
|
assert.Equal(t, "2024-01-02", secondRow.GetData(TRANSACTION_DATA_TABLE_TRANSACTION_TIME))
|
||||||
|
assert.Equal(t, "2", secondRow.GetData(TRANSACTION_DATA_TABLE_TRANSACTION_TYPE))
|
||||||
|
assert.Equal(t, "200", secondRow.GetData(TRANSACTION_DATA_TABLE_AMOUNT))
|
||||||
|
|
||||||
|
assert.False(t, iterator.HasNext())
|
||||||
|
emptyRow, err := iterator.Next(nil, nil)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Nil(t, emptyRow)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBasicDataTableToTransactionDataTableWrapper_TransactionRowIterator_EmptyRow(t *testing.T) {
|
||||||
|
columns := []string{"TransactionTime", "TransactionType", "Amount"}
|
||||||
|
rows := []*testBasicDataTableRow{
|
||||||
|
{
|
||||||
|
rowId: "1",
|
||||||
|
rowColumns: []string{""},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
basicDataTable := &testBasicDataTable{
|
||||||
|
headerColumns: columns,
|
||||||
|
rows: rows,
|
||||||
|
}
|
||||||
|
|
||||||
|
columnMapping := map[TransactionDataTableColumn]string{
|
||||||
|
TRANSACTION_DATA_TABLE_TRANSACTION_TIME: "TransactionTime",
|
||||||
|
TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: "TransactionType",
|
||||||
|
TRANSACTION_DATA_TABLE_AMOUNT: "Amount",
|
||||||
|
}
|
||||||
|
|
||||||
|
transactionDataTable := CreateNewTransactionDataTableFromBasicDataTable(basicDataTable, columnMapping)
|
||||||
|
iterator := transactionDataTable.TransactionRowIterator()
|
||||||
|
|
||||||
|
assert.True(t, iterator.HasNext())
|
||||||
|
row, err := iterator.Next(nil, nil)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.NotNil(t, row)
|
||||||
|
assert.False(t, row.IsValid())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBasicDataTableToTransactionDataTableWrapper_TransactionRowIterator_InvalidRow(t *testing.T) {
|
||||||
|
columns := []string{"TransactionTime", "TransactionType", "Amount"}
|
||||||
|
rows := []*testBasicDataTableRow{
|
||||||
|
{
|
||||||
|
rowId: "1",
|
||||||
|
rowColumns: []string{"2024-01-01", "1"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
basicDataTable := &testBasicDataTable{
|
||||||
|
headerColumns: columns,
|
||||||
|
rows: rows,
|
||||||
|
}
|
||||||
|
|
||||||
|
columnMapping := map[TransactionDataTableColumn]string{
|
||||||
|
TRANSACTION_DATA_TABLE_TRANSACTION_TIME: "TransactionTime",
|
||||||
|
TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: "TransactionType",
|
||||||
|
TRANSACTION_DATA_TABLE_AMOUNT: "Amount",
|
||||||
|
}
|
||||||
|
|
||||||
|
transactionDataTable := CreateNewTransactionDataTableFromBasicDataTable(basicDataTable, columnMapping)
|
||||||
|
iterator := transactionDataTable.TransactionRowIterator()
|
||||||
|
|
||||||
|
assert.True(t, iterator.HasNext())
|
||||||
|
row, err := iterator.Next(nil, nil)
|
||||||
|
assert.NotNil(t, err)
|
||||||
|
assert.Equal(t, errs.ErrFewerFieldsInDataRowThanInHeaderRow, err)
|
||||||
|
assert.Nil(t, row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBasicDataTableToTransactionDataTableWrapper_TransactionRowIterator_WithRowParserAddedColumn(t *testing.T) {
|
||||||
|
columns := []string{"TransactionTime", "TransactionType", "Amount"}
|
||||||
|
rows := []*testBasicDataTableRow{
|
||||||
|
{
|
||||||
|
rowId: "1",
|
||||||
|
rowColumns: []string{"2024-01-01", "1", "100"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
basicDataTable := &testBasicDataTable{
|
||||||
|
headerColumns: columns,
|
||||||
|
rows: rows,
|
||||||
|
}
|
||||||
|
|
||||||
|
columnMapping := map[TransactionDataTableColumn]string{
|
||||||
|
TRANSACTION_DATA_TABLE_TRANSACTION_TIME: "TransactionTime",
|
||||||
|
TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: "TransactionType",
|
||||||
|
TRANSACTION_DATA_TABLE_AMOUNT: "Amount",
|
||||||
|
TRANSACTION_DATA_TABLE_DESCRIPTION: "Description",
|
||||||
|
}
|
||||||
|
|
||||||
|
transactionDataTable := CreateNewTransactionDataTableFromBasicDataTableWithRowParser(basicDataTable, columnMapping, &testTransactionDataRowParser{})
|
||||||
|
assert.True(t, transactionDataTable.HasColumn(TRANSACTION_DATA_TABLE_DESCRIPTION))
|
||||||
|
|
||||||
|
iterator := transactionDataTable.TransactionRowIterator()
|
||||||
|
assert.True(t, iterator.HasNext())
|
||||||
|
row, err := iterator.Next(nil, nil)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.NotNil(t, row)
|
||||||
|
assert.True(t, row.IsValid())
|
||||||
|
assert.Equal(t, "Test Description", row.GetData(TRANSACTION_DATA_TABLE_DESCRIPTION))
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
package datatable
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type testCommonDataTable struct {
|
||||||
|
headerColumns []string
|
||||||
|
dataRows []*testCommonDataTableRow
|
||||||
|
}
|
||||||
|
|
||||||
|
type testCommonDataTableRow struct {
|
||||||
|
rowId string
|
||||||
|
rowData map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
type testCommonDataTableRowIterator struct {
|
||||||
|
dataTable *testCommonDataTable
|
||||||
|
currentIndex int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *testCommonDataTable) DataRowCount() int {
|
||||||
|
return len(t.dataRows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *testCommonDataTable) HeaderColumnCount() int {
|
||||||
|
return len(t.headerColumns)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *testCommonDataTable) HasColumn(columnName string) bool {
|
||||||
|
for _, header := range t.headerColumns {
|
||||||
|
if header == columnName {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *testCommonDataTable) DataRowIterator() CommonDataTableRowIterator {
|
||||||
|
return &testCommonDataTableRowIterator{
|
||||||
|
dataTable: t,
|
||||||
|
currentIndex: -1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *testCommonDataTableRow) GetData(dataKey string) string {
|
||||||
|
return t.rowData[dataKey]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *testCommonDataTableRow) HasData(dataKey string) bool {
|
||||||
|
_, exists := t.rowData[dataKey]
|
||||||
|
return exists
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *testCommonDataTableRow) ColumnCount() int {
|
||||||
|
return len(t.rowData)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *testCommonDataTableRowIterator) HasNext() bool {
|
||||||
|
return t.currentIndex+1 < len(t.dataTable.dataRows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *testCommonDataTableRowIterator) Next() CommonDataTableRow {
|
||||||
|
if !t.HasNext() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
t.currentIndex++
|
||||||
|
return t.dataTable.dataRows[t.currentIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *testCommonDataTableRowIterator) CurrentRowId() string {
|
||||||
|
if t.currentIndex < 0 || t.currentIndex >= len(t.dataTable.dataRows) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return t.dataTable.dataRows[t.currentIndex].rowId
|
||||||
|
}
|
||||||
|
|
||||||
|
type testCommonTransactionDataRowParser struct {
|
||||||
|
returnError bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *testCommonTransactionDataRowParser) Parse(ctx core.Context, user *models.User, dataRow CommonDataTableRow, rowId string) (map[TransactionDataTableColumn]string, bool, error) {
|
||||||
|
if p.returnError {
|
||||||
|
return nil, false, errs.ErrOperationFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
rowData := make(map[TransactionDataTableColumn]string)
|
||||||
|
rowData[TRANSACTION_DATA_TABLE_TRANSACTION_TIME] = dataRow.GetData("TransactionTime")
|
||||||
|
rowData[TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = dataRow.GetData("TransactionType")
|
||||||
|
rowData[TRANSACTION_DATA_TABLE_AMOUNT] = dataRow.GetData("Amount")
|
||||||
|
rowData[TRANSACTION_DATA_TABLE_DESCRIPTION] = "Test Description"
|
||||||
|
return rowData, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommonDataTableToTransactionDataTableWrapper_HasColumn(t *testing.T) {
|
||||||
|
basicDataTable := &testCommonDataTable{
|
||||||
|
headerColumns: []string{"TransactionTime", "TransactionType", "Amount"},
|
||||||
|
dataRows: []*testCommonDataTableRow{},
|
||||||
|
}
|
||||||
|
|
||||||
|
supportedColumns := map[TransactionDataTableColumn]bool{
|
||||||
|
TRANSACTION_DATA_TABLE_TRANSACTION_TIME: true,
|
||||||
|
TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: true,
|
||||||
|
TRANSACTION_DATA_TABLE_AMOUNT: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
transactionDataTable := CreateNewTransactionDataTableFromCommonDataTable(basicDataTable, supportedColumns, &testCommonTransactionDataRowParser{})
|
||||||
|
|
||||||
|
assert.True(t, transactionDataTable.HasColumn(TRANSACTION_DATA_TABLE_TRANSACTION_TIME))
|
||||||
|
assert.True(t, transactionDataTable.HasColumn(TRANSACTION_DATA_TABLE_TRANSACTION_TYPE))
|
||||||
|
assert.True(t, transactionDataTable.HasColumn(TRANSACTION_DATA_TABLE_AMOUNT))
|
||||||
|
|
||||||
|
assert.False(t, transactionDataTable.HasColumn(TRANSACTION_DATA_TABLE_CATEGORY))
|
||||||
|
assert.False(t, transactionDataTable.HasColumn(TRANSACTION_DATA_TABLE_DESCRIPTION))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommonDataTableToTransactionDataTableWrapper_TransactionRowCount(t *testing.T) {
|
||||||
|
rows := []*testCommonDataTableRow{
|
||||||
|
{
|
||||||
|
rowId: "1",
|
||||||
|
rowData: map[string]string{
|
||||||
|
"TransactionTime": "2024-01-01",
|
||||||
|
"TransactionType": "1",
|
||||||
|
"Amount": "100",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rowId: "2",
|
||||||
|
rowData: map[string]string{
|
||||||
|
"TransactionTime": "2024-01-02",
|
||||||
|
"TransactionType": "2",
|
||||||
|
"Amount": "200",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rowId: "3",
|
||||||
|
rowData: map[string]string{
|
||||||
|
"TransactionTime": "2024-01-03",
|
||||||
|
"TransactionType": "1",
|
||||||
|
"Amount": "300",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
basicDataTable := &testCommonDataTable{
|
||||||
|
headerColumns: []string{"TransactionTime", "TransactionType", "Amount"},
|
||||||
|
dataRows: rows,
|
||||||
|
}
|
||||||
|
|
||||||
|
supportedColumns := map[TransactionDataTableColumn]bool{
|
||||||
|
TRANSACTION_DATA_TABLE_TRANSACTION_TIME: true,
|
||||||
|
TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: true,
|
||||||
|
TRANSACTION_DATA_TABLE_AMOUNT: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
transactionDataTable := CreateNewTransactionDataTableFromCommonDataTable(basicDataTable, supportedColumns, &testCommonTransactionDataRowParser{})
|
||||||
|
assert.Equal(t, len(rows), transactionDataTable.TransactionRowCount())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommonDataTableToTransactionDataTableWrapper_TransactionRowIterator(t *testing.T) {
|
||||||
|
rows := []*testCommonDataTableRow{
|
||||||
|
{
|
||||||
|
rowId: "1",
|
||||||
|
rowData: map[string]string{
|
||||||
|
"TransactionTime": "2024-01-01",
|
||||||
|
"TransactionType": "1",
|
||||||
|
"Amount": "100",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rowId: "2",
|
||||||
|
rowData: map[string]string{
|
||||||
|
"TransactionTime": "2024-01-02",
|
||||||
|
"TransactionType": "2",
|
||||||
|
"Amount": "200",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
basicDataTable := &testCommonDataTable{
|
||||||
|
headerColumns: []string{"TransactionTime", "TransactionType", "Amount"},
|
||||||
|
dataRows: rows,
|
||||||
|
}
|
||||||
|
|
||||||
|
supportedColumns := map[TransactionDataTableColumn]bool{
|
||||||
|
TRANSACTION_DATA_TABLE_TRANSACTION_TIME: true,
|
||||||
|
TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: true,
|
||||||
|
TRANSACTION_DATA_TABLE_AMOUNT: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
transactionDataTable := CreateNewTransactionDataTableFromCommonDataTable(basicDataTable, supportedColumns, &testCommonTransactionDataRowParser{})
|
||||||
|
iterator := transactionDataTable.TransactionRowIterator()
|
||||||
|
|
||||||
|
assert.True(t, iterator.HasNext())
|
||||||
|
firstRow, err := iterator.Next(nil, nil)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.NotNil(t, firstRow)
|
||||||
|
assert.True(t, firstRow.IsValid())
|
||||||
|
assert.Equal(t, "2024-01-01", firstRow.GetData(TRANSACTION_DATA_TABLE_TRANSACTION_TIME))
|
||||||
|
assert.Equal(t, "1", firstRow.GetData(TRANSACTION_DATA_TABLE_TRANSACTION_TYPE))
|
||||||
|
assert.Equal(t, "100", firstRow.GetData(TRANSACTION_DATA_TABLE_AMOUNT))
|
||||||
|
assert.Equal(t, "", firstRow.GetData(TRANSACTION_DATA_TABLE_DESCRIPTION))
|
||||||
|
|
||||||
|
assert.True(t, iterator.HasNext())
|
||||||
|
secondRow, err := iterator.Next(nil, nil)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.NotNil(t, secondRow)
|
||||||
|
assert.True(t, secondRow.IsValid())
|
||||||
|
assert.Equal(t, "2024-01-02", secondRow.GetData(TRANSACTION_DATA_TABLE_TRANSACTION_TIME))
|
||||||
|
assert.Equal(t, "2", secondRow.GetData(TRANSACTION_DATA_TABLE_TRANSACTION_TYPE))
|
||||||
|
assert.Equal(t, "200", secondRow.GetData(TRANSACTION_DATA_TABLE_AMOUNT))
|
||||||
|
assert.Equal(t, "", secondRow.GetData(TRANSACTION_DATA_TABLE_DESCRIPTION))
|
||||||
|
|
||||||
|
assert.False(t, iterator.HasNext())
|
||||||
|
emptyRow, err := iterator.Next(nil, nil)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Nil(t, emptyRow)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommonDataTableToTransactionDataTableWrapper_TransactionRowIterator_EOF(t *testing.T) {
|
||||||
|
rows := []*testCommonDataTableRow{
|
||||||
|
{
|
||||||
|
rowId: "1",
|
||||||
|
rowData: map[string]string{
|
||||||
|
"TransactionTime": "2024-01-01",
|
||||||
|
"TransactionType": "1",
|
||||||
|
"Amount": "100",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
basicDataTable := &testCommonDataTable{
|
||||||
|
headerColumns: []string{"TransactionTime", "TransactionType", "Amount"},
|
||||||
|
dataRows: rows,
|
||||||
|
}
|
||||||
|
|
||||||
|
supportedColumns := map[TransactionDataTableColumn]bool{
|
||||||
|
TRANSACTION_DATA_TABLE_TRANSACTION_TIME: true,
|
||||||
|
TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: true,
|
||||||
|
TRANSACTION_DATA_TABLE_AMOUNT: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
transactionDataTable := CreateNewTransactionDataTableFromCommonDataTable(basicDataTable, supportedColumns, &testCommonTransactionDataRowParser{returnError: true})
|
||||||
|
iterator := transactionDataTable.TransactionRowIterator()
|
||||||
|
|
||||||
|
assert.True(t, iterator.HasNext())
|
||||||
|
row, err := iterator.Next(nil, nil)
|
||||||
|
assert.EqualError(t, err, errs.ErrOperationFailed.Message)
|
||||||
|
assert.Nil(t, row)
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package datatable
|
||||||
|
|
||||||
|
// SubBasicDataTable defines the structure of sub basic data table
|
||||||
|
type SubBasicDataTable struct {
|
||||||
|
baseTable BasicDataTable
|
||||||
|
fromIndex int
|
||||||
|
toIndex int
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubBasicDataTableRowIterator defines the structure of sub basic data table row iterator
|
||||||
|
type SubBasicDataTableRowIterator struct {
|
||||||
|
dataTable *SubBasicDataTable
|
||||||
|
innerIterator BasicDataTableRowIterator
|
||||||
|
currentIndex int
|
||||||
|
}
|
||||||
|
|
||||||
|
// DataRowCount returns the total count of data row
|
||||||
|
func (t *SubBasicDataTable) DataRowCount() int {
|
||||||
|
return t.toIndex - t.fromIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
// HeaderColumnNames returns the header column name list
|
||||||
|
func (t *SubBasicDataTable) HeaderColumnNames() []string {
|
||||||
|
return t.baseTable.HeaderColumnNames()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DataRowIterator returns the iterator of data row
|
||||||
|
func (t *SubBasicDataTable) DataRowIterator() BasicDataTableRowIterator {
|
||||||
|
innerIterator := t.baseTable.DataRowIterator()
|
||||||
|
currentIndex := -1
|
||||||
|
|
||||||
|
// skip rows until reaching the fromIndex
|
||||||
|
for currentIndex = -1; currentIndex < t.fromIndex-1 && innerIterator.HasNext(); currentIndex++ {
|
||||||
|
innerIterator.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
return &SubBasicDataTableRowIterator{
|
||||||
|
dataTable: t,
|
||||||
|
innerIterator: innerIterator,
|
||||||
|
currentIndex: currentIndex,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasNext returns whether the iterator does not reach the end
|
||||||
|
func (t *SubBasicDataTableRowIterator) HasNext() bool {
|
||||||
|
return t.currentIndex+1 < t.dataTable.toIndex && t.innerIterator.HasNext()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CurrentRowId returns current row id
|
||||||
|
func (t *SubBasicDataTableRowIterator) CurrentRowId() string {
|
||||||
|
return t.innerIterator.CurrentRowId()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next returns the next basic data row
|
||||||
|
func (t *SubBasicDataTableRowIterator) Next() BasicDataTableRow {
|
||||||
|
if t.currentIndex+1 >= t.dataTable.toIndex {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
t.currentIndex++
|
||||||
|
return t.innerIterator.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSubBasicTable returns a sub basic data table that references a portion of the original table
|
||||||
|
func CreateSubBasicTable(dataTable BasicDataTable, fromIndex, toIndex int) *SubBasicDataTable {
|
||||||
|
if fromIndex < 0 {
|
||||||
|
fromIndex = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if fromIndex > dataTable.DataRowCount() {
|
||||||
|
fromIndex = dataTable.DataRowCount()
|
||||||
|
}
|
||||||
|
|
||||||
|
if toIndex > dataTable.DataRowCount() {
|
||||||
|
toIndex = dataTable.DataRowCount()
|
||||||
|
}
|
||||||
|
|
||||||
|
if toIndex < fromIndex {
|
||||||
|
toIndex = fromIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
return &SubBasicDataTable{
|
||||||
|
baseTable: dataTable,
|
||||||
|
fromIndex: fromIndex,
|
||||||
|
toIndex: toIndex,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package datatable
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCreateSubBasicTable_WithValidInput(t *testing.T) {
|
||||||
|
columns := []string{"Col1", "Col2", "Col3"}
|
||||||
|
rows := []*testBasicDataTableRow{
|
||||||
|
{
|
||||||
|
rowId: "1",
|
||||||
|
rowColumns: []string{"A1", "B1", "C1"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rowId: "2",
|
||||||
|
rowColumns: []string{"A2", "B2", "C2"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rowId: "3",
|
||||||
|
rowColumns: []string{"A3", "B3", "C3"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
basicDataTable := &testBasicDataTable{
|
||||||
|
headerColumns: columns,
|
||||||
|
rows: rows,
|
||||||
|
}
|
||||||
|
|
||||||
|
subTable := CreateSubBasicTable(basicDataTable, 1, 2)
|
||||||
|
assert.Equal(t, 1, subTable.DataRowCount())
|
||||||
|
assert.Equal(t, columns, subTable.HeaderColumnNames())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateSubBasicTable_WithInvalidInput(t *testing.T) {
|
||||||
|
columns := []string{"Col1", "Col2", "Col3"}
|
||||||
|
rows := []*testBasicDataTableRow{
|
||||||
|
{
|
||||||
|
rowId: "1",
|
||||||
|
rowColumns: []string{"A1", "B1", "C1"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rowId: "2",
|
||||||
|
rowColumns: []string{"A2", "B2", "C2"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
basicDataTable := &testBasicDataTable{
|
||||||
|
headerColumns: columns,
|
||||||
|
rows: rows,
|
||||||
|
}
|
||||||
|
|
||||||
|
subTable := CreateSubBasicTable(basicDataTable, -1, 2)
|
||||||
|
assert.Equal(t, 0, subTable.fromIndex)
|
||||||
|
assert.Equal(t, 2, subTable.toIndex)
|
||||||
|
|
||||||
|
subTable = CreateSubBasicTable(basicDataTable, 5, 2)
|
||||||
|
assert.Equal(t, 2, subTable.fromIndex)
|
||||||
|
assert.Equal(t, 2, subTable.toIndex)
|
||||||
|
|
||||||
|
subTable = CreateSubBasicTable(basicDataTable, 0, 5)
|
||||||
|
assert.Equal(t, 0, subTable.fromIndex)
|
||||||
|
assert.Equal(t, 2, subTable.toIndex)
|
||||||
|
|
||||||
|
subTable = CreateSubBasicTable(basicDataTable, 2, 1)
|
||||||
|
assert.Equal(t, 2, subTable.fromIndex)
|
||||||
|
assert.Equal(t, 2, subTable.toIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubBasicDataTable_DataRowIterator(t *testing.T) {
|
||||||
|
columns := []string{"Col1", "Col2", "Col3"}
|
||||||
|
rows := []*testBasicDataTableRow{
|
||||||
|
{
|
||||||
|
rowId: "1",
|
||||||
|
rowColumns: []string{"A1", "B1", "C1"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rowId: "2",
|
||||||
|
rowColumns: []string{"A2", "B2", "C2"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rowId: "3",
|
||||||
|
rowColumns: []string{"A3", "B3", "C3"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
basicDataTable := &testBasicDataTable{
|
||||||
|
headerColumns: columns,
|
||||||
|
rows: rows,
|
||||||
|
}
|
||||||
|
|
||||||
|
subTable := CreateSubBasicTable(basicDataTable, 1, 3)
|
||||||
|
iterator := subTable.DataRowIterator()
|
||||||
|
|
||||||
|
assert.True(t, iterator.HasNext())
|
||||||
|
firstRow := iterator.Next()
|
||||||
|
assert.NotNil(t, firstRow)
|
||||||
|
assert.Equal(t, "2", iterator.CurrentRowId())
|
||||||
|
assert.Equal(t, "A2", firstRow.GetData(0))
|
||||||
|
assert.Equal(t, "B2", firstRow.GetData(1))
|
||||||
|
assert.Equal(t, "C2", firstRow.GetData(2))
|
||||||
|
|
||||||
|
assert.True(t, iterator.HasNext())
|
||||||
|
secondRow := iterator.Next()
|
||||||
|
assert.NotNil(t, secondRow)
|
||||||
|
assert.Equal(t, "3", iterator.CurrentRowId())
|
||||||
|
assert.Equal(t, "A3", secondRow.GetData(0))
|
||||||
|
assert.Equal(t, "B3", secondRow.GetData(1))
|
||||||
|
assert.Equal(t, "C3", secondRow.GetData(2))
|
||||||
|
|
||||||
|
assert.False(t, iterator.HasNext())
|
||||||
|
assert.Nil(t, iterator.Next())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubBasicDataTable_EmptyDataRange(t *testing.T) {
|
||||||
|
columns := []string{"Col1", "Col2", "Col3"}
|
||||||
|
rows := []*testBasicDataTableRow{
|
||||||
|
{
|
||||||
|
rowId: "1",
|
||||||
|
rowColumns: []string{"A1", "B1", "C1"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rowId: "2",
|
||||||
|
rowColumns: []string{"A2", "B2", "C2"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
basicDataTable := &testBasicDataTable{
|
||||||
|
headerColumns: columns,
|
||||||
|
rows: rows,
|
||||||
|
}
|
||||||
|
|
||||||
|
subTable := CreateSubBasicTable(basicDataTable, 1, 1)
|
||||||
|
assert.Equal(t, 0, subTable.DataRowCount())
|
||||||
|
|
||||||
|
iterator := subTable.DataRowIterator()
|
||||||
|
assert.False(t, iterator.HasNext())
|
||||||
|
assert.Nil(t, iterator.Next())
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package _default
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/converters/converter"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
var allJsonDataSupportedColumns = []datatable.TransactionDataTableColumn{
|
||||||
|
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME,
|
||||||
|
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE,
|
||||||
|
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE,
|
||||||
|
datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY,
|
||||||
|
datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME,
|
||||||
|
datatable.TRANSACTION_DATA_TABLE_AMOUNT,
|
||||||
|
datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME,
|
||||||
|
datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT,
|
||||||
|
datatable.TRANSACTION_DATA_TABLE_GEOGRAPHIC_LOCATION,
|
||||||
|
datatable.TRANSACTION_DATA_TABLE_TAGS,
|
||||||
|
datatable.TRANSACTION_DATA_TABLE_DESCRIPTION,
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultTransactionDataJsonImporter defines the structure of ezbookkeeping default json importer for transaction data
|
||||||
|
type defaultTransactionDataJsonImporter struct{}
|
||||||
|
|
||||||
|
// Initialize an ezbookkeeping default transaction data json file importer singleton instance
|
||||||
|
var (
|
||||||
|
DefaultTransactionDataJsonFileImporter = &defaultTransactionDataJsonImporter{}
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParseImportedData returns the imported data by parsing the transaction json data
|
||||||
|
func (c *defaultTransactionDataJsonImporter) ParseImportedData(ctx core.Context, user *models.User, data []byte, defaultTimezoneOffset int16, accountMap map[string]*models.Account, expenseCategoryMap map[string]map[string]*models.TransactionCategory, incomeCategoryMap map[string]map[string]*models.TransactionCategory, transferCategoryMap map[string]map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) (models.ImportedTransactionSlice, []*models.Account, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionTag, error) {
|
||||||
|
var importRequest models.ImportTransactionRequest
|
||||||
|
|
||||||
|
if err := json.Unmarshal(data, &importRequest); err != nil {
|
||||||
|
return nil, nil, nil, nil, nil, nil, errs.ErrInvalidJSONFile
|
||||||
|
}
|
||||||
|
|
||||||
|
transactionDataTable, err := c.createNewDefaultTransactionDataTable(importRequest)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, nil, nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dataTableImporter := converter.CreateNewImporterWithTypeNameMapping(
|
||||||
|
ezbookkeepingTransactionTypeNameMapping,
|
||||||
|
ezbookkeepingGeoLocationSeparator,
|
||||||
|
ezbookkeepingGeoLocationOrder,
|
||||||
|
ezbookkeepingTagSeparator,
|
||||||
|
)
|
||||||
|
|
||||||
|
return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezoneOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *defaultTransactionDataJsonImporter) createNewDefaultTransactionDataTable(importRequest models.ImportTransactionRequest) (datatable.TransactionDataTable, error) {
|
||||||
|
transactionDataTable := datatable.CreateNewWritableTransactionDataTable(allJsonDataSupportedColumns)
|
||||||
|
|
||||||
|
if importRequest.Transactions == nil || len(importRequest.Transactions) < 1 {
|
||||||
|
return nil, errs.ErrNotFoundTransactionDataInFile
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < len(importRequest.Transactions); i++ {
|
||||||
|
transaction := importRequest.Transactions[i]
|
||||||
|
|
||||||
|
utcOffset, err := utils.StringToInt(transaction.UtcOffset)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, errs.ErrTransactionTimeZoneInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
timezone := time.FixedZone("Transaction Timezone", utcOffset*60)
|
||||||
|
|
||||||
|
row := make(map[datatable.TransactionDataTableColumn]string, len(allJsonDataSupportedColumns))
|
||||||
|
row[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME] = transaction.Time
|
||||||
|
row[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE] = utils.FormatTimezoneOffset(timezone)
|
||||||
|
row[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = transaction.Type
|
||||||
|
row[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = transaction.CategoryName
|
||||||
|
row[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = transaction.SourceAccountName
|
||||||
|
row[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = transaction.SourceAmount
|
||||||
|
row[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = transaction.DestinationAccountName
|
||||||
|
row[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = transaction.DestinationAmount
|
||||||
|
row[datatable.TRANSACTION_DATA_TABLE_GEOGRAPHIC_LOCATION] = transaction.GeoLocation
|
||||||
|
row[datatable.TRANSACTION_DATA_TABLE_TAGS] = transaction.TagNames
|
||||||
|
row[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = transaction.Comment
|
||||||
|
|
||||||
|
transactionDataTable.Add(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
return transactionDataTable, nil
|
||||||
|
}
|
||||||
@@ -153,11 +153,7 @@ func (c *customTransactionDataDsvFileImporter) ParseImportedData(ctx core.Contex
|
|||||||
return nil, nil, nil, nil, nil, nil, err
|
return nil, nil, nil, nil, nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if !c.hasHeaderLine {
|
dataTable := csvconverter.CreateNewCustomCsvBasicDataTable(allLines, c.hasHeaderLine)
|
||||||
allLines = append([][]string{{}}, allLines...)
|
|
||||||
}
|
|
||||||
|
|
||||||
dataTable := csvconverter.CreateNewCustomCsvBasicDataTable(allLines)
|
|
||||||
transactionDataTable := CreateNewCustomPlainTextDataTable(dataTable, c.columnIndexMapping, c.transactionTypeNameMapping, c.timeFormat, c.timezoneFormat, c.amountDecimalSeparator, c.amountDigitGroupingSymbol)
|
transactionDataTable := CreateNewCustomPlainTextDataTable(dataTable, c.columnIndexMapping, c.transactionTypeNameMapping, c.timeFormat, c.timezoneFormat, c.amountDecimalSeparator, c.amountDigitGroupingSymbol)
|
||||||
dataTableImporter := converter.CreateNewImporterWithTypeNameMapping(customTransactionTypeNameMapping, c.geoLocationSeparator, c.geoLocationOrder, c.transactionTagSeparator)
|
dataTableImporter := converter.CreateNewImporterWithTypeNameMapping(customTransactionTypeNameMapping, c.geoLocationSeparator, c.geoLocationOrder, c.transactionTagSeparator)
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
type ExcelMSCFBFileBasicDataTable struct {
|
type ExcelMSCFBFileBasicDataTable struct {
|
||||||
workbook *xls.WorkBook
|
workbook *xls.WorkBook
|
||||||
headerLineColumnNames []string
|
headerLineColumnNames []string
|
||||||
|
hasTitleLine bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExcelMSCFBFileBasicDataTableRow defines the structure of excel (microsoft compound file binary) file data table row
|
// ExcelMSCFBFileBasicDataTableRow defines the structure of excel (microsoft compound file binary) file data table row
|
||||||
@@ -26,7 +27,7 @@ type ExcelMSCFBFileBasicDataTableRow struct {
|
|||||||
type ExcelMSCFBFileBasicDataTableRowIterator struct {
|
type ExcelMSCFBFileBasicDataTableRowIterator struct {
|
||||||
dataTable *ExcelMSCFBFileBasicDataTable
|
dataTable *ExcelMSCFBFileBasicDataTable
|
||||||
currentSheetIndex int
|
currentSheetIndex int
|
||||||
currentRowIndexInSheet uint16
|
currentRowIndexInSheet int
|
||||||
}
|
}
|
||||||
|
|
||||||
// DataRowCount returns the total count of data row
|
// DataRowCount returns the total count of data row
|
||||||
@@ -36,11 +37,23 @@ func (t *ExcelMSCFBFileBasicDataTable) DataRowCount() int {
|
|||||||
for i := 0; i < t.workbook.NumSheets(); i++ {
|
for i := 0; i < t.workbook.NumSheets(); i++ {
|
||||||
sheet := t.workbook.GetSheet(i)
|
sheet := t.workbook.GetSheet(i)
|
||||||
|
|
||||||
if sheet.MaxRow < 1 {
|
if sheet == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
totalDataRowCount += int(sheet.MaxRow)
|
if t.hasTitleLine {
|
||||||
|
if sheet.MaxRow < 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
totalDataRowCount += int(sheet.MaxRow)
|
||||||
|
} else {
|
||||||
|
if sheet.MaxRow <= 0 && sheet.Row(0) == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
totalDataRowCount += int(sheet.MaxRow) + 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return totalDataRowCount
|
return totalDataRowCount
|
||||||
@@ -48,15 +61,25 @@ func (t *ExcelMSCFBFileBasicDataTable) DataRowCount() int {
|
|||||||
|
|
||||||
// HeaderColumnNames returns the header column name list
|
// HeaderColumnNames returns the header column name list
|
||||||
func (t *ExcelMSCFBFileBasicDataTable) HeaderColumnNames() []string {
|
func (t *ExcelMSCFBFileBasicDataTable) HeaderColumnNames() []string {
|
||||||
|
if !t.hasTitleLine {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
return t.headerLineColumnNames
|
return t.headerLineColumnNames
|
||||||
}
|
}
|
||||||
|
|
||||||
// DataRowIterator returns the iterator of data row
|
// DataRowIterator returns the iterator of data row
|
||||||
func (t *ExcelMSCFBFileBasicDataTable) DataRowIterator() datatable.BasicDataTableRowIterator {
|
func (t *ExcelMSCFBFileBasicDataTable) DataRowIterator() datatable.BasicDataTableRowIterator {
|
||||||
|
startIndex := -1
|
||||||
|
|
||||||
|
if t.hasTitleLine {
|
||||||
|
startIndex = 0
|
||||||
|
}
|
||||||
|
|
||||||
return &ExcelMSCFBFileBasicDataTableRowIterator{
|
return &ExcelMSCFBFileBasicDataTableRowIterator{
|
||||||
dataTable: t,
|
dataTable: t,
|
||||||
currentSheetIndex: 0,
|
currentSheetIndex: 0,
|
||||||
currentRowIndexInSheet: 0,
|
currentRowIndexInSheet: startIndex,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,15 +105,21 @@ func (t *ExcelMSCFBFileBasicDataTableRowIterator) HasNext() bool {
|
|||||||
|
|
||||||
currentSheet := workbook.GetSheet(t.currentSheetIndex)
|
currentSheet := workbook.GetSheet(t.currentSheetIndex)
|
||||||
|
|
||||||
if t.currentRowIndexInSheet+1 <= currentSheet.MaxRow {
|
if t.currentRowIndexInSheet+1 <= int(currentSheet.MaxRow) && currentSheet.Row(t.currentRowIndexInSheet+1) != nil {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := t.currentSheetIndex + 1; i < workbook.NumSheets(); i++ {
|
for i := t.currentSheetIndex + 1; i < workbook.NumSheets(); i++ {
|
||||||
sheet := workbook.GetSheet(i)
|
sheet := workbook.GetSheet(i)
|
||||||
|
|
||||||
if sheet.MaxRow < 1 {
|
if t.dataTable.hasTitleLine {
|
||||||
continue
|
if sheet.MaxRow < 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if sheet.MaxRow <= 0 && sheet.Row(0) == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
@@ -107,20 +136,22 @@ func (t *ExcelMSCFBFileBasicDataTableRowIterator) CurrentRowId() string {
|
|||||||
// Next returns the next basic data row
|
// Next returns the next basic data row
|
||||||
func (t *ExcelMSCFBFileBasicDataTableRowIterator) Next() datatable.BasicDataTableRow {
|
func (t *ExcelMSCFBFileBasicDataTableRowIterator) Next() datatable.BasicDataTableRow {
|
||||||
workbook := t.dataTable.workbook
|
workbook := t.dataTable.workbook
|
||||||
currentRowIndexInTable := t.currentRowIndexInSheet
|
|
||||||
|
|
||||||
for i := t.currentSheetIndex; i < workbook.NumSheets(); i++ {
|
for i := t.currentSheetIndex; i < workbook.NumSheets(); i++ {
|
||||||
sheet := workbook.GetSheet(i)
|
sheet := workbook.GetSheet(i)
|
||||||
|
|
||||||
if currentRowIndexInTable+1 <= sheet.MaxRow {
|
if t.currentRowIndexInSheet+1 <= int(sheet.MaxRow) && sheet.Row(t.currentRowIndexInSheet+1) != nil {
|
||||||
t.currentRowIndexInSheet++
|
t.currentRowIndexInSheet++
|
||||||
currentRowIndexInTable = t.currentRowIndexInSheet
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
t.currentSheetIndex++
|
t.currentSheetIndex++
|
||||||
t.currentRowIndexInSheet = 0
|
|
||||||
currentRowIndexInTable = 0
|
if t.dataTable.hasTitleLine {
|
||||||
|
t.currentRowIndexInSheet = 0
|
||||||
|
} else {
|
||||||
|
t.currentRowIndexInSheet = -1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if t.currentSheetIndex >= workbook.NumSheets() {
|
if t.currentSheetIndex >= workbook.NumSheets() {
|
||||||
@@ -129,7 +160,7 @@ func (t *ExcelMSCFBFileBasicDataTableRowIterator) Next() datatable.BasicDataTabl
|
|||||||
|
|
||||||
currentSheet := workbook.GetSheet(t.currentSheetIndex)
|
currentSheet := workbook.GetSheet(t.currentSheetIndex)
|
||||||
|
|
||||||
if t.currentRowIndexInSheet > currentSheet.MaxRow {
|
if t.currentRowIndexInSheet > int(currentSheet.MaxRow) || currentSheet.Row(t.currentRowIndexInSheet) == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,7 +171,7 @@ func (t *ExcelMSCFBFileBasicDataTableRowIterator) Next() datatable.BasicDataTabl
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CreateNewExcelMSCFBFileBasicDataTable returns excel (microsoft compound file binary) data table by file binary data
|
// CreateNewExcelMSCFBFileBasicDataTable returns excel (microsoft compound file binary) data table by file binary data
|
||||||
func CreateNewExcelMSCFBFileBasicDataTable(data []byte) (datatable.BasicDataTable, error) {
|
func CreateNewExcelMSCFBFileBasicDataTable(data []byte, hasTitleLine bool) (datatable.BasicDataTable, error) {
|
||||||
reader := bytes.NewReader(data)
|
reader := bytes.NewReader(data)
|
||||||
workbook, err := xls.OpenReader(reader, "")
|
workbook, err := xls.OpenReader(reader, "")
|
||||||
|
|
||||||
@@ -148,12 +179,12 @@ func CreateNewExcelMSCFBFileBasicDataTable(data []byte) (datatable.BasicDataTabl
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var headerRowItems []string
|
var firstRowItems []string
|
||||||
|
|
||||||
for i := 0; i < workbook.NumSheets(); i++ {
|
for i := 0; i < workbook.NumSheets(); i++ {
|
||||||
sheet := workbook.GetSheet(i)
|
sheet := workbook.GetSheet(i)
|
||||||
|
|
||||||
if sheet.MaxRow < 0 {
|
if sheet.MaxRow <= 0 && sheet.Row(0) == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,21 +202,28 @@ func CreateNewExcelMSCFBFileBasicDataTable(data []byte) (datatable.BasicDataTabl
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
headerRowItems = append(headerRowItems, headerItem)
|
firstRowItems = append(firstRowItems, headerItem)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for j := 0; j <= min(row.LastCol(), len(headerRowItems)-1); j++ {
|
for j := 0; j <= min(row.LastCol(), len(firstRowItems)-1); j++ {
|
||||||
headerItem := row.Col(j)
|
headerItem := row.Col(j)
|
||||||
|
|
||||||
if headerItem != headerRowItems[j] {
|
if headerItem != firstRowItems[j] {
|
||||||
return nil, errs.ErrFieldsInMultiTableAreDifferent
|
return nil, errs.ErrFieldsInMultiTableAreDifferent
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var headerLineColumnNames []string = nil
|
||||||
|
|
||||||
|
if hasTitleLine {
|
||||||
|
headerLineColumnNames = firstRowItems
|
||||||
|
}
|
||||||
|
|
||||||
return &ExcelMSCFBFileBasicDataTable{
|
return &ExcelMSCFBFileBasicDataTable{
|
||||||
workbook: workbook,
|
workbook: workbook,
|
||||||
headerLineColumnNames: headerRowItems,
|
headerLineColumnNames: headerLineColumnNames,
|
||||||
|
hasTitleLine: hasTitleLine,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,16 @@ func TestExcelMSCFBFileBasicDataTableDataRowCount(t *testing.T) {
|
|||||||
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xls")
|
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xls")
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
|
|
||||||
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata)
|
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, false)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, 3, datatable.DataRowCount())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExcelMSCFBFileBasicDataTableDataRowCount_HasTitleLine(t *testing.T) {
|
||||||
|
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xls")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, true)
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
assert.Equal(t, 2, datatable.DataRowCount())
|
assert.Equal(t, 2, datatable.DataRowCount())
|
||||||
}
|
}
|
||||||
@@ -22,7 +31,16 @@ func TestExcelMSCFBFileBasicDataTableDataRowCount_MultipleSheets(t *testing.T) {
|
|||||||
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xls")
|
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xls")
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
|
|
||||||
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata)
|
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, false)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, 9, datatable.DataRowCount())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExcelMSCFBFileBasicDataTableDataRowCount_MultipleSheets_HasTitleLine(t *testing.T) {
|
||||||
|
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xls")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, true)
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
assert.Equal(t, 5, datatable.DataRowCount())
|
assert.Equal(t, 5, datatable.DataRowCount())
|
||||||
}
|
}
|
||||||
@@ -31,7 +49,7 @@ func TestExcelMSCFBFileBasicDataTableDataRowCount_OnlyHeaderLine(t *testing.T) {
|
|||||||
testdata, err := os.ReadFile("../../../testdata/only_one_row_excel_file.xls")
|
testdata, err := os.ReadFile("../../../testdata/only_one_row_excel_file.xls")
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
|
|
||||||
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata)
|
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, true)
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
assert.Equal(t, 0, datatable.DataRowCount())
|
assert.Equal(t, 0, datatable.DataRowCount())
|
||||||
}
|
}
|
||||||
@@ -40,7 +58,11 @@ func TestExcelMSCFBFileBasicDataTableDataRowCount_EmptyContent(t *testing.T) {
|
|||||||
testdata, err := os.ReadFile("../../../testdata/empty_excel_file.xls")
|
testdata, err := os.ReadFile("../../../testdata/empty_excel_file.xls")
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
|
|
||||||
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata)
|
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, false)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, 0, datatable.DataRowCount())
|
||||||
|
|
||||||
|
datatable, err = CreateNewExcelMSCFBFileBasicDataTable(testdata, true)
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
assert.Equal(t, 0, datatable.DataRowCount())
|
assert.Equal(t, 0, datatable.DataRowCount())
|
||||||
}
|
}
|
||||||
@@ -49,7 +71,17 @@ func TestExcelMSCFBFileBasicDataTableHeaderColumnNames(t *testing.T) {
|
|||||||
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xls")
|
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xls")
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
|
|
||||||
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata)
|
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, false)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Nil(t, datatable.HeaderColumnNames())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExcelMSCFBFileBasicDataTableHeaderColumnNames_HasTitleLine(t *testing.T) {
|
||||||
|
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xls")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, true)
|
||||||
|
assert.Nil(t, err)
|
||||||
assert.EqualValues(t, []string{"A1", "B1", "C1"}, datatable.HeaderColumnNames())
|
assert.EqualValues(t, []string{"A1", "B1", "C1"}, datatable.HeaderColumnNames())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,15 +89,47 @@ func TestExcelMSCFBFileBasicDataTableHeaderColumnNames_EmptyContent(t *testing.T
|
|||||||
testdata, err := os.ReadFile("../../../testdata/empty_excel_file.xls")
|
testdata, err := os.ReadFile("../../../testdata/empty_excel_file.xls")
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
|
|
||||||
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata)
|
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, false)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Nil(t, datatable.HeaderColumnNames())
|
||||||
|
|
||||||
|
datatable, err = CreateNewExcelMSCFBFileBasicDataTable(testdata, true)
|
||||||
|
assert.Nil(t, err)
|
||||||
assert.Nil(t, datatable.HeaderColumnNames())
|
assert.Nil(t, datatable.HeaderColumnNames())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExcelMSCFBFileBasicDataTableRowIterator(t *testing.T) {
|
func TestExcelMSCFBFileBasicDataRowIterator(t *testing.T) {
|
||||||
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xls")
|
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xls")
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
|
|
||||||
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata)
|
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, false)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
iterator := datatable.DataRowIterator()
|
||||||
|
assert.True(t, iterator.HasNext())
|
||||||
|
|
||||||
|
// data row 1
|
||||||
|
assert.NotNil(t, iterator.Next())
|
||||||
|
assert.True(t, iterator.HasNext())
|
||||||
|
|
||||||
|
// data row 2
|
||||||
|
assert.NotNil(t, iterator.Next())
|
||||||
|
assert.True(t, iterator.HasNext())
|
||||||
|
|
||||||
|
// data row 3
|
||||||
|
assert.NotNil(t, iterator.Next())
|
||||||
|
assert.False(t, iterator.HasNext())
|
||||||
|
|
||||||
|
// not existed data row 4
|
||||||
|
assert.Nil(t, iterator.Next())
|
||||||
|
assert.False(t, iterator.HasNext())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExcelMSCFBFileBasicDataRowIterator_HasTitleLine(t *testing.T) {
|
||||||
|
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xls")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, true)
|
||||||
|
assert.Nil(t, err)
|
||||||
iterator := datatable.DataRowIterator()
|
iterator := datatable.DataRowIterator()
|
||||||
assert.True(t, iterator.HasNext())
|
assert.True(t, iterator.HasNext())
|
||||||
|
|
||||||
@@ -86,11 +150,66 @@ func TestExcelMSCFBFileBasicDataTableRowIterator(t *testing.T) {
|
|||||||
assert.False(t, iterator.HasNext())
|
assert.False(t, iterator.HasNext())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExcelMSCFBFileBasicDataTableRowIterator_MultipleSheets(t *testing.T) {
|
func TestExcelMSCFBFileBasicDataRowIterator_MultipleSheets(t *testing.T) {
|
||||||
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xls")
|
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xls")
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
|
|
||||||
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata)
|
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, false)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
iterator := datatable.DataRowIterator()
|
||||||
|
assert.True(t, iterator.HasNext())
|
||||||
|
|
||||||
|
// sheet 1 data row 1
|
||||||
|
assert.NotNil(t, iterator.Next())
|
||||||
|
assert.True(t, iterator.HasNext())
|
||||||
|
|
||||||
|
// sheet 1 data row 2
|
||||||
|
assert.NotNil(t, iterator.Next())
|
||||||
|
assert.True(t, iterator.HasNext())
|
||||||
|
|
||||||
|
// sheet 1 data row 3
|
||||||
|
assert.NotNil(t, iterator.Next())
|
||||||
|
assert.True(t, iterator.HasNext())
|
||||||
|
|
||||||
|
// sheet 3 data row 1
|
||||||
|
assert.NotNil(t, iterator.Next())
|
||||||
|
assert.True(t, iterator.HasNext())
|
||||||
|
|
||||||
|
// sheet 3 data row 2
|
||||||
|
assert.NotNil(t, iterator.Next())
|
||||||
|
assert.True(t, iterator.HasNext())
|
||||||
|
|
||||||
|
// sheet 4 data row 1
|
||||||
|
assert.NotNil(t, iterator.Next())
|
||||||
|
assert.True(t, iterator.HasNext())
|
||||||
|
|
||||||
|
// sheet 5 data row 1
|
||||||
|
assert.NotNil(t, iterator.Next())
|
||||||
|
assert.True(t, iterator.HasNext())
|
||||||
|
|
||||||
|
// sheet 5 data row 2
|
||||||
|
assert.NotNil(t, iterator.Next())
|
||||||
|
assert.True(t, iterator.HasNext())
|
||||||
|
|
||||||
|
// sheet 5 data row 3
|
||||||
|
assert.NotNil(t, iterator.Next())
|
||||||
|
assert.False(t, iterator.HasNext())
|
||||||
|
|
||||||
|
// not existed data row
|
||||||
|
assert.Nil(t, iterator.Next())
|
||||||
|
assert.False(t, iterator.HasNext())
|
||||||
|
|
||||||
|
// not existed data row
|
||||||
|
assert.Nil(t, iterator.Next())
|
||||||
|
assert.False(t, iterator.HasNext())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExcelMSCFBFileBasicDataRowIterator_MultipleSheets_HasTitleLine(t *testing.T) {
|
||||||
|
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xls")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, true)
|
||||||
|
assert.Nil(t, err)
|
||||||
iterator := datatable.DataRowIterator()
|
iterator := datatable.DataRowIterator()
|
||||||
assert.True(t, iterator.HasNext())
|
assert.True(t, iterator.HasNext())
|
||||||
|
|
||||||
@@ -123,11 +242,12 @@ func TestExcelMSCFBFileBasicDataTableRowIterator_MultipleSheets(t *testing.T) {
|
|||||||
assert.False(t, iterator.HasNext())
|
assert.False(t, iterator.HasNext())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExcelMSCFBFileBasicDataTableRowIterator_OnlyHeaderLine(t *testing.T) {
|
func TestExcelMSCFBFileBasicDataRowIterator_OnlyHeaderLine(t *testing.T) {
|
||||||
testdata, err := os.ReadFile("../../../testdata/only_one_row_excel_file.xls")
|
testdata, err := os.ReadFile("../../../testdata/only_one_row_excel_file.xls")
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
|
|
||||||
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata)
|
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, true)
|
||||||
|
assert.Nil(t, err)
|
||||||
iterator := datatable.DataRowIterator()
|
iterator := datatable.DataRowIterator()
|
||||||
assert.False(t, iterator.HasNext())
|
assert.False(t, iterator.HasNext())
|
||||||
|
|
||||||
@@ -140,11 +260,12 @@ func TestExcelMSCFBFileBasicDataTableRowIterator_OnlyHeaderLine(t *testing.T) {
|
|||||||
assert.False(t, iterator.HasNext())
|
assert.False(t, iterator.HasNext())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExcelMSCFBFileBasicDataTableRowIterator_EmptyContent(t *testing.T) {
|
func TestExcelMSCFBFileBasicDataRowIterator_EmptyContent(t *testing.T) {
|
||||||
testdata, err := os.ReadFile("../../../testdata/empty_excel_file.xls")
|
testdata, err := os.ReadFile("../../../testdata/empty_excel_file.xls")
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
|
|
||||||
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata)
|
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, false)
|
||||||
|
assert.Nil(t, err)
|
||||||
iterator := datatable.DataRowIterator()
|
iterator := datatable.DataRowIterator()
|
||||||
assert.False(t, iterator.HasNext())
|
assert.False(t, iterator.HasNext())
|
||||||
|
|
||||||
@@ -155,13 +276,27 @@ func TestExcelMSCFBFileBasicDataTableRowIterator_EmptyContent(t *testing.T) {
|
|||||||
// not existed data row 2
|
// not existed data row 2
|
||||||
assert.Nil(t, iterator.Next())
|
assert.Nil(t, iterator.Next())
|
||||||
assert.False(t, iterator.HasNext())
|
assert.False(t, iterator.HasNext())
|
||||||
|
|
||||||
|
datatable, err = CreateNewExcelMSCFBFileBasicDataTable(testdata, true)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
iterator = datatable.DataRowIterator()
|
||||||
|
assert.False(t, iterator.HasNext())
|
||||||
|
|
||||||
|
// not existed data row 1
|
||||||
|
assert.Nil(t, iterator.Next())
|
||||||
|
assert.False(t, iterator.HasNext())
|
||||||
|
|
||||||
|
// not existed data row 2
|
||||||
|
assert.Nil(t, iterator.Next())
|
||||||
|
assert.False(t, iterator.HasNext())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExcelMSCFBFileBasicDataTableRowColumnCount(t *testing.T) {
|
func TestExcelMSCFBFileBasicDataRowColumnCount(t *testing.T) {
|
||||||
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xls")
|
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xls")
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
|
|
||||||
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata)
|
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, false)
|
||||||
|
assert.Nil(t, err)
|
||||||
iterator := datatable.DataRowIterator()
|
iterator := datatable.DataRowIterator()
|
||||||
|
|
||||||
row1 := iterator.Next()
|
row1 := iterator.Next()
|
||||||
@@ -171,11 +306,36 @@ func TestExcelMSCFBFileBasicDataTableRowColumnCount(t *testing.T) {
|
|||||||
assert.EqualValues(t, 4, row2.ColumnCount())
|
assert.EqualValues(t, 4, row2.ColumnCount())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExcelMSCFBFileBasicDataTableRowGetData(t *testing.T) {
|
func TestExcelMSCFBFileBasicDataRowGetData(t *testing.T) {
|
||||||
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xls")
|
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xls")
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
|
|
||||||
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata)
|
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, false)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
iterator := datatable.DataRowIterator()
|
||||||
|
|
||||||
|
row1 := iterator.Next()
|
||||||
|
assert.Equal(t, "A1", row1.GetData(0))
|
||||||
|
assert.Equal(t, "B1", row1.GetData(1))
|
||||||
|
assert.Equal(t, "C1", row1.GetData(2))
|
||||||
|
|
||||||
|
row2 := iterator.Next()
|
||||||
|
assert.Equal(t, "A2", row2.GetData(0))
|
||||||
|
assert.Equal(t, "B2", row2.GetData(1))
|
||||||
|
assert.Equal(t, "C2", row2.GetData(2))
|
||||||
|
|
||||||
|
row3 := iterator.Next()
|
||||||
|
assert.Equal(t, "A3", row3.GetData(0))
|
||||||
|
assert.Equal(t, "B3", row3.GetData(1))
|
||||||
|
assert.Equal(t, "C3", row3.GetData(2))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExcelMSCFBFileBasicDataRowGetData_HasTitleLine(t *testing.T) {
|
||||||
|
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xls")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, true)
|
||||||
|
assert.Nil(t, err)
|
||||||
iterator := datatable.DataRowIterator()
|
iterator := datatable.DataRowIterator()
|
||||||
|
|
||||||
row1 := iterator.Next()
|
row1 := iterator.Next()
|
||||||
@@ -189,22 +349,80 @@ func TestExcelMSCFBFileBasicDataTableRowGetData(t *testing.T) {
|
|||||||
assert.Equal(t, "C3", row2.GetData(2))
|
assert.Equal(t, "C3", row2.GetData(2))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExcelMSCFBFileBasicDataTableRowGetData_GetNotExistedColumnData(t *testing.T) {
|
func TestExcelMSCFBFileBasicDataRowGetData_GetNotExistedColumnData(t *testing.T) {
|
||||||
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xls")
|
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xls")
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
|
|
||||||
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata)
|
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, false)
|
||||||
|
assert.Nil(t, err)
|
||||||
iterator := datatable.DataRowIterator()
|
iterator := datatable.DataRowIterator()
|
||||||
|
|
||||||
row1 := iterator.Next()
|
row1 := iterator.Next()
|
||||||
assert.Equal(t, "", row1.GetData(3))
|
assert.Equal(t, "", row1.GetData(3))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExcelMSCFBFileBasicDataTableRowGetData_MultipleSheets(t *testing.T) {
|
func TestExcelMSCFBFileBasicDataRowGetData_MultipleSheets(t *testing.T) {
|
||||||
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xls")
|
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xls")
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
|
|
||||||
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata)
|
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, false)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
iterator := datatable.DataRowIterator()
|
||||||
|
|
||||||
|
sheet1Row1 := iterator.Next()
|
||||||
|
assert.Equal(t, "A1", sheet1Row1.GetData(0))
|
||||||
|
assert.Equal(t, "B1", sheet1Row1.GetData(1))
|
||||||
|
assert.Equal(t, "C1", sheet1Row1.GetData(2))
|
||||||
|
|
||||||
|
sheet1Row2 := iterator.Next()
|
||||||
|
assert.Equal(t, "1-A2", sheet1Row2.GetData(0))
|
||||||
|
assert.Equal(t, "1-B2", sheet1Row2.GetData(1))
|
||||||
|
assert.Equal(t, "1-C2", sheet1Row2.GetData(2))
|
||||||
|
|
||||||
|
sheet1Row3 := iterator.Next()
|
||||||
|
assert.Equal(t, "1-A3", sheet1Row3.GetData(0))
|
||||||
|
assert.Equal(t, "1-B3", sheet1Row3.GetData(1))
|
||||||
|
assert.Equal(t, "1-C3", sheet1Row3.GetData(2))
|
||||||
|
|
||||||
|
// skip empty sheet2
|
||||||
|
|
||||||
|
sheet3Row1 := iterator.Next()
|
||||||
|
assert.Equal(t, "A1", sheet3Row1.GetData(0))
|
||||||
|
assert.Equal(t, "B1", sheet3Row1.GetData(1))
|
||||||
|
assert.Equal(t, "C1", sheet3Row1.GetData(2))
|
||||||
|
|
||||||
|
sheet3Row2 := iterator.Next()
|
||||||
|
assert.Equal(t, "3-A2", sheet3Row2.GetData(0))
|
||||||
|
assert.Equal(t, "3-B2", sheet3Row2.GetData(1))
|
||||||
|
assert.Equal(t, "", sheet3Row2.GetData(2))
|
||||||
|
|
||||||
|
sheet4Row1 := iterator.Next()
|
||||||
|
assert.Equal(t, "A1", sheet4Row1.GetData(0))
|
||||||
|
assert.Equal(t, "B1", sheet4Row1.GetData(1))
|
||||||
|
assert.Equal(t, "C1", sheet4Row1.GetData(2))
|
||||||
|
|
||||||
|
sheet5Row1 := iterator.Next()
|
||||||
|
assert.Equal(t, "A1", sheet5Row1.GetData(0))
|
||||||
|
assert.Equal(t, "B1", sheet5Row1.GetData(1))
|
||||||
|
assert.Equal(t, "C1", sheet5Row1.GetData(2))
|
||||||
|
|
||||||
|
sheet5Row2 := iterator.Next()
|
||||||
|
assert.Equal(t, "5-A2", sheet5Row2.GetData(0))
|
||||||
|
assert.Equal(t, "5-B2", sheet5Row2.GetData(1))
|
||||||
|
assert.Equal(t, "5-C2", sheet5Row2.GetData(2))
|
||||||
|
|
||||||
|
sheet5Row3 := iterator.Next()
|
||||||
|
assert.Equal(t, "5-A3", sheet5Row3.GetData(0))
|
||||||
|
assert.Equal(t, "5-B3", sheet5Row3.GetData(1))
|
||||||
|
assert.Equal(t, "5-C3", sheet5Row3.GetData(2))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExcelMSCFBFileBasicDataRowGetData_MultipleSheets_HasTitleLine(t *testing.T) {
|
||||||
|
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xls")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, true)
|
||||||
|
assert.Nil(t, err)
|
||||||
iterator := datatable.DataRowIterator()
|
iterator := datatable.DataRowIterator()
|
||||||
|
|
||||||
sheet1Row1 := iterator.Next()
|
sheet1Row1 := iterator.Next()
|
||||||
@@ -241,6 +459,6 @@ func TestCreateNewExcelMSCFBFileBasicDataTable_MultipleSheetsWithDifferentHeader
|
|||||||
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_with_different_header_row_excel_file.xls")
|
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_with_different_header_row_excel_file.xls")
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
|
|
||||||
_, err = CreateNewExcelMSCFBFileBasicDataTable(testdata)
|
_, err = CreateNewExcelMSCFBFileBasicDataTable(testdata, true)
|
||||||
assert.EqualError(t, err, errs.ErrFieldsInMultiTableAreDifferent.Message)
|
assert.EqualError(t, err, errs.ErrFieldsInMultiTableAreDifferent.Message)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ type excelOOXMLSheet struct {
|
|||||||
type ExcelOOXMLFileBasicDataTable struct {
|
type ExcelOOXMLFileBasicDataTable struct {
|
||||||
sheets []*excelOOXMLSheet
|
sheets []*excelOOXMLSheet
|
||||||
headerLineColumnNames []string
|
headerLineColumnNames []string
|
||||||
|
hasTitleLine bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExcelOOXMLFileBasicDataTableRow defines the structure of excel (Office Open XML) file data table row
|
// ExcelOOXMLFileBasicDataTableRow defines the structure of excel (Office Open XML) file data table row
|
||||||
@@ -47,7 +48,11 @@ func (t *ExcelOOXMLFileBasicDataTable) DataRowCount() int {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
totalDataRowCount += len(sheet.allData) - 1
|
if t.hasTitleLine {
|
||||||
|
totalDataRowCount += len(sheet.allData) - 1
|
||||||
|
} else {
|
||||||
|
totalDataRowCount += len(sheet.allData)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return totalDataRowCount
|
return totalDataRowCount
|
||||||
@@ -55,15 +60,25 @@ func (t *ExcelOOXMLFileBasicDataTable) DataRowCount() int {
|
|||||||
|
|
||||||
// HeaderColumnNames returns the header column name list
|
// HeaderColumnNames returns the header column name list
|
||||||
func (t *ExcelOOXMLFileBasicDataTable) HeaderColumnNames() []string {
|
func (t *ExcelOOXMLFileBasicDataTable) HeaderColumnNames() []string {
|
||||||
|
if !t.hasTitleLine {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
return t.headerLineColumnNames
|
return t.headerLineColumnNames
|
||||||
}
|
}
|
||||||
|
|
||||||
// DataRowIterator returns the iterator of data row
|
// DataRowIterator returns the iterator of data row
|
||||||
func (t *ExcelOOXMLFileBasicDataTable) DataRowIterator() datatable.BasicDataTableRowIterator {
|
func (t *ExcelOOXMLFileBasicDataTable) DataRowIterator() datatable.BasicDataTableRowIterator {
|
||||||
|
startIndex := -1
|
||||||
|
|
||||||
|
if t.hasTitleLine {
|
||||||
|
startIndex = 0
|
||||||
|
}
|
||||||
|
|
||||||
return &ExcelOOXMLFileBasicDataTableRowIterator{
|
return &ExcelOOXMLFileBasicDataTableRowIterator{
|
||||||
dataTable: t,
|
dataTable: t,
|
||||||
currentSheetIndex: 0,
|
currentSheetIndex: 0,
|
||||||
currentRowIndexInSheet: 0,
|
currentRowIndexInSheet: startIndex,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,8 +113,14 @@ func (t *ExcelOOXMLFileBasicDataTableRowIterator) HasNext() bool {
|
|||||||
for i := t.currentSheetIndex + 1; i < len(sheets); i++ {
|
for i := t.currentSheetIndex + 1; i < len(sheets); i++ {
|
||||||
sheet := sheets[i]
|
sheet := sheets[i]
|
||||||
|
|
||||||
if len(sheet.allData) <= 1 {
|
if t.dataTable.hasTitleLine {
|
||||||
continue
|
if len(sheet.allData) <= 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if len(sheet.allData) <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
@@ -116,20 +137,22 @@ func (t *ExcelOOXMLFileBasicDataTableRowIterator) CurrentRowId() string {
|
|||||||
// Next returns the next basic data row
|
// Next returns the next basic data row
|
||||||
func (t *ExcelOOXMLFileBasicDataTableRowIterator) Next() datatable.BasicDataTableRow {
|
func (t *ExcelOOXMLFileBasicDataTableRowIterator) Next() datatable.BasicDataTableRow {
|
||||||
sheets := t.dataTable.sheets
|
sheets := t.dataTable.sheets
|
||||||
currentRowIndexInTable := t.currentRowIndexInSheet
|
|
||||||
|
|
||||||
for i := t.currentSheetIndex; i < len(sheets); i++ {
|
for i := t.currentSheetIndex; i < len(sheets); i++ {
|
||||||
sheet := sheets[i]
|
sheet := sheets[i]
|
||||||
|
|
||||||
if currentRowIndexInTable+1 < len(sheet.allData) {
|
if t.currentRowIndexInSheet+1 < len(sheet.allData) {
|
||||||
t.currentRowIndexInSheet++
|
t.currentRowIndexInSheet++
|
||||||
currentRowIndexInTable = t.currentRowIndexInSheet
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
t.currentSheetIndex++
|
t.currentSheetIndex++
|
||||||
t.currentRowIndexInSheet = 0
|
|
||||||
currentRowIndexInTable = 0
|
if t.dataTable.hasTitleLine {
|
||||||
|
t.currentRowIndexInSheet = 0
|
||||||
|
} else {
|
||||||
|
t.currentRowIndexInSheet = -1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if t.currentSheetIndex >= len(sheets) {
|
if t.currentSheetIndex >= len(sheets) {
|
||||||
@@ -150,7 +173,7 @@ func (t *ExcelOOXMLFileBasicDataTableRowIterator) Next() datatable.BasicDataTabl
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CreateNewExcelOOXMLFileBasicDataTable returns excel (Office Open XML) data table by file binary data
|
// CreateNewExcelOOXMLFileBasicDataTable returns excel (Office Open XML) data table by file binary data
|
||||||
func CreateNewExcelOOXMLFileBasicDataTable(data []byte) (datatable.BasicDataTable, error) {
|
func CreateNewExcelOOXMLFileBasicDataTable(data []byte, hasTitleLine bool) (datatable.BasicDataTable, error) {
|
||||||
reader := bytes.NewReader(data)
|
reader := bytes.NewReader(data)
|
||||||
file, err := excelize.OpenReader(reader)
|
file, err := excelize.OpenReader(reader)
|
||||||
|
|
||||||
@@ -161,7 +184,7 @@ func CreateNewExcelOOXMLFileBasicDataTable(data []byte) (datatable.BasicDataTabl
|
|||||||
}
|
}
|
||||||
|
|
||||||
sheetNames := file.GetSheetList()
|
sheetNames := file.GetSheetList()
|
||||||
var headerRowItems []string
|
var firstRowItems []string
|
||||||
var sheets []*excelOOXMLSheet
|
var sheets []*excelOOXMLSheet
|
||||||
|
|
||||||
for i := 0; i < len(sheetNames); i++ {
|
for i := 0; i < len(sheetNames); i++ {
|
||||||
@@ -186,13 +209,13 @@ func CreateNewExcelOOXMLFileBasicDataTable(data []byte) (datatable.BasicDataTabl
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
headerRowItems = append(headerRowItems, headerItem)
|
firstRowItems = append(firstRowItems, headerItem)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for j := 0; j < min(len(row), len(headerRowItems)); j++ {
|
for j := 0; j < min(len(row), len(firstRowItems)); j++ {
|
||||||
headerItem := row[j]
|
headerItem := row[j]
|
||||||
|
|
||||||
if headerItem != headerRowItems[j] {
|
if headerItem != firstRowItems[j] {
|
||||||
return nil, errs.ErrFieldsInMultiTableAreDifferent
|
return nil, errs.ErrFieldsInMultiTableAreDifferent
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -204,8 +227,15 @@ func CreateNewExcelOOXMLFileBasicDataTable(data []byte) (datatable.BasicDataTabl
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var headerLineColumnNames []string = nil
|
||||||
|
|
||||||
|
if hasTitleLine {
|
||||||
|
headerLineColumnNames = firstRowItems
|
||||||
|
}
|
||||||
|
|
||||||
return &ExcelOOXMLFileBasicDataTable{
|
return &ExcelOOXMLFileBasicDataTable{
|
||||||
sheets: sheets,
|
sheets: sheets,
|
||||||
headerLineColumnNames: headerRowItems,
|
headerLineColumnNames: headerLineColumnNames,
|
||||||
|
hasTitleLine: hasTitleLine,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,16 @@ func TestExcelOOXMLFileBasicDataTableDataRowCount(t *testing.T) {
|
|||||||
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xlsx")
|
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xlsx")
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
|
|
||||||
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata)
|
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, false)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, 3, datatable.DataRowCount())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExcelOOXMLFileBasicDataTableDataRowCount_HasTitleLine(t *testing.T) {
|
||||||
|
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xlsx")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, true)
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
assert.Equal(t, 2, datatable.DataRowCount())
|
assert.Equal(t, 2, datatable.DataRowCount())
|
||||||
}
|
}
|
||||||
@@ -22,7 +31,16 @@ func TestExcelOOXMLFileBasicDataTableDataRowCount_MultipleSheets(t *testing.T) {
|
|||||||
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xlsx")
|
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xlsx")
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
|
|
||||||
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata)
|
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, false)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, 9, datatable.DataRowCount())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExcelOOXMLFileBasicDataTableDataRowCount_MultipleSheets_HasTitleLine(t *testing.T) {
|
||||||
|
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xlsx")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, true)
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
assert.Equal(t, 5, datatable.DataRowCount())
|
assert.Equal(t, 5, datatable.DataRowCount())
|
||||||
}
|
}
|
||||||
@@ -31,7 +49,7 @@ func TestExcelOOXMLFileBasicDataTableDataRowCount_OnlyHeaderLine(t *testing.T) {
|
|||||||
testdata, err := os.ReadFile("../../../testdata/only_one_row_excel_file.xlsx")
|
testdata, err := os.ReadFile("../../../testdata/only_one_row_excel_file.xlsx")
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
|
|
||||||
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata)
|
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, true)
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
assert.Equal(t, 0, datatable.DataRowCount())
|
assert.Equal(t, 0, datatable.DataRowCount())
|
||||||
}
|
}
|
||||||
@@ -40,7 +58,11 @@ func TestExcelOOXMLFileBasicDataTableDataRowCount_EmptyContent(t *testing.T) {
|
|||||||
testdata, err := os.ReadFile("../../../testdata/empty_excel_file.xlsx")
|
testdata, err := os.ReadFile("../../../testdata/empty_excel_file.xlsx")
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
|
|
||||||
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata)
|
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, false)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, 0, datatable.DataRowCount())
|
||||||
|
|
||||||
|
datatable, err = CreateNewExcelOOXMLFileBasicDataTable(testdata, true)
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
assert.Equal(t, 0, datatable.DataRowCount())
|
assert.Equal(t, 0, datatable.DataRowCount())
|
||||||
}
|
}
|
||||||
@@ -49,7 +71,17 @@ func TestExcelOOXMLFileBasicDataTableHeaderColumnNames(t *testing.T) {
|
|||||||
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xlsx")
|
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xlsx")
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
|
|
||||||
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata)
|
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, false)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Nil(t, datatable.HeaderColumnNames())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExcelOOXMLFileBasicDataTableHeaderColumnNames_HasTitleLine(t *testing.T) {
|
||||||
|
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xlsx")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, true)
|
||||||
|
assert.Nil(t, err)
|
||||||
assert.EqualValues(t, []string{"A1", "B1", "C1"}, datatable.HeaderColumnNames())
|
assert.EqualValues(t, []string{"A1", "B1", "C1"}, datatable.HeaderColumnNames())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +89,12 @@ func TestExcelOOXMLFileBasicDataTableHeaderColumnNames_EmptyContent(t *testing.T
|
|||||||
testdata, err := os.ReadFile("../../../testdata/empty_excel_file.xlsx")
|
testdata, err := os.ReadFile("../../../testdata/empty_excel_file.xlsx")
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
|
|
||||||
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata)
|
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, false)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Nil(t, datatable.HeaderColumnNames())
|
||||||
|
|
||||||
|
datatable, err = CreateNewExcelOOXMLFileBasicDataTable(testdata, true)
|
||||||
|
assert.Nil(t, err)
|
||||||
assert.Nil(t, datatable.HeaderColumnNames())
|
assert.Nil(t, datatable.HeaderColumnNames())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +102,34 @@ func TestExcelOOXMLFileBasicDataRowIterator(t *testing.T) {
|
|||||||
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xlsx")
|
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xlsx")
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
|
|
||||||
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata)
|
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, false)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
iterator := datatable.DataRowIterator()
|
||||||
|
assert.True(t, iterator.HasNext())
|
||||||
|
|
||||||
|
// data row 1
|
||||||
|
assert.NotNil(t, iterator.Next())
|
||||||
|
assert.True(t, iterator.HasNext())
|
||||||
|
|
||||||
|
// data row 2
|
||||||
|
assert.NotNil(t, iterator.Next())
|
||||||
|
assert.True(t, iterator.HasNext())
|
||||||
|
|
||||||
|
// data row 3
|
||||||
|
assert.NotNil(t, iterator.Next())
|
||||||
|
assert.False(t, iterator.HasNext())
|
||||||
|
|
||||||
|
// not existed data row 4
|
||||||
|
assert.Nil(t, iterator.Next())
|
||||||
|
assert.False(t, iterator.HasNext())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExcelOOXMLFileBasicDataRowIterator_HasTitleLine(t *testing.T) {
|
||||||
|
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xlsx")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, true)
|
||||||
|
assert.Nil(t, err)
|
||||||
iterator := datatable.DataRowIterator()
|
iterator := datatable.DataRowIterator()
|
||||||
assert.True(t, iterator.HasNext())
|
assert.True(t, iterator.HasNext())
|
||||||
|
|
||||||
@@ -90,7 +154,62 @@ func TestExcelOOXMLFileBasicDataRowIterator_MultipleSheets(t *testing.T) {
|
|||||||
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xlsx")
|
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xlsx")
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
|
|
||||||
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata)
|
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, false)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
iterator := datatable.DataRowIterator()
|
||||||
|
assert.True(t, iterator.HasNext())
|
||||||
|
|
||||||
|
// sheet 1 data row 1
|
||||||
|
assert.NotNil(t, iterator.Next())
|
||||||
|
assert.True(t, iterator.HasNext())
|
||||||
|
|
||||||
|
// sheet 1 data row 2
|
||||||
|
assert.NotNil(t, iterator.Next())
|
||||||
|
assert.True(t, iterator.HasNext())
|
||||||
|
|
||||||
|
// sheet 1 data row 3
|
||||||
|
assert.NotNil(t, iterator.Next())
|
||||||
|
assert.True(t, iterator.HasNext())
|
||||||
|
|
||||||
|
// sheet 3 data row 1
|
||||||
|
assert.NotNil(t, iterator.Next())
|
||||||
|
assert.True(t, iterator.HasNext())
|
||||||
|
|
||||||
|
// sheet 3 data row 2
|
||||||
|
assert.NotNil(t, iterator.Next())
|
||||||
|
assert.True(t, iterator.HasNext())
|
||||||
|
|
||||||
|
// sheet 4 data row 1
|
||||||
|
assert.NotNil(t, iterator.Next())
|
||||||
|
assert.True(t, iterator.HasNext())
|
||||||
|
|
||||||
|
// sheet 5 data row 1
|
||||||
|
assert.NotNil(t, iterator.Next())
|
||||||
|
assert.True(t, iterator.HasNext())
|
||||||
|
|
||||||
|
// sheet 5 data row 2
|
||||||
|
assert.NotNil(t, iterator.Next())
|
||||||
|
assert.True(t, iterator.HasNext())
|
||||||
|
|
||||||
|
// sheet 5 data row 3
|
||||||
|
assert.NotNil(t, iterator.Next())
|
||||||
|
assert.False(t, iterator.HasNext())
|
||||||
|
|
||||||
|
// not existed data row
|
||||||
|
assert.Nil(t, iterator.Next())
|
||||||
|
assert.False(t, iterator.HasNext())
|
||||||
|
|
||||||
|
// not existed data row
|
||||||
|
assert.Nil(t, iterator.Next())
|
||||||
|
assert.False(t, iterator.HasNext())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExcelOOXMLFileBasicDataRowIterator_MultipleSheets_HasTitleLine(t *testing.T) {
|
||||||
|
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xlsx")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, true)
|
||||||
|
assert.Nil(t, err)
|
||||||
iterator := datatable.DataRowIterator()
|
iterator := datatable.DataRowIterator()
|
||||||
assert.True(t, iterator.HasNext())
|
assert.True(t, iterator.HasNext())
|
||||||
|
|
||||||
@@ -127,7 +246,8 @@ func TestExcelOOXMLFileBasicDataRowIterator_OnlyHeaderLine(t *testing.T) {
|
|||||||
testdata, err := os.ReadFile("../../../testdata/only_one_row_excel_file.xlsx")
|
testdata, err := os.ReadFile("../../../testdata/only_one_row_excel_file.xlsx")
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
|
|
||||||
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata)
|
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, true)
|
||||||
|
assert.Nil(t, err)
|
||||||
iterator := datatable.DataRowIterator()
|
iterator := datatable.DataRowIterator()
|
||||||
assert.False(t, iterator.HasNext())
|
assert.False(t, iterator.HasNext())
|
||||||
|
|
||||||
@@ -144,7 +264,8 @@ func TestExcelOOXMLFileBasicDataRowIterator_EmptyContent(t *testing.T) {
|
|||||||
testdata, err := os.ReadFile("../../../testdata/empty_excel_file.xlsx")
|
testdata, err := os.ReadFile("../../../testdata/empty_excel_file.xlsx")
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
|
|
||||||
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata)
|
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, false)
|
||||||
|
assert.Nil(t, err)
|
||||||
iterator := datatable.DataRowIterator()
|
iterator := datatable.DataRowIterator()
|
||||||
assert.False(t, iterator.HasNext())
|
assert.False(t, iterator.HasNext())
|
||||||
|
|
||||||
@@ -155,13 +276,27 @@ func TestExcelOOXMLFileBasicDataRowIterator_EmptyContent(t *testing.T) {
|
|||||||
// not existed data row 2
|
// not existed data row 2
|
||||||
assert.Nil(t, iterator.Next())
|
assert.Nil(t, iterator.Next())
|
||||||
assert.False(t, iterator.HasNext())
|
assert.False(t, iterator.HasNext())
|
||||||
|
|
||||||
|
datatable, err = CreateNewExcelOOXMLFileBasicDataTable(testdata, true)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
iterator = datatable.DataRowIterator()
|
||||||
|
assert.False(t, iterator.HasNext())
|
||||||
|
|
||||||
|
// not existed data row 1
|
||||||
|
assert.Nil(t, iterator.Next())
|
||||||
|
assert.False(t, iterator.HasNext())
|
||||||
|
|
||||||
|
// not existed data row 2
|
||||||
|
assert.Nil(t, iterator.Next())
|
||||||
|
assert.False(t, iterator.HasNext())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExcelOOXMLFileBasicDataRowColumnCount(t *testing.T) {
|
func TestExcelOOXMLFileBasicDataRowColumnCount(t *testing.T) {
|
||||||
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xlsx")
|
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xlsx")
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
|
|
||||||
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata)
|
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, false)
|
||||||
|
assert.Nil(t, err)
|
||||||
iterator := datatable.DataRowIterator()
|
iterator := datatable.DataRowIterator()
|
||||||
|
|
||||||
row1 := iterator.Next()
|
row1 := iterator.Next()
|
||||||
@@ -175,7 +310,32 @@ func TestExcelOOXMLFileBasicDataRowGetData(t *testing.T) {
|
|||||||
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xlsx")
|
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xlsx")
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
|
|
||||||
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata)
|
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, false)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
iterator := datatable.DataRowIterator()
|
||||||
|
|
||||||
|
row1 := iterator.Next()
|
||||||
|
assert.Equal(t, "A1", row1.GetData(0))
|
||||||
|
assert.Equal(t, "B1", row1.GetData(1))
|
||||||
|
assert.Equal(t, "C1", row1.GetData(2))
|
||||||
|
|
||||||
|
row2 := iterator.Next()
|
||||||
|
assert.Equal(t, "A2", row2.GetData(0))
|
||||||
|
assert.Equal(t, "B2", row2.GetData(1))
|
||||||
|
assert.Equal(t, "C2", row2.GetData(2))
|
||||||
|
|
||||||
|
row3 := iterator.Next()
|
||||||
|
assert.Equal(t, "A3", row3.GetData(0))
|
||||||
|
assert.Equal(t, "B3", row3.GetData(1))
|
||||||
|
assert.Equal(t, "C3", row3.GetData(2))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExcelOOXMLFileBasicDataRowGetData_HasTitleLine(t *testing.T) {
|
||||||
|
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xlsx")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, true)
|
||||||
|
assert.Nil(t, err)
|
||||||
iterator := datatable.DataRowIterator()
|
iterator := datatable.DataRowIterator()
|
||||||
|
|
||||||
row1 := iterator.Next()
|
row1 := iterator.Next()
|
||||||
@@ -193,7 +353,8 @@ func TestExcelOOXMLFileBasicDataRowGetData_GetNotExistedColumnData(t *testing.T)
|
|||||||
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xlsx")
|
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xlsx")
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
|
|
||||||
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata)
|
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, false)
|
||||||
|
assert.Nil(t, err)
|
||||||
iterator := datatable.DataRowIterator()
|
iterator := datatable.DataRowIterator()
|
||||||
|
|
||||||
row1 := iterator.Next()
|
row1 := iterator.Next()
|
||||||
@@ -204,7 +365,64 @@ func TestExcelOOXMLFileBasicDataRowGetData_MultipleSheets(t *testing.T) {
|
|||||||
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xlsx")
|
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xlsx")
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
|
|
||||||
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata)
|
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, false)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
iterator := datatable.DataRowIterator()
|
||||||
|
|
||||||
|
sheet1Row1 := iterator.Next()
|
||||||
|
assert.Equal(t, "A1", sheet1Row1.GetData(0))
|
||||||
|
assert.Equal(t, "B1", sheet1Row1.GetData(1))
|
||||||
|
assert.Equal(t, "C1", sheet1Row1.GetData(2))
|
||||||
|
|
||||||
|
sheet1Row2 := iterator.Next()
|
||||||
|
assert.Equal(t, "1-A2", sheet1Row2.GetData(0))
|
||||||
|
assert.Equal(t, "1-B2", sheet1Row2.GetData(1))
|
||||||
|
assert.Equal(t, "1-C2", sheet1Row2.GetData(2))
|
||||||
|
|
||||||
|
sheet1Row3 := iterator.Next()
|
||||||
|
assert.Equal(t, "1-A3", sheet1Row3.GetData(0))
|
||||||
|
assert.Equal(t, "1-B3", sheet1Row3.GetData(1))
|
||||||
|
assert.Equal(t, "1-C3", sheet1Row3.GetData(2))
|
||||||
|
|
||||||
|
// skip empty sheet2
|
||||||
|
|
||||||
|
sheet3Row1 := iterator.Next()
|
||||||
|
assert.Equal(t, "A1", sheet3Row1.GetData(0))
|
||||||
|
assert.Equal(t, "B1", sheet3Row1.GetData(1))
|
||||||
|
assert.Equal(t, "C1", sheet3Row1.GetData(2))
|
||||||
|
|
||||||
|
sheet3Row2 := iterator.Next()
|
||||||
|
assert.Equal(t, "3-A2", sheet3Row2.GetData(0))
|
||||||
|
assert.Equal(t, "3-B2", sheet3Row2.GetData(1))
|
||||||
|
assert.Equal(t, "", sheet3Row2.GetData(2))
|
||||||
|
|
||||||
|
sheet4Row1 := iterator.Next()
|
||||||
|
assert.Equal(t, "A1", sheet4Row1.GetData(0))
|
||||||
|
assert.Equal(t, "B1", sheet4Row1.GetData(1))
|
||||||
|
assert.Equal(t, "C1", sheet4Row1.GetData(2))
|
||||||
|
|
||||||
|
sheet5Row1 := iterator.Next()
|
||||||
|
assert.Equal(t, "A1", sheet5Row1.GetData(0))
|
||||||
|
assert.Equal(t, "B1", sheet5Row1.GetData(1))
|
||||||
|
assert.Equal(t, "C1", sheet5Row1.GetData(2))
|
||||||
|
|
||||||
|
sheet5Row2 := iterator.Next()
|
||||||
|
assert.Equal(t, "5-A2", sheet5Row2.GetData(0))
|
||||||
|
assert.Equal(t, "5-B2", sheet5Row2.GetData(1))
|
||||||
|
assert.Equal(t, "5-C2", sheet5Row2.GetData(2))
|
||||||
|
|
||||||
|
sheet5Row3 := iterator.Next()
|
||||||
|
assert.Equal(t, "5-A3", sheet5Row3.GetData(0))
|
||||||
|
assert.Equal(t, "5-B3", sheet5Row3.GetData(1))
|
||||||
|
assert.Equal(t, "5-C3", sheet5Row3.GetData(2))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExcelOOXMLFileBasicDataRowGetData_MultipleSheets_HasTitleLine(t *testing.T) {
|
||||||
|
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xlsx")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, true)
|
||||||
|
assert.Nil(t, err)
|
||||||
iterator := datatable.DataRowIterator()
|
iterator := datatable.DataRowIterator()
|
||||||
|
|
||||||
sheet1Row1 := iterator.Next()
|
sheet1Row1 := iterator.Next()
|
||||||
@@ -241,6 +459,6 @@ func TestCreateNewExcelOOXMLFileBasicDataTable_MultipleSheetsWithDifferentHeader
|
|||||||
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_with_different_header_row_excel_file.xlsx")
|
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_with_different_header_row_excel_file.xlsx")
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
|
|
||||||
_, err = CreateNewExcelOOXMLFileBasicDataTable(testdata)
|
_, err = CreateNewExcelOOXMLFileBasicDataTable(testdata, true)
|
||||||
assert.EqualError(t, err, errs.ErrFieldsInMultiTableAreDifferent.Message)
|
assert.EqualError(t, err, errs.ErrFieldsInMultiTableAreDifferent.Message)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,13 @@ package feidee
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/csv"
|
|
||||||
"golang.org/x/text/encoding/unicode"
|
|
||||||
"golang.org/x/text/transform"
|
|
||||||
"io"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/text/encoding/unicode"
|
||||||
|
"golang.org/x/text/transform"
|
||||||
|
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/converters/converter"
|
"github.com/mayswind/ezbookkeeping/pkg/converters/converter"
|
||||||
csvdatatable "github.com/mayswind/ezbookkeeping/pkg/converters/csv"
|
"github.com/mayswind/ezbookkeeping/pkg/converters/csv"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
|
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||||
@@ -60,7 +59,13 @@ func (c *feideeMymoneyAppTransactionDataCsvFileImporter) ParseImportedData(ctx c
|
|||||||
fallback := unicode.UTF8.NewDecoder()
|
fallback := unicode.UTF8.NewDecoder()
|
||||||
reader := transform.NewReader(bytes.NewReader(data), unicode.BOMOverride(fallback))
|
reader := transform.NewReader(bytes.NewReader(data), unicode.BOMOverride(fallback))
|
||||||
|
|
||||||
dataTable, err := c.createNewFeideeMymoneyAppBasicDataTable(ctx, reader)
|
csvDataTable, err := csv.CreateNewCsvBasicDataTable(ctx, reader, false)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, nil, nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dataTable, err := createNewFeideeMymoneyAppTransactionBasicDataTable(ctx, csvDataTable)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, nil, nil, nil, nil, err
|
return nil, nil, nil, nil, nil, nil, err
|
||||||
@@ -89,54 +94,6 @@ func (c *feideeMymoneyAppTransactionDataCsvFileImporter) ParseImportedData(ctx c
|
|||||||
return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezoneOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
|
return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezoneOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *feideeMymoneyAppTransactionDataCsvFileImporter) createNewFeideeMymoneyAppBasicDataTable(ctx core.Context, reader io.Reader) (datatable.BasicDataTable, error) {
|
|
||||||
csvReader := csv.NewReader(reader)
|
|
||||||
csvReader.FieldsPerRecord = -1
|
|
||||||
|
|
||||||
allOriginalLines := make([][]string, 0)
|
|
||||||
hasFileHeader := false
|
|
||||||
|
|
||||||
for {
|
|
||||||
items, err := csvReader.Read()
|
|
||||||
|
|
||||||
if err == io.EOF {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf(ctx, "[feidee_mymoney_app_transaction_data_csv_file_importer.createNewFeideeMymoneyAppTransactionDataTable] cannot parse feidee mymoney csv data, because %s", err.Error())
|
|
||||||
return nil, errs.ErrInvalidCSVFile
|
|
||||||
}
|
|
||||||
|
|
||||||
if !hasFileHeader {
|
|
||||||
if len(items) <= 0 {
|
|
||||||
continue
|
|
||||||
} else if strings.Index(items[0], feideeMymoneyAppTransactionDataCsvFileHeader) == 0 {
|
|
||||||
hasFileHeader = true
|
|
||||||
continue
|
|
||||||
} else {
|
|
||||||
log.Warnf(ctx, "[feidee_mymoney_app_transaction_data_csv_file_importer.createNewFeideeMymoneyAppTransactionDataTable] read unexpected line before read file header, line content is %s", strings.Join(items, ","))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
allOriginalLines = append(allOriginalLines, items)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !hasFileHeader {
|
|
||||||
return nil, errs.ErrInvalidFileHeader
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(allOriginalLines) < 2 {
|
|
||||||
log.Errorf(ctx, "[feidee_mymoney_app_transaction_data_csv_file_importer.createNewFeideeMymoneyAppTransactionDataTable] cannot parse import data, because data table row count is less 1")
|
|
||||||
return nil, errs.ErrNotFoundTransactionDataInFile
|
|
||||||
}
|
|
||||||
|
|
||||||
dataTable := csvdatatable.CreateNewCustomCsvBasicDataTable(allOriginalLines)
|
|
||||||
|
|
||||||
return dataTable, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *feideeMymoneyAppTransactionDataCsvFileImporter) createNewFeideeMymoneyAppTransactionDataTable(ctx core.Context, commonDataTable datatable.CommonDataTable) (datatable.TransactionDataTable, error) {
|
func (c *feideeMymoneyAppTransactionDataCsvFileImporter) createNewFeideeMymoneyAppTransactionDataTable(ctx core.Context, commonDataTable datatable.CommonDataTable) (datatable.TransactionDataTable, error) {
|
||||||
newColumns := make([]datatable.TransactionDataTableColumn, 0, 11)
|
newColumns := make([]datatable.TransactionDataTableColumn, 0, 11)
|
||||||
newColumns = append(newColumns, datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE)
|
newColumns = append(newColumns, datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE)
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package feidee
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/converters/csv"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
func createNewFeideeMymoneyAppTransactionBasicDataTable(ctx core.Context, originalDataTable datatable.BasicDataTable) (datatable.BasicDataTable, error) {
|
||||||
|
iterator := originalDataTable.DataRowIterator()
|
||||||
|
allOriginalLines := make([][]string, 0)
|
||||||
|
hasFileHeader := false
|
||||||
|
|
||||||
|
for iterator.HasNext() {
|
||||||
|
row := iterator.Next()
|
||||||
|
|
||||||
|
if !hasFileHeader {
|
||||||
|
if row.ColumnCount() <= 0 {
|
||||||
|
continue
|
||||||
|
} else if strings.Index(row.GetData(0), feideeMymoneyAppTransactionDataCsvFileHeader) == 0 {
|
||||||
|
hasFileHeader = true
|
||||||
|
continue
|
||||||
|
} else {
|
||||||
|
log.Warnf(ctx, "[feidee_mymoney_app_transaction_data_extrator.createNewFeideeMymoneyAppTransactionBasicDataTable] read unexpected line in row \"%s\" before read file header", iterator.CurrentRowId())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]string, row.ColumnCount())
|
||||||
|
|
||||||
|
for i := 0; i < row.ColumnCount(); i++ {
|
||||||
|
items[i] = strings.Trim(row.GetData(i), " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
allOriginalLines = append(allOriginalLines, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasFileHeader {
|
||||||
|
return nil, errs.ErrInvalidFileHeader
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(allOriginalLines) < 2 {
|
||||||
|
log.Errorf(ctx, "[feidee_mymoney_app_transaction_data_extrator.createNewFeideeMymoneyAppTransactionBasicDataTable] cannot parse import data, because data table row count is less 1")
|
||||||
|
return nil, errs.ErrNotFoundTransactionDataInFile
|
||||||
|
}
|
||||||
|
|
||||||
|
return csv.CreateNewCustomCsvBasicDataTable(allOriginalLines, true), nil
|
||||||
|
}
|
||||||
+1
-1
@@ -32,7 +32,7 @@ var (
|
|||||||
|
|
||||||
// ParseImportedData returns the imported data by parsing the feidee mymoney (elecloud) transaction xlsx data
|
// ParseImportedData returns the imported data by parsing the feidee mymoney (elecloud) transaction xlsx data
|
||||||
func (c *feideeMymoneyElecloudTransactionDataXlsxFileImporter) ParseImportedData(ctx core.Context, user *models.User, data []byte, defaultTimezoneOffset int16, accountMap map[string]*models.Account, expenseCategoryMap map[string]map[string]*models.TransactionCategory, incomeCategoryMap map[string]map[string]*models.TransactionCategory, transferCategoryMap map[string]map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) (models.ImportedTransactionSlice, []*models.Account, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionTag, error) {
|
func (c *feideeMymoneyElecloudTransactionDataXlsxFileImporter) ParseImportedData(ctx core.Context, user *models.User, data []byte, defaultTimezoneOffset int16, accountMap map[string]*models.Account, expenseCategoryMap map[string]map[string]*models.TransactionCategory, incomeCategoryMap map[string]map[string]*models.TransactionCategory, transferCategoryMap map[string]map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) (models.ImportedTransactionSlice, []*models.Account, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionTag, error) {
|
||||||
dataTable, err := excel.CreateNewExcelOOXMLFileBasicDataTable(data)
|
dataTable, err := excel.CreateNewExcelOOXMLFileBasicDataTable(data, true)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, nil, nil, nil, nil, err
|
return nil, nil, nil, nil, nil, nil, err
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ var (
|
|||||||
|
|
||||||
// ParseImportedData returns the imported data by parsing the feidee mymoney (web) transaction xls data
|
// ParseImportedData returns the imported data by parsing the feidee mymoney (web) transaction xls data
|
||||||
func (c *feideeMymoneyWebTransactionDataXlsFileImporter) ParseImportedData(ctx core.Context, user *models.User, data []byte, defaultTimezoneOffset int16, accountMap map[string]*models.Account, expenseCategoryMap map[string]map[string]*models.TransactionCategory, incomeCategoryMap map[string]map[string]*models.TransactionCategory, transferCategoryMap map[string]map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) (models.ImportedTransactionSlice, []*models.Account, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionTag, error) {
|
func (c *feideeMymoneyWebTransactionDataXlsFileImporter) ParseImportedData(ctx core.Context, user *models.User, data []byte, defaultTimezoneOffset int16, accountMap map[string]*models.Account, expenseCategoryMap map[string]map[string]*models.TransactionCategory, incomeCategoryMap map[string]map[string]*models.TransactionCategory, transferCategoryMap map[string]map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) (models.ImportedTransactionSlice, []*models.Account, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionTag, error) {
|
||||||
dataTable, err := excel.CreateNewExcelMSCFBFileBasicDataTable(data)
|
dataTable, err := excel.CreateNewExcelMSCFBFileBasicDataTable(data, true)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, nil, nil, nil, nil, err
|
return nil, nil, nil, nil, nil, nil, err
|
||||||
|
|||||||
@@ -7,24 +7,21 @@ import (
|
|||||||
"github.com/mayswind/ezbookkeeping/pkg/converters/csv"
|
"github.com/mayswind/ezbookkeeping/pkg/converters/csv"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
|
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
"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/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
var fireflyIIITransactionSupportedColumns = map[datatable.TransactionDataTableColumn]bool{
|
var fireflyIIITransactionDataColumnNameMapping = map[datatable.TransactionDataTableColumn]string{
|
||||||
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME: true,
|
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME: "date",
|
||||||
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE: true,
|
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: "type",
|
||||||
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: true,
|
datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY: "category",
|
||||||
datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY: true,
|
datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME: "source_name",
|
||||||
datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME: true,
|
datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY: "currency_code",
|
||||||
datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY: true,
|
datatable.TRANSACTION_DATA_TABLE_AMOUNT: "amount",
|
||||||
datatable.TRANSACTION_DATA_TABLE_AMOUNT: true,
|
datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME: "destination_name",
|
||||||
datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME: true,
|
datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY: "foreign_currency_code",
|
||||||
datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY: true,
|
datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT: "foreign_amount",
|
||||||
datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT: true,
|
datatable.TRANSACTION_DATA_TABLE_TAGS: "tags",
|
||||||
datatable.TRANSACTION_DATA_TABLE_TAGS: true,
|
datatable.TRANSACTION_DATA_TABLE_DESCRIPTION: "description",
|
||||||
datatable.TRANSACTION_DATA_TABLE_DESCRIPTION: true,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var fireflyIIITransactionTypeNameMapping = map[models.TransactionType]string{
|
var fireflyIIITransactionTypeNameMapping = map[models.TransactionType]string{
|
||||||
@@ -45,27 +42,14 @@ var (
|
|||||||
// ParseImportedData returns the imported data by parsing the firefly III transaction csv data
|
// ParseImportedData returns the imported data by parsing the firefly III transaction csv data
|
||||||
func (c *fireflyIIITransactionDataCsvFileImporter) ParseImportedData(ctx core.Context, user *models.User, data []byte, defaultTimezoneOffset int16, accountMap map[string]*models.Account, expenseCategoryMap map[string]map[string]*models.TransactionCategory, incomeCategoryMap map[string]map[string]*models.TransactionCategory, transferCategoryMap map[string]map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) (models.ImportedTransactionSlice, []*models.Account, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionTag, error) {
|
func (c *fireflyIIITransactionDataCsvFileImporter) ParseImportedData(ctx core.Context, user *models.User, data []byte, defaultTimezoneOffset int16, accountMap map[string]*models.Account, expenseCategoryMap map[string]map[string]*models.TransactionCategory, incomeCategoryMap map[string]map[string]*models.TransactionCategory, transferCategoryMap map[string]map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) (models.ImportedTransactionSlice, []*models.Account, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionTag, error) {
|
||||||
reader := bytes.NewReader(data)
|
reader := bytes.NewReader(data)
|
||||||
dataTable, err := csv.CreateNewCsvBasicDataTable(ctx, reader)
|
dataTable, err := csv.CreateNewCsvBasicDataTable(ctx, reader, true)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, nil, nil, nil, nil, err
|
return nil, nil, nil, nil, nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
commonDataTable := datatable.CreateNewCommonDataTableFromBasicDataTable(dataTable)
|
|
||||||
|
|
||||||
if !commonDataTable.HasColumn(fireflyIIITransactionTimeColumnName) ||
|
|
||||||
!commonDataTable.HasColumn(fireflyIIITransactionTypeColumnName) ||
|
|
||||||
!commonDataTable.HasColumn(fireflyIIITransactionSourceAccountNameColumnName) ||
|
|
||||||
!commonDataTable.HasColumn(fireflyIIITransactionSourceAccountTypeColumnName) ||
|
|
||||||
!commonDataTable.HasColumn(fireflyIIITransactionDestinationAccountNameColumnName) ||
|
|
||||||
!commonDataTable.HasColumn(fireflyIIITransactionDestinationAccountTypeColumnName) ||
|
|
||||||
!commonDataTable.HasColumn(fireflyIIITransactionAmountColumnName) {
|
|
||||||
log.Errorf(ctx, "[fireflyiii_transaction_data_csv_file_importer.ParseImportedData] cannot parse Firefly III csv data, because missing essential columns in header row")
|
|
||||||
return nil, nil, nil, nil, nil, nil, errs.ErrMissingRequiredFieldInHeaderRow
|
|
||||||
}
|
|
||||||
|
|
||||||
transactionRowParser := createFireflyIIITransactionDataRowParser()
|
transactionRowParser := createFireflyIIITransactionDataRowParser()
|
||||||
transactionDataTable := datatable.CreateNewTransactionDataTableFromCommonDataTable(commonDataTable, fireflyIIITransactionSupportedColumns, transactionRowParser)
|
transactionDataTable := datatable.CreateNewTransactionDataTableFromBasicDataTableWithRowParser(dataTable, fireflyIIITransactionDataColumnNameMapping, transactionRowParser)
|
||||||
dataTableImporter := converter.CreateNewImporterWithTypeNameMapping(fireflyIIITransactionTypeNameMapping, "", "", ",")
|
dataTableImporter := converter.CreateNewImporterWithTypeNameMapping(fireflyIIITransactionTypeNameMapping, "", "", ",")
|
||||||
|
|
||||||
return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezoneOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
|
return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezoneOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
|
||||||
|
|||||||
@@ -20,11 +20,11 @@ func TestFireFlyIIICsvFileConverterParseImportedData_MinimumValidData(t *testing
|
|||||||
DefaultCurrency: "CNY",
|
DefaultCurrency: "CNY",
|
||||||
}
|
}
|
||||||
|
|
||||||
allNewTransactions, allNewAccounts, allNewSubExpenseCategories, allNewSubIncomeCategories, allNewSubTransferCategories, allNewTags, err := converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,source_type,destination_name,destination_type,category\n"+
|
allNewTransactions, allNewAccounts, allNewSubExpenseCategories, allNewSubIncomeCategories, allNewSubTransferCategories, allNewTags, err := converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,destination_name,category\n"+
|
||||||
"\"Opening balance\",123.45,2024-09-01T00:00:00+08:00,\"Initial balance for \"\"Test Account\"\"\",\"Initial balance account\",\"Test Account\",\"Asset account\",\n"+
|
"\"Opening balance\",123.45,2024-09-01T00:00:00+08:00,\"Initial balance for \"\"Test Account\"\"\",\"Test Account\",\n"+
|
||||||
"Deposit,0.12,2024-09-01T01:23:45+08:00,\"A revenue account\",\"Revenue account\",\"Test Account\",\"Asset account\",\"Test Category\"\n"+
|
"Deposit,0.12,2024-09-01T01:23:45+08:00,\"A revenue account\",\"Test Account\",\"Test Category\"\n"+
|
||||||
"Withdrawal,-1.00,2024-09-01T12:34:56+08:00,\"Test Account\",\"Asset account\",\"A expense account\",\"Expense account\",\"Test Category2\"\n"+
|
"Withdrawal,-1.00,2024-09-01T12:34:56+08:00,\"Test Account\",\"A expense account\",\"Test Category2\"\n"+
|
||||||
"Transfer,0.05,2024-09-01T23:59:59+08:00,\"Test Account\",\"Asset account\",\"Test Account2\",\"Asset account\",\"Test Category3\""), 0, nil, nil, nil, nil, nil)
|
"Transfer,0.05,2024-09-01T23:59:59+08:00,\"Test Account\",\"Test Account2\",\"Test Category3\""), 0, nil, nil, nil, nil, nil)
|
||||||
|
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
|
|
||||||
@@ -91,16 +91,16 @@ func TestFireFlyIIICsvFileConverterParseImportedData_ParseInvalidTime(t *testing
|
|||||||
DefaultCurrency: "CNY",
|
DefaultCurrency: "CNY",
|
||||||
}
|
}
|
||||||
|
|
||||||
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,source_type,destination_name,destination_type,category\n"+
|
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,destination_name,category\n"+
|
||||||
"Withdrawal,-1.00,2024-09-01T12:34:56,\"Test Account\",\"Asset account\",\"A expense account\",\"Expense account\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
|
"Withdrawal,-1.00,2024-09-01T12:34:56,\"Test Account\",\"A expense account\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
|
||||||
assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message)
|
assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message)
|
||||||
|
|
||||||
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,source_type,destination_name,destination_type,category\n"+
|
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,destination_name,category\n"+
|
||||||
"Withdrawal,-1.00,2024-09-01 12:34:56+08:00,\"Test Account\",\"Asset account\",\"A expense account\",\"Expense account\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
|
"Withdrawal,-1.00,2024-09-01 12:34:56+08:00,\"Test Account\",\"A expense account\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
|
||||||
assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message)
|
assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFireFlyIIICsvFileConverterParseImportedData_ParseValidTransactionType(t *testing.T) {
|
func TestFireFlyIIICsvFileConverterParseImportedData_ParseInvalidType(t *testing.T) {
|
||||||
converter := FireflyIIITransactionDataCsvFileImporter
|
converter := FireflyIIITransactionDataCsvFileImporter
|
||||||
context := core.NewNullContext()
|
context := core.NewNullContext()
|
||||||
|
|
||||||
@@ -109,107 +109,8 @@ func TestFireFlyIIICsvFileConverterParseImportedData_ParseValidTransactionType(t
|
|||||||
DefaultCurrency: "CNY",
|
DefaultCurrency: "CNY",
|
||||||
}
|
}
|
||||||
|
|
||||||
// income transactions
|
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,destination_name,category\n"+
|
||||||
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,source_type,destination_name,destination_type,category\n"+
|
"Type,123.45,2024-09-01T12:34:56+08:00,\"Test Account\",\"A expense account\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
|
||||||
"Deposit,10.00,2024-09-01T12:34:56+08:00,\"A revenue account\",\"Revenue account\",\"Test Account\",\"Asset account\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
|
|
||||||
|
|
||||||
assert.Nil(t, err)
|
|
||||||
assert.Equal(t, 1, len(allNewTransactions))
|
|
||||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_INCOME, allNewTransactions[0].Type)
|
|
||||||
assert.Equal(t, int64(1000), allNewTransactions[0].Amount)
|
|
||||||
assert.Equal(t, "Test Account", allNewTransactions[0].OriginalSourceAccountName)
|
|
||||||
|
|
||||||
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,source_type,destination_name,destination_type,category\n"+
|
|
||||||
"Deposit,10.00,2024-09-01T12:34:56+08:00,\"A revenue account\",\"Revenue account\",\"Test Account\",\"Debt\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
|
|
||||||
|
|
||||||
assert.Nil(t, err)
|
|
||||||
assert.Equal(t, 1, len(allNewTransactions))
|
|
||||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_INCOME, allNewTransactions[0].Type)
|
|
||||||
assert.Equal(t, int64(1000), allNewTransactions[0].Amount)
|
|
||||||
assert.Equal(t, "Test Account", allNewTransactions[0].OriginalSourceAccountName)
|
|
||||||
|
|
||||||
// expense transactions
|
|
||||||
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,source_type,destination_name,destination_type,category\n"+
|
|
||||||
"Withdrawal,-10.00,2024-09-01T12:34:56+08:00,\"Test Account\",\"Asset account\",\"A expense account\",\"Expense account\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
|
|
||||||
|
|
||||||
assert.Nil(t, err)
|
|
||||||
assert.Equal(t, 1, len(allNewTransactions))
|
|
||||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[0].Type)
|
|
||||||
assert.Equal(t, int64(1000), allNewTransactions[0].Amount)
|
|
||||||
assert.Equal(t, "Test Account", allNewTransactions[0].OriginalSourceAccountName)
|
|
||||||
|
|
||||||
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,source_type,destination_name,destination_type,category\n"+
|
|
||||||
"Withdrawal,-10.00,2024-09-01T12:34:56+08:00,\"Test Account\",\"Debt\",\"A expense account\",\"Expense account\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
|
|
||||||
|
|
||||||
assert.Nil(t, err)
|
|
||||||
assert.Equal(t, 1, len(allNewTransactions))
|
|
||||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[0].Type)
|
|
||||||
assert.Equal(t, int64(1000), allNewTransactions[0].Amount)
|
|
||||||
assert.Equal(t, "Test Account", allNewTransactions[0].OriginalSourceAccountName)
|
|
||||||
|
|
||||||
// opening balance transactions
|
|
||||||
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,source_type,destination_name,destination_type,category\n"+
|
|
||||||
"\"Opening balance\",10.00,2024-09-01T12:34:56+08:00,\"Initial balance\",\"Initial balance account\",\"Test Account\",\"Asset account\",\"\""), 0, nil, nil, nil, nil, nil)
|
|
||||||
|
|
||||||
assert.Nil(t, err)
|
|
||||||
assert.Equal(t, 1, len(allNewTransactions))
|
|
||||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_MODIFY_BALANCE, allNewTransactions[0].Type)
|
|
||||||
assert.Equal(t, int64(1000), allNewTransactions[0].Amount)
|
|
||||||
assert.Equal(t, "Test Account", allNewTransactions[0].OriginalSourceAccountName)
|
|
||||||
|
|
||||||
// transfer transactions
|
|
||||||
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,source_type,destination_name,destination_type,category\n"+
|
|
||||||
"Transfer,10.00,2024-09-01T12:34:56+08:00,\"Test Account\",\"Asset account\",\"Test Account2\",\"Asset account\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
|
|
||||||
|
|
||||||
assert.Nil(t, err)
|
|
||||||
assert.Equal(t, 1, len(allNewTransactions))
|
|
||||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[0].Type)
|
|
||||||
assert.Equal(t, int64(1000), allNewTransactions[0].Amount)
|
|
||||||
assert.Equal(t, "Test Account", allNewTransactions[0].OriginalSourceAccountName)
|
|
||||||
assert.Equal(t, "Test Account2", allNewTransactions[0].OriginalDestinationAccountName)
|
|
||||||
|
|
||||||
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,source_type,destination_name,destination_type,category\n"+
|
|
||||||
"Withdrawal,-10.00,2024-09-01T12:34:56+08:00,\"Test Account\",\"Asset account\",\"Test Account2\",\"Debt\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
|
|
||||||
|
|
||||||
assert.Nil(t, err)
|
|
||||||
assert.Equal(t, 1, len(allNewTransactions))
|
|
||||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[0].Type)
|
|
||||||
assert.Equal(t, int64(1000), allNewTransactions[0].Amount)
|
|
||||||
assert.Equal(t, "Test Account", allNewTransactions[0].OriginalSourceAccountName)
|
|
||||||
assert.Equal(t, "Test Account2", allNewTransactions[0].OriginalDestinationAccountName)
|
|
||||||
|
|
||||||
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,source_type,destination_name,destination_type,category\n"+
|
|
||||||
"Deposit,10.00,2024-09-01T12:34:56+08:00,\"Test Account\",\"Debt\",\"Test Account2\",\"Asset account\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
|
|
||||||
|
|
||||||
assert.Nil(t, err)
|
|
||||||
assert.Equal(t, 1, len(allNewTransactions))
|
|
||||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[0].Type)
|
|
||||||
assert.Equal(t, int64(1000), allNewTransactions[0].Amount)
|
|
||||||
assert.Equal(t, "Test Account", allNewTransactions[0].OriginalSourceAccountName)
|
|
||||||
assert.Equal(t, "Test Account2", allNewTransactions[0].OriginalDestinationAccountName)
|
|
||||||
|
|
||||||
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,source_type,destination_name,destination_type,category\n"+
|
|
||||||
"Transfer,10.00,2024-09-01T12:34:56+08:00,\"Test Account\",\"Debt\",\"Test Account2\",\"Debt\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
|
|
||||||
|
|
||||||
assert.Nil(t, err)
|
|
||||||
assert.Equal(t, 1, len(allNewTransactions))
|
|
||||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[0].Type)
|
|
||||||
assert.Equal(t, int64(1000), allNewTransactions[0].Amount)
|
|
||||||
assert.Equal(t, "Test Account", allNewTransactions[0].OriginalSourceAccountName)
|
|
||||||
assert.Equal(t, "Test Account2", allNewTransactions[0].OriginalDestinationAccountName)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFireFlyIIICsvFileConverterParseImportedData_ParseInvalidTransactionType(t *testing.T) {
|
|
||||||
converter := FireflyIIITransactionDataCsvFileImporter
|
|
||||||
context := core.NewNullContext()
|
|
||||||
|
|
||||||
user := &models.User{
|
|
||||||
Uid: 1234567890,
|
|
||||||
DefaultCurrency: "CNY",
|
|
||||||
}
|
|
||||||
|
|
||||||
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,source_type,destination_name,destination_type,category\n"+
|
|
||||||
"Transfer,10.00,2024-09-01T12:34:56+08:00,\"Test Account\",\"Revenue account\",\"Test Account2\",\"Expense account\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
|
|
||||||
assert.EqualError(t, err, errs.ErrTransactionTypeInvalid.Message)
|
assert.EqualError(t, err, errs.ErrTransactionTypeInvalid.Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,15 +123,15 @@ func TestFireFlyIIICsvFileConverterParseImportedData_ParseAccountNameAsCategoryN
|
|||||||
DefaultCurrency: "CNY",
|
DefaultCurrency: "CNY",
|
||||||
}
|
}
|
||||||
|
|
||||||
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,source_type,destination_name,destination_type,category\n"+
|
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,destination_name,category\n"+
|
||||||
"Withdrawal,-1.00,2024-09-01T12:34:56+08:00,\"Test Account\",\"Asset account\",\"A expense account\",\"Expense account\",\"\""), 0, nil, nil, nil, nil, nil)
|
"Withdrawal,-1.00,2024-09-01T12:34:56+08:00,\"Test Account\",\"A expense account\",\"\""), 0, nil, nil, nil, nil, nil)
|
||||||
|
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
assert.Equal(t, 1, len(allNewTransactions))
|
assert.Equal(t, 1, len(allNewTransactions))
|
||||||
assert.Equal(t, "A expense account", allNewTransactions[0].OriginalCategoryName)
|
assert.Equal(t, "A expense account", allNewTransactions[0].OriginalCategoryName)
|
||||||
|
|
||||||
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,source_type,destination_name,destination_type,category\n"+
|
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,destination_name,category\n"+
|
||||||
"Deposit,10.00,2024-09-01T12:34:56+08:00,\"A revenue account\",\"Revenue account\",\"Test Account\",\"Asset account\",\"\""), 0, nil, nil, nil, nil, nil)
|
"Deposit,10.00,2024-09-01T12:34:56+08:00,\"A revenue account\",\"Test Account\",\"\""), 0, nil, nil, nil, nil, nil)
|
||||||
|
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
assert.Equal(t, 1, len(allNewTransactions))
|
assert.Equal(t, 1, len(allNewTransactions))
|
||||||
@@ -246,20 +147,20 @@ func TestFireFlyIIICsvFileConverterParseImportedData_ParseValidTimezone(t *testi
|
|||||||
DefaultCurrency: "CNY",
|
DefaultCurrency: "CNY",
|
||||||
}
|
}
|
||||||
|
|
||||||
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,source_type,destination_name,destination_type,category\n"+
|
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,destination_name,category\n"+
|
||||||
"Withdrawal,-1.00,2024-09-01T12:34:56-10:00,\"Test Account\",\"Asset account\",\"A expense account\",\"Expense account\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
|
"Withdrawal,-1.00,2024-09-01T12:34:56-10:00,\"Test Account\",\"A expense account\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
assert.Equal(t, 1, len(allNewTransactions))
|
assert.Equal(t, 1, len(allNewTransactions))
|
||||||
assert.Equal(t, int64(1725230096), utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime))
|
assert.Equal(t, int64(1725230096), utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime))
|
||||||
|
|
||||||
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,source_type,destination_name,destination_type,category\n"+
|
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,destination_name,category\n"+
|
||||||
"Withdrawal,-1.00,2024-09-01T12:34:56+00:00,\"Test Account\",\"Asset account\",\"A expense account\",\"Expense account\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
|
"Withdrawal,-1.00,2024-09-01T12:34:56+00:00,\"Test Account\",\"A expense account\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
assert.Equal(t, 1, len(allNewTransactions))
|
assert.Equal(t, 1, len(allNewTransactions))
|
||||||
assert.Equal(t, int64(1725194096), utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime))
|
assert.Equal(t, int64(1725194096), utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime))
|
||||||
|
|
||||||
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,source_type,destination_name,destination_type,category\n"+
|
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,destination_name,category\n"+
|
||||||
"Withdrawal,-1.00,2024-09-01T12:34:56+12:45,\"Test Account\",\"Asset account\",\"A expense account\",\"Expense account\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
|
"Withdrawal,-1.00,2024-09-01T12:34:56+12:45,\"Test Account\",\"A expense account\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
assert.Equal(t, 1, len(allNewTransactions))
|
assert.Equal(t, 1, len(allNewTransactions))
|
||||||
assert.Equal(t, int64(1725148196), utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime))
|
assert.Equal(t, int64(1725148196), utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime))
|
||||||
@@ -274,9 +175,9 @@ func TestFireFlyIIICsvFileConverterParseImportedData_ParseValidAccountCurrency(t
|
|||||||
DefaultCurrency: "CNY",
|
DefaultCurrency: "CNY",
|
||||||
}
|
}
|
||||||
|
|
||||||
allNewTransactions, allNewAccounts, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,foreign_amount,date,currency_code,foreign_currency_code,source_name,source_type,destination_name,destination_type,category\n"+
|
allNewTransactions, allNewAccounts, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,foreign_amount,date,currency_code,foreign_currency_code,source_name,destination_name,category\n"+
|
||||||
"\"Opening balance\",123.45,,2024-09-01T00:00:00+08:00,USD,,\"Initial balance for \"\"Test Account\"\"\",\"Initial balance account\",\"Test Account\",\"Asset account\",\n"+
|
"\"Opening balance\",123.45,,2024-09-01T00:00:00+08:00,USD,,\"Initial balance for \"\"Test Account\"\"\",\"Test Account\",\n"+
|
||||||
"Transfer,1.23,-1.10,2024-09-01T23:59:59+08:00,USD,EUR,\"Test Account\",\"Asset account\",\"Test Account2\",\"Asset account\",\"Test Category2\""), 0, nil, nil, nil, nil, nil)
|
"Transfer,1.23,1.10,2024-09-01T23:59:59+08:00,USD,EUR,\"Test Account\",\"Test Account2\",\"Test Category2\""), 0, nil, nil, nil, nil, nil)
|
||||||
|
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
|
|
||||||
@@ -301,8 +202,8 @@ func TestFireFlyIIICsvFileConverterParseImportedData_ParseValidForeignAmountAndC
|
|||||||
DefaultCurrency: "CNY",
|
DefaultCurrency: "CNY",
|
||||||
}
|
}
|
||||||
|
|
||||||
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,foreign_amount,date,currency_code,foreign_currency_code,source_name,source_type,destination_name,destination_type,category\n"+
|
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,foreign_amount,date,currency_code,foreign_currency_code,source_name,destination_name,category\n"+
|
||||||
"Transfer,10.00,15.00,2024-09-01T12:34:56+08:00,USD,EUR,\"Test Account\",\"Asset account\",\"Test Account2\",\"Asset account\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
|
"Transfer,10.00,15.00,2024-09-01T12:34:56+08:00,USD,EUR,\"Test Account\",\"Test Account2\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
|
||||||
|
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
assert.Equal(t, 1, len(allNewTransactions))
|
assert.Equal(t, 1, len(allNewTransactions))
|
||||||
@@ -312,8 +213,8 @@ func TestFireFlyIIICsvFileConverterParseImportedData_ParseValidForeignAmountAndC
|
|||||||
assert.Equal(t, "USD", allNewTransactions[0].OriginalSourceAccountCurrency)
|
assert.Equal(t, "USD", allNewTransactions[0].OriginalSourceAccountCurrency)
|
||||||
assert.Equal(t, "EUR", allNewTransactions[0].OriginalDestinationAccountCurrency)
|
assert.Equal(t, "EUR", allNewTransactions[0].OriginalDestinationAccountCurrency)
|
||||||
|
|
||||||
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,currency_code,foreign_currency_code,source_name,source_type,destination_name,destination_type,category\n"+
|
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,currency_code,foreign_currency_code,source_name,destination_name,category\n"+
|
||||||
"Transfer,10.00,2024-09-01T12:34:56+08:00,USD,EUR,\"Test Account\",\"Asset account\",\"Test Account2\",\"Asset account\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
|
"Transfer,10.00,2024-09-01T12:34:56+08:00,USD,EUR,\"Test Account\",\"Test Account2\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
|
||||||
|
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
assert.Equal(t, 1, len(allNewTransactions))
|
assert.Equal(t, 1, len(allNewTransactions))
|
||||||
@@ -322,8 +223,8 @@ func TestFireFlyIIICsvFileConverterParseImportedData_ParseValidForeignAmountAndC
|
|||||||
assert.Equal(t, "USD", allNewTransactions[0].OriginalSourceAccountCurrency)
|
assert.Equal(t, "USD", allNewTransactions[0].OriginalSourceAccountCurrency)
|
||||||
assert.Equal(t, "EUR", allNewTransactions[0].OriginalDestinationAccountCurrency)
|
assert.Equal(t, "EUR", allNewTransactions[0].OriginalDestinationAccountCurrency)
|
||||||
|
|
||||||
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,currency_code,source_name,source_type,destination_name,destination_type,category\n"+
|
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,currency_code,foreign_currency_code,source_name,destination_name,category\n"+
|
||||||
"Transfer,10.00,2024-09-01T12:34:56+08:00,USD,\"Test Account\",\"Asset account\",\"Test Account2\",\"Asset account\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
|
"Transfer,10.00,2024-09-01T12:34:56+08:00,USD,,\"Test Account\",\"Test Account2\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
|
||||||
|
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
assert.Equal(t, 1, len(allNewTransactions))
|
assert.Equal(t, 1, len(allNewTransactions))
|
||||||
@@ -340,14 +241,14 @@ func TestFireFlyIIICsvFileConverterParseImportedData_ParseInvalidAccountCurrency
|
|||||||
DefaultCurrency: "CNY",
|
DefaultCurrency: "CNY",
|
||||||
}
|
}
|
||||||
|
|
||||||
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,foreign_amount,date,currency_code,foreign_currency_code,source_name,source_type,destination_name,destination_type,category\n"+
|
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,foreign_amount,date,currency_code,foreign_currency_code,source_name,destination_name,category\n"+
|
||||||
"\"Opening balance\",123.45,,2024-09-01T00:00:00+08:00,USD,,\"Initial balance for \"\"Test Account\"\"\",\"Initial balance account\",\"Test Account\",\"Asset account\",\n"+
|
"\"Opening balance\",123.45,,2024-09-01T00:00:00+08:00,USD,,\"Initial balance for \"\"Test Account\"\"\",\"Test Account\",\n"+
|
||||||
"Transfer,1.23,1.10,2024-09-01T23:59:59+08:00,CNY,EUR,\"Test Account\",\"Asset account\",\"Test Account2\",\"Asset account\",\"Test Category3\""), 0, nil, nil, nil, nil, nil)
|
"Transfer,1.23,1.10,2024-09-01T23:59:59+08:00,CNY,EUR,\"Test Account\",\"Test Account2\",\"Test Category3\""), 0, nil, nil, nil, nil, nil)
|
||||||
assert.EqualError(t, err, errs.ErrAccountCurrencyInvalid.Message)
|
assert.EqualError(t, err, errs.ErrAccountCurrencyInvalid.Message)
|
||||||
|
|
||||||
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,foreign_amount,date,currency_code,foreign_currency_code,source_name,source_type,destination_name,destination_type,category\n"+
|
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,foreign_amount,date,currency_code,foreign_currency_code,source_name,destination_name,category\n"+
|
||||||
"\"Opening balance\",123.45,,2024-09-01T00:00:00+08:00,USD,,\"Initial balance for \"\"Test Account\"\"\",\"Initial balance account\",\"Test Account\",\n"+
|
"\"Opening balance\",123.45,,2024-09-01T00:00:00+08:00,USD,,\"Initial balance for \"\"Test Account\"\"\",\"Test Account\",\n"+
|
||||||
"Transfer,1.23,1.10,2024-09-01T23:59:59+08:00,CNY,EUR,\"Test Account2\",\"Asset account\",\"Test Account\",\"Asset account\",\"Test Category3\""), 0, nil, nil, nil, nil, nil)
|
"Transfer,1.23,1.10,2024-09-01T23:59:59+08:00,CNY,EUR,\"Test Account2\",\"Test Account\",\"Test Category3\""), 0, nil, nil, nil, nil, nil)
|
||||||
assert.EqualError(t, err, errs.ErrAccountCurrencyInvalid.Message)
|
assert.EqualError(t, err, errs.ErrAccountCurrencyInvalid.Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,12 +261,12 @@ func TestFireFlyIIICsvFileConverterParseImportedData_ParseNotSupportedCurrency(t
|
|||||||
DefaultCurrency: "CNY",
|
DefaultCurrency: "CNY",
|
||||||
}
|
}
|
||||||
|
|
||||||
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,foreign_amount,date,currency_code,foreign_currency_code,source_name,source_type,destination_name,destination_type,category\n"+
|
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,foreign_amount,date,currency_code,foreign_currency_code,source_name,destination_name,category\n"+
|
||||||
"\"Opening balance\",123.45,,2024-09-01T00:00:00+08:00,XXX,,\"Initial balance for \"\"Test Account\"\"\",\"Initial balance account\",\"Test Account\",\"Asset account\",\n"), 0, nil, nil, nil, nil, nil)
|
"\"Opening balance\",123.45,,2024-09-01T00:00:00+08:00,XXX,,\"Initial balance for \"\"Test Account\"\"\",\"Test Account\",\n"), 0, nil, nil, nil, nil, nil)
|
||||||
assert.EqualError(t, err, errs.ErrAccountCurrencyInvalid.Message)
|
assert.EqualError(t, err, errs.ErrAccountCurrencyInvalid.Message)
|
||||||
|
|
||||||
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,foreign_amount,date,currency_code,foreign_currency_code,source_name,source_type,destination_name,destination_type,category\n"+
|
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,foreign_amount,date,currency_code,foreign_currency_code,source_name,destination_name,category\n"+
|
||||||
"Transfer,123.45,123.45,2024-09-01T23:59:59+08:00,USD,XXX,\"Test Account\",\"Asset account\",\"Test Account2\",\"Asset account\",\"Test Category2\""), 0, nil, nil, nil, nil, nil)
|
"Transfer,123.45,123.45,2024-09-01T23:59:59+08:00,USD,XXX,\"Test Account\",\"Test Account2\",\"Test Category2\""), 0, nil, nil, nil, nil, nil)
|
||||||
assert.EqualError(t, err, errs.ErrAccountCurrencyInvalid.Message)
|
assert.EqualError(t, err, errs.ErrAccountCurrencyInvalid.Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,12 +279,12 @@ func TestFireFlyIIICsvFileConverterParseImportedData_ParseInvalidAmount(t *testi
|
|||||||
DefaultCurrency: "CNY",
|
DefaultCurrency: "CNY",
|
||||||
}
|
}
|
||||||
|
|
||||||
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,source_type,destination_name,destination_type,category\n"+
|
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,destination_name,category\n"+
|
||||||
"Withdrawal,-123 45,2024-09-01T12:34:56+08:00,\"Test Account\",\"Asset account\",\"A expense account\",\"Expense account\",\"Test Category\"\n"), 0, nil, nil, nil, nil, nil)
|
"Withdrawal,-123 45,2024-09-01T12:34:56+08:00,\"Test Account\",\"A expense account\",\"Test Category\"\n"), 0, nil, nil, nil, nil, nil)
|
||||||
assert.EqualError(t, err, errs.ErrAmountInvalid.Message)
|
assert.EqualError(t, err, errs.ErrAmountInvalid.Message)
|
||||||
|
|
||||||
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,foreign_amount,date,source_name,source_type,destination_name,destination_type,category\n"+
|
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,foreign_amount,date,source_name,destination_name,category\n"+
|
||||||
"Transfer,123.45,123 45,2024-09-01T23:59:59+08:00,\"Test Account\",\"Asset account\",\"Test Account2\",\"Asset account\",\"Test Category2\""), 0, nil, nil, nil, nil, nil)
|
"Transfer,123.45,123 45,2024-09-01T23:59:59+08:00,\"Test Account\",\"Test Account2\",\"Test Category2\""), 0, nil, nil, nil, nil, nil)
|
||||||
assert.EqualError(t, err, errs.ErrAmountInvalid.Message)
|
assert.EqualError(t, err, errs.ErrAmountInvalid.Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,8 +297,8 @@ func TestFireFlyIIICsvFileConverterParseImportedData_ParseDescription(t *testing
|
|||||||
DefaultCurrency: "CNY",
|
DefaultCurrency: "CNY",
|
||||||
}
|
}
|
||||||
|
|
||||||
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,description,date,source_name,source_type,destination_name,destination_type,category\n"+
|
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,description,date,source_name,destination_name,category\n"+
|
||||||
"Withdrawal,-123.45,\"foo bar\t#test\",2024-09-01T12:34:56+08:00,\"Test Account\",\"Asset account\",\"A expense account\",\"Expense account\",\"Test Category\"\n"), 0, nil, nil, nil, nil, nil)
|
"Withdrawal,-123.45,\"foo bar\t#test\",2024-09-01T12:34:56+08:00,\"Test Account\",\"A expense account\",\"Test Category\"\n"), 0, nil, nil, nil, nil, nil)
|
||||||
|
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
assert.Equal(t, 1, len(allNewTransactions))
|
assert.Equal(t, 1, len(allNewTransactions))
|
||||||
@@ -413,8 +314,8 @@ func TestFireFlyIIICsvFileConverterParseImportedData_ParseTags(t *testing.T) {
|
|||||||
DefaultCurrency: "CNY",
|
DefaultCurrency: "CNY",
|
||||||
}
|
}
|
||||||
|
|
||||||
allNewTransactions, _, _, _, _, allNewTags, err := converter.ParseImportedData(context, user, []byte("type,amount,tags,date,source_name,source_type,destination_name,destination_type,category\n"+
|
allNewTransactions, _, _, _, _, allNewTags, err := converter.ParseImportedData(context, user, []byte("type,amount,tags,date,source_name,destination_name,category\n"+
|
||||||
"Withdrawal,-123.45,\"tag1,tag2,tag3\",2024-09-01T12:34:56+08:00,\"Test Account\",\"Asset account\",\"A expense account\",\"Expense account\",\"Test Category\"\n"), 0, nil, nil, nil, nil, nil)
|
"Withdrawal,-123.45,\"tag1,tag2,tag3\",2024-09-01T12:34:56+08:00,\"Test Account\",\"A expense account\",\"Test Category\"\n"), 0, nil, nil, nil, nil, nil)
|
||||||
|
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
assert.Equal(t, 1, len(allNewTransactions))
|
assert.Equal(t, 1, len(allNewTransactions))
|
||||||
@@ -437,7 +338,7 @@ func TestFireFlyIIICsvFileConverterParseImportedData_MissingFileHeader(t *testin
|
|||||||
}
|
}
|
||||||
|
|
||||||
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(""), 0, nil, nil, nil, nil, nil)
|
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(""), 0, nil, nil, nil, nil, nil)
|
||||||
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
|
assert.EqualError(t, err, errs.ErrNotFoundTransactionDataInFile.Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFireFlyIIICsvFileConverterParseImportedData_MissingRequiredColumn(t *testing.T) {
|
func TestFireFlyIIICsvFileConverterParseImportedData_MissingRequiredColumn(t *testing.T) {
|
||||||
@@ -450,13 +351,18 @@ func TestFireFlyIIICsvFileConverterParseImportedData_MissingRequiredColumn(t *te
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Missing Time Column
|
// Missing Time Column
|
||||||
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,source_name,source_type,destination_name,destination_type,category\n"+
|
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,source_name,destination_name,category\n"+
|
||||||
"\"Opening balance\",123.45,\"Initial balance for \"\"Test Account\"\"\",\"Initial balance account\",\"Test Account\",\"Asset account\",\n"), 0, nil, nil, nil, nil, nil)
|
"\"Opening balance\",123.45,\"Initial balance for \"\"Test Account\"\"\",\"Test Account\",\n"), 0, nil, nil, nil, nil, nil)
|
||||||
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
|
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
|
||||||
|
|
||||||
// Missing Type Column
|
// Missing Type Column
|
||||||
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("amount,date,source_name,source_type,destination_name,destination_type,category\n"+
|
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("amount,date,source_name,destination_name,category\n"+
|
||||||
"123.45,2024-09-01T00:00:00+08:00,\"Initial balance for \"\"Test Account\"\"\",\"Initial balance account\",\"Test Account\",\"Asset account\",\n"), 0, nil, nil, nil, nil, nil)
|
"123.45,2024-09-01T00:00:00+08:00,\"Initial balance for \"\"Test Account\"\"\",\"Test Account\",\n"), 0, nil, nil, nil, nil, nil)
|
||||||
|
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
|
||||||
|
|
||||||
|
// Missing Sub Category Column
|
||||||
|
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,destination_name\n"+
|
||||||
|
"\"Opening balance\",123.45,2024-09-01T00:00:00+08:00,\"Initial balance for \"\"Test Account\"\"\",\"Test Account\"\n"), 0, nil, nil, nil, nil, nil)
|
||||||
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
|
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
|
||||||
|
|
||||||
// Missing Account Name Column
|
// Missing Account Name Column
|
||||||
@@ -465,22 +371,12 @@ func TestFireFlyIIICsvFileConverterParseImportedData_MissingRequiredColumn(t *te
|
|||||||
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
|
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
|
||||||
|
|
||||||
// Missing Amount Column
|
// Missing Amount Column
|
||||||
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,date,source_name,source_type,destination_name,destination_type,category\n"+
|
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,date,source_name,destination_name,category\n"+
|
||||||
"\"Opening balance\",2024-09-01T00:00:00+08:00,\"Initial balance for \"\"Test Account\"\"\",\"Initial balance account\",\"Test Account\",\"Asset account\",\n"), 0, nil, nil, nil, nil, nil)
|
"\"Opening balance\",2024-09-01T00:00:00+08:00,\"Initial balance for \"\"Test Account\"\"\",\"Test Account\",\n"), 0, nil, nil, nil, nil, nil)
|
||||||
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
|
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
|
||||||
|
|
||||||
// Missing Account2 Name Column
|
// Missing Account2 Name Column
|
||||||
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,category\n"+
|
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,category\n"+
|
||||||
"\"Opening balance\",123.45,2024-09-01T00:00:00+08:00,\"Initial balance for \"\"Test Account\"\"\",\n"), 0, nil, nil, nil, nil, nil)
|
"\"Opening balance\",123.45,2024-09-01T00:00:00+08:00,\"Initial balance for \"\"Test Account\"\"\",\n"), 0, nil, nil, nil, nil, nil)
|
||||||
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
|
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
|
||||||
|
|
||||||
// Missing Source Account Type Column
|
|
||||||
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,destination_name,destination_type,category\n"+
|
|
||||||
"\"Opening balance\",123.45,2024-09-01T00:00:00+08:00,\"Initial balance for \"\"Test Account\"\"\",\"Test Account\",\"Asset account\",\"Asset account\",\n"), 0, nil, nil, nil, nil, nil)
|
|
||||||
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
|
|
||||||
|
|
||||||
// Missing Destination Account Type Column
|
|
||||||
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,source_type,destination_name,category\n"+
|
|
||||||
"\"Opening balance\",123.45,2024-09-01T00:00:00+08:00,\"Initial balance for \"\"Test Account\"\"\",\"Asset account\",\"Test Account\",\n"), 0, nil, nil, nil, nil, nil)
|
|
||||||
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,132 +2,103 @@ package fireflyIII
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
|
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
const fireflyIIITransactionTimeColumnName = "date"
|
|
||||||
const fireflyIIITransactionTypeColumnName = "type"
|
|
||||||
const fireflyIIITransactionCategoryColumnName = "category"
|
|
||||||
const fireflyIIITransactionSourceAccountNameColumnName = "source_name"
|
|
||||||
const fireflyIIITransactionSourceAccountTypeColumnName = "source_type"
|
|
||||||
const fireflyIIITransactionCurrencyCodeColumnName = "currency_code"
|
|
||||||
const fireflyIIITransactionAmountColumnName = "amount"
|
|
||||||
const fireflyIIITransactionDestinationAccountNameColumnName = "destination_name"
|
|
||||||
const fireflyIIITransactionDestinationAccountTypeColumnName = "destination_type"
|
|
||||||
const fireflyIIITransactionForeignCurrencyCodeColumnName = "foreign_currency_code"
|
|
||||||
const fireflyIIITransactionForeignAmountColumnName = "foreign_amount"
|
|
||||||
const fireflyIIITransactionTagsColumnName = "tags"
|
|
||||||
const fireflyIIITransactionDescriptionColumnName = "description"
|
|
||||||
|
|
||||||
const fireflyIIIAssetAccountName = "Asset account"
|
|
||||||
const fireflyIIIExpenseAccountName = "Expense account"
|
|
||||||
const fireflyIIIRevenueAccountName = "Revenue account"
|
|
||||||
const fireflyIIIDebtAccountName = "Debt"
|
|
||||||
|
|
||||||
// fireflyIIITransactionDataRowParser defines the structure of firefly III transaction data row parser
|
// fireflyIIITransactionDataRowParser defines the structure of firefly III transaction data row parser
|
||||||
type fireflyIIITransactionDataRowParser struct {
|
type fireflyIIITransactionDataRowParser struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetAddedColumns returns the added columns after converting the data row
|
||||||
|
func (p *fireflyIIITransactionDataRowParser) GetAddedColumns() []datatable.TransactionDataTableColumn {
|
||||||
|
return []datatable.TransactionDataTableColumn{
|
||||||
|
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Parse returns the converted transaction data row
|
// Parse returns the converted transaction data row
|
||||||
func (p *fireflyIIITransactionDataRowParser) Parse(ctx core.Context, user *models.User, dataRow datatable.CommonDataTableRow, rowId string) (rowData map[datatable.TransactionDataTableColumn]string, rowDataValid bool, err error) {
|
func (p *fireflyIIITransactionDataRowParser) Parse(data map[datatable.TransactionDataTableColumn]string) (rowData map[datatable.TransactionDataTableColumn]string, rowDataValid bool, err error) {
|
||||||
rowData = make(map[datatable.TransactionDataTableColumn]string, len(fireflyIIITransactionSupportedColumns))
|
rowData = make(map[datatable.TransactionDataTableColumn]string, len(data))
|
||||||
|
|
||||||
|
for column, value := range data {
|
||||||
|
rowData[column] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
// use the expense and revenue account name as category names if the category name is empty
|
||||||
|
if rowData[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] == "" {
|
||||||
|
if rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] == fireflyIIITransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME] {
|
||||||
|
rowData[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = rowData[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME]
|
||||||
|
} else if rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] == fireflyIIITransactionTypeNameMapping[models.TRANSACTION_TYPE_EXPENSE] {
|
||||||
|
rowData[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// parse long date time and timezone
|
// parse long date time and timezone
|
||||||
dateTime, err := utils.ParseFromLongDateTimeWithTimezoneRFC3339Format(dataRow.GetData(fireflyIIITransactionTimeColumnName))
|
if rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME] != "" {
|
||||||
|
dateTime, err := utils.ParseFromLongDateTimeWithTimezoneRFC3339Format(rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME])
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false, errs.ErrTransactionTimeInvalid
|
return nil, false, errs.ErrTransactionTimeInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME] = utils.FormatUnixTimeToLongDateTime(dateTime.Unix(), dateTime.Location())
|
||||||
|
rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE] = utils.FormatTimezoneOffset(dateTime.Location())
|
||||||
}
|
}
|
||||||
|
|
||||||
rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME] = utils.FormatUnixTimeToLongDateTime(dateTime.Unix(), dateTime.Location())
|
// trim trailing zero in decimal
|
||||||
rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE] = utils.FormatTimezoneOffset(dateTime.Location())
|
if rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT] != "" {
|
||||||
|
rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.TrimTrailingZerosInDecimal(rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT])
|
||||||
// parse transaction type, transaction category and amount
|
amount, err := utils.ParseAmount(rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT])
|
||||||
transactionType := dataRow.GetData(fireflyIIITransactionTypeColumnName)
|
|
||||||
sourceAccountType := dataRow.GetData(fireflyIIITransactionSourceAccountTypeColumnName)
|
|
||||||
destinationAccountType := dataRow.GetData(fireflyIIITransactionDestinationAccountTypeColumnName)
|
|
||||||
|
|
||||||
amount, err := utils.ParseAmount(utils.TrimTrailingZerosInDecimal(dataRow.GetData(fireflyIIITransactionAmountColumnName)))
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, false, errs.ErrAmountInvalid
|
|
||||||
}
|
|
||||||
|
|
||||||
foreignAmount := amount
|
|
||||||
|
|
||||||
if dataRow.HasData(fireflyIIITransactionForeignAmountColumnName) && dataRow.GetData(fireflyIIITransactionForeignAmountColumnName) != "" {
|
|
||||||
foreignAmount, err = utils.ParseAmount(utils.TrimTrailingZerosInDecimal(dataRow.GetData(fireflyIIITransactionForeignAmountColumnName)))
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false, errs.ErrAmountInvalid
|
return nil, false, errs.ErrAmountInvalid
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
rowData[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = dataRow.GetData(fireflyIIITransactionCategoryColumnName)
|
if rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] == fireflyIIITransactionTypeNameMapping[models.TRANSACTION_TYPE_EXPENSE] {
|
||||||
|
|
||||||
if sourceAccountType == fireflyIIIRevenueAccountName && (destinationAccountType == fireflyIIIAssetAccountName || destinationAccountType == fireflyIIIDebtAccountName) { // income
|
|
||||||
rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = fireflyIIITransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME]
|
|
||||||
|
|
||||||
// if the category is empty, use the source account (revenue account) name as the category name
|
|
||||||
if rowData[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] == "" {
|
|
||||||
rowData[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = dataRow.GetData(fireflyIIITransactionSourceAccountNameColumnName)
|
|
||||||
}
|
|
||||||
|
|
||||||
rowData[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = dataRow.GetData(fireflyIIITransactionDestinationAccountNameColumnName)
|
|
||||||
rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(amount)
|
|
||||||
} else if (sourceAccountType == fireflyIIIAssetAccountName || sourceAccountType == fireflyIIIDebtAccountName) && destinationAccountType == fireflyIIIExpenseAccountName { // expense
|
|
||||||
rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = fireflyIIITransactionTypeNameMapping[models.TRANSACTION_TYPE_EXPENSE]
|
|
||||||
|
|
||||||
// if the category is empty, use the destination account (expense account) name as the category name
|
|
||||||
if rowData[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] == "" {
|
|
||||||
rowData[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = dataRow.GetData(fireflyIIITransactionDestinationAccountNameColumnName)
|
|
||||||
}
|
|
||||||
|
|
||||||
rowData[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = dataRow.GetData(fireflyIIITransactionSourceAccountNameColumnName)
|
|
||||||
rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(-amount)
|
|
||||||
} else if transactionType == fireflyIIITransactionTypeNameMapping[models.TRANSACTION_TYPE_MODIFY_BALANCE] { // opening balance
|
|
||||||
rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = fireflyIIITransactionTypeNameMapping[models.TRANSACTION_TYPE_MODIFY_BALANCE]
|
|
||||||
rowData[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = ""
|
|
||||||
rowData[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = dataRow.GetData(fireflyIIITransactionDestinationAccountNameColumnName)
|
|
||||||
rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(amount)
|
|
||||||
} else if (sourceAccountType == fireflyIIIAssetAccountName || sourceAccountType == fireflyIIIDebtAccountName) && (destinationAccountType == fireflyIIIAssetAccountName || destinationAccountType == fireflyIIIDebtAccountName) { // transfer
|
|
||||||
rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = fireflyIIITransactionTypeNameMapping[models.TRANSACTION_TYPE_TRANSFER]
|
|
||||||
rowData[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = dataRow.GetData(fireflyIIITransactionSourceAccountNameColumnName)
|
|
||||||
rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = dataRow.GetData(fireflyIIITransactionDestinationAccountNameColumnName)
|
|
||||||
|
|
||||||
if transactionType == fireflyIIITransactionTypeNameMapping[models.TRANSACTION_TYPE_EXPENSE] {
|
|
||||||
rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(-amount)
|
rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(-amount)
|
||||||
rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = utils.FormatAmount(-foreignAmount)
|
|
||||||
} else {
|
} else {
|
||||||
rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(amount)
|
rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(amount)
|
||||||
rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = utils.FormatAmount(foreignAmount)
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
log.Errorf(ctx, "[fireflyiii_transaction_data_row_parser.Parse] cannot detect transaction type, source account type is \"%s\", destination account type is \"%s\", Firefly III transaction type is \"%s\"", sourceAccountType, destinationAccountType, transactionType)
|
|
||||||
return nil, false, errs.ErrTransactionTypeInvalid
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// parse account currency
|
if rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] != "" {
|
||||||
rowData[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY] = dataRow.GetData(fireflyIIITransactionCurrencyCodeColumnName)
|
rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = utils.TrimTrailingZerosInDecimal(rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT])
|
||||||
rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY] = dataRow.GetData(fireflyIIITransactionForeignCurrencyCodeColumnName)
|
amount, err := utils.ParseAmount(rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT])
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, errs.ErrAmountInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
if rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] == fireflyIIITransactionTypeNameMapping[models.TRANSACTION_TYPE_EXPENSE] {
|
||||||
|
rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = utils.FormatAmount(-amount)
|
||||||
|
} else {
|
||||||
|
rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = utils.FormatAmount(amount)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT]
|
||||||
|
}
|
||||||
|
|
||||||
// the related account currency field is foreign currency in firefly III actually
|
// the related account currency field is foreign currency in firefly III actually
|
||||||
if rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY] == "" && rowData[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY] != "" {
|
if rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY] == "" {
|
||||||
rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY] = rowData[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY]
|
rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY] = rowData[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY]
|
||||||
}
|
}
|
||||||
|
|
||||||
// parse tags / description
|
// the destination account of modify balance transaction in firefly III is the asset account
|
||||||
rowData[datatable.TRANSACTION_DATA_TABLE_TAGS] = dataRow.GetData(fireflyIIITransactionTagsColumnName)
|
if rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] == fireflyIIITransactionTypeNameMapping[models.TRANSACTION_TYPE_MODIFY_BALANCE] {
|
||||||
rowData[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = dataRow.GetData(fireflyIIITransactionDescriptionColumnName)
|
rowData[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME]
|
||||||
|
}
|
||||||
|
|
||||||
|
// the destination account of income transaction in firefly III is the asset account
|
||||||
|
if rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] == fireflyIIITransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME] {
|
||||||
|
rowData[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME]
|
||||||
|
}
|
||||||
|
|
||||||
return rowData, true, nil
|
return rowData, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// createFireflyIIITransactionDataRowParser returns firefly III transaction data row parser
|
// createFireflyIIITransactionDataRowParser returns firefly III transaction data row parser
|
||||||
func createFireflyIIITransactionDataRowParser() datatable.CommonTransactionDataRowParser {
|
func createFireflyIIITransactionDataRowParser() datatable.TransactionDataRowParser {
|
||||||
return &fireflyIIITransactionDataRowParser{}
|
return &fireflyIIITransactionDataRowParser{}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package jdcom
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
|
||||||
|
"golang.org/x/text/encoding/unicode"
|
||||||
|
"golang.org/x/text/transform"
|
||||||
|
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/converters/converter"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/converters/csv"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// jdComFinanceTransactionDataCsvFileImporter defines the structure of jd.com finance csv importer for transaction data
|
||||||
|
type jdComFinanceTransactionDataCsvFileImporter struct {
|
||||||
|
fileHeaderLineBeginning string
|
||||||
|
dataHeaderStartContentBeginning string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize a jd.com finance transaction data csv file importer singleton instance
|
||||||
|
var (
|
||||||
|
JDComFinanceTransactionDataCsvFileImporter = &jdComFinanceTransactionDataCsvFileImporter{}
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParseImportedData returns the imported data by parsing the jd.com finance transaction csv data
|
||||||
|
func (c *jdComFinanceTransactionDataCsvFileImporter) ParseImportedData(ctx core.Context, user *models.User, data []byte, defaultTimezoneOffset int16, accountMap map[string]*models.Account, expenseCategoryMap map[string]map[string]*models.TransactionCategory, incomeCategoryMap map[string]map[string]*models.TransactionCategory, transferCategoryMap map[string]map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) (models.ImportedTransactionSlice, []*models.Account, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionTag, error) {
|
||||||
|
fallback := unicode.UTF8.NewDecoder()
|
||||||
|
reader := transform.NewReader(bytes.NewReader(data), unicode.BOMOverride(fallback))
|
||||||
|
|
||||||
|
csvDataTable, err := csv.CreateNewCsvBasicDataTable(ctx, reader, false)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, nil, nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dataTable, err := createNewJDComFinanceTransactionBasicDataTable(ctx, csvDataTable)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, nil, nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
commonDataTable := datatable.CreateNewCommonDataTableFromBasicDataTable(dataTable)
|
||||||
|
|
||||||
|
if !commonDataTable.HasColumn(jdComFinanceTransactionTimeColumnName) ||
|
||||||
|
!commonDataTable.HasColumn(jdComFinanceTransactionMerchantNameColumnName) ||
|
||||||
|
!commonDataTable.HasColumn(jdComFinanceTransactionMemoColumnName) ||
|
||||||
|
!commonDataTable.HasColumn(jdComFinanceTransactionAmountColumnName) ||
|
||||||
|
!commonDataTable.HasColumn(jdComFinanceTransactionRelatedAccountColumnName) ||
|
||||||
|
!commonDataTable.HasColumn(jdComFinanceTransactionStatusColumnName) ||
|
||||||
|
!commonDataTable.HasColumn(jdComFinanceTransactionTypeColumnName) {
|
||||||
|
log.Errorf(ctx, "[jdcom_finance_transaction_data_csv_file_importer.ParseImportedData] cannot parse jd.com finance csv data, because missing essential columns in header row")
|
||||||
|
return nil, nil, nil, nil, nil, nil, errs.ErrMissingRequiredFieldInHeaderRow
|
||||||
|
}
|
||||||
|
|
||||||
|
transactionRowParser := createJDComFinanceTransactionDataRowParser(dataTable.HeaderColumnNames())
|
||||||
|
transactionDataTable := datatable.CreateNewTransactionDataTableFromCommonDataTable(commonDataTable, jdComFinanceTransactionSupportedColumns, transactionRowParser)
|
||||||
|
dataTableImporter := converter.CreateNewSimpleImporterWithTypeNameMapping(jdComFinanceTransactionTypeNameMapping)
|
||||||
|
|
||||||
|
return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezoneOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
|
||||||
|
}
|
||||||
@@ -0,0 +1,508 @@
|
|||||||
|
package jdcom
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestJDComFinanceCsvFileImporterParseImportedData_MinimumValidData(t *testing.T) {
|
||||||
|
converter := JDComFinanceTransactionDataCsvFileImporter
|
||||||
|
context := core.NewNullContext()
|
||||||
|
|
||||||
|
user := &models.User{
|
||||||
|
Uid: 1234567890,
|
||||||
|
DefaultCurrency: "CNY",
|
||||||
|
}
|
||||||
|
|
||||||
|
data := "导出信息:\n" +
|
||||||
|
"京东账号名:xxxxxx\n" +
|
||||||
|
"日期区间:2025-01-01 至 2025-09-01\n" +
|
||||||
|
"\n" +
|
||||||
|
"交易时间,商户名称,交易说明,金额,收/付款方式,交易状态,收/支,交易分类\n" +
|
||||||
|
"2025-09-01 01:23:45,xxx,xxx,0.12,余额,交易成功,收入,其他\n" +
|
||||||
|
"2025-09-01 12:34:56,xxx,xxx,123.45,银行卡,交易成功,支出,其他网购\n" +
|
||||||
|
"2025-09-01 23:59:59,xxx,京东钱包余额充值,0.05,银行卡,交易成功,不计收支,余额\n" +
|
||||||
|
"2025-09-02 23:59:59,xxx,京东余额提现,0.03,银行卡,交易成功,不计收支,余额\n"
|
||||||
|
allNewTransactions, allNewAccounts, allNewSubExpenseCategories, allNewSubIncomeCategories, allNewSubTransferCategories, allNewTags, err := converter.ParseImportedData(context, user, []byte(data), 0, nil, nil, nil, nil, nil)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 4, len(allNewTransactions))
|
||||||
|
assert.Equal(t, 3, len(allNewAccounts))
|
||||||
|
assert.Equal(t, 1, len(allNewSubExpenseCategories))
|
||||||
|
assert.Equal(t, 1, len(allNewSubIncomeCategories))
|
||||||
|
assert.Equal(t, 1, len(allNewSubTransferCategories))
|
||||||
|
assert.Equal(t, 0, len(allNewTags))
|
||||||
|
|
||||||
|
assert.Equal(t, int64(1234567890), allNewTransactions[0].Uid)
|
||||||
|
assert.Equal(t, models.TRANSACTION_DB_TYPE_INCOME, allNewTransactions[0].Type)
|
||||||
|
assert.Equal(t, "2025-09-01 01:23:45", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime), time.UTC))
|
||||||
|
assert.Equal(t, int64(12), allNewTransactions[0].Amount)
|
||||||
|
assert.Equal(t, "余额", allNewTransactions[0].OriginalSourceAccountName)
|
||||||
|
assert.Equal(t, "其他", allNewTransactions[0].OriginalCategoryName)
|
||||||
|
|
||||||
|
assert.Equal(t, int64(1234567890), allNewTransactions[1].Uid)
|
||||||
|
assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[1].Type)
|
||||||
|
assert.Equal(t, "2025-09-01 12:34:56", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[1].TransactionTime), time.UTC))
|
||||||
|
assert.Equal(t, int64(12345), allNewTransactions[1].Amount)
|
||||||
|
assert.Equal(t, "银行卡", allNewTransactions[1].OriginalSourceAccountName)
|
||||||
|
assert.Equal(t, "其他网购", allNewTransactions[1].OriginalCategoryName)
|
||||||
|
|
||||||
|
assert.Equal(t, int64(1234567890), allNewTransactions[2].Uid)
|
||||||
|
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[2].Type)
|
||||||
|
assert.Equal(t, "2025-09-01 23:59:59", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[2].TransactionTime), time.UTC))
|
||||||
|
assert.Equal(t, int64(5), allNewTransactions[2].Amount)
|
||||||
|
assert.Equal(t, "银行卡", allNewTransactions[2].OriginalSourceAccountName)
|
||||||
|
assert.Equal(t, "xxx", allNewTransactions[2].OriginalDestinationAccountName)
|
||||||
|
assert.Equal(t, "余额", allNewTransactions[2].OriginalCategoryName)
|
||||||
|
|
||||||
|
assert.Equal(t, int64(1234567890), allNewTransactions[3].Uid)
|
||||||
|
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[3].Type)
|
||||||
|
assert.Equal(t, "2025-09-02 23:59:59", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[3].TransactionTime), time.UTC))
|
||||||
|
assert.Equal(t, int64(3), allNewTransactions[3].Amount)
|
||||||
|
assert.Equal(t, "xxx", allNewTransactions[3].OriginalSourceAccountName)
|
||||||
|
assert.Equal(t, "银行卡", allNewTransactions[3].OriginalDestinationAccountName)
|
||||||
|
assert.Equal(t, "余额", allNewTransactions[3].OriginalCategoryName)
|
||||||
|
|
||||||
|
assert.Equal(t, int64(1234567890), allNewAccounts[0].Uid)
|
||||||
|
assert.Equal(t, "余额", allNewAccounts[0].Name)
|
||||||
|
assert.Equal(t, "CNY", allNewAccounts[0].Currency)
|
||||||
|
|
||||||
|
assert.Equal(t, int64(1234567890), allNewAccounts[1].Uid)
|
||||||
|
assert.Equal(t, "银行卡", allNewAccounts[1].Name)
|
||||||
|
assert.Equal(t, "CNY", allNewAccounts[1].Currency)
|
||||||
|
|
||||||
|
assert.Equal(t, int64(1234567890), allNewAccounts[2].Uid)
|
||||||
|
assert.Equal(t, "xxx", allNewAccounts[2].Name)
|
||||||
|
assert.Equal(t, "CNY", allNewAccounts[2].Currency)
|
||||||
|
|
||||||
|
assert.Equal(t, int64(1234567890), allNewSubExpenseCategories[0].Uid)
|
||||||
|
assert.Equal(t, "其他网购", allNewSubExpenseCategories[0].Name)
|
||||||
|
|
||||||
|
assert.Equal(t, int64(1234567890), allNewSubIncomeCategories[0].Uid)
|
||||||
|
assert.Equal(t, "其他", allNewSubIncomeCategories[0].Name)
|
||||||
|
|
||||||
|
assert.Equal(t, int64(1234567890), allNewSubTransferCategories[0].Uid)
|
||||||
|
assert.Equal(t, "余额", allNewSubTransferCategories[0].Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJDComFinanceCsvFileImporterParseImportedData_ParseRefundTransaction(t *testing.T) {
|
||||||
|
converter := JDComFinanceTransactionDataCsvFileImporter
|
||||||
|
context := core.NewNullContext()
|
||||||
|
|
||||||
|
user := &models.User{
|
||||||
|
Uid: 1234567890,
|
||||||
|
DefaultCurrency: "CNY",
|
||||||
|
}
|
||||||
|
|
||||||
|
data1 := "导出信息:\n" +
|
||||||
|
"京东账号名:xxxxxx\n" +
|
||||||
|
"日期区间:2025-01-01 至 2025-09-01\n" +
|
||||||
|
"\n" +
|
||||||
|
"交易时间,商户名称,交易说明,金额,收/付款方式,交易状态,收/支\n" +
|
||||||
|
"2025-09-01 01:23:45,xxx,xxx,0.12,银行卡,退款成功,不计收支\n" +
|
||||||
|
"2025-09-01 02:34:56,xxx,xxx,0.12(已全额退款),银行卡,交易成功,不计收支\n" +
|
||||||
|
"2025-09-02 01:23:45,xxx,xxx,3.45,银行卡,退款成功,不计收支\n" +
|
||||||
|
"2025-09-02 02:34:56,xxx,xxx,123.45(已退款3.45),银行卡,交易成功,支出\n"
|
||||||
|
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(data1), 0, nil, nil, nil, nil, nil)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, int64(1234567890), allNewTransactions[0].Uid)
|
||||||
|
assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[0].Type)
|
||||||
|
assert.Equal(t, "2025-09-01 01:23:45", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime), time.UTC))
|
||||||
|
assert.Equal(t, int64(-12), allNewTransactions[0].Amount)
|
||||||
|
assert.Equal(t, "银行卡", allNewTransactions[0].OriginalSourceAccountName)
|
||||||
|
|
||||||
|
assert.Equal(t, int64(1234567890), allNewTransactions[1].Uid)
|
||||||
|
assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[1].Type)
|
||||||
|
assert.Equal(t, "2025-09-01 02:34:56", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[1].TransactionTime), time.UTC))
|
||||||
|
assert.Equal(t, int64(12), allNewTransactions[1].Amount)
|
||||||
|
assert.Equal(t, "银行卡", allNewTransactions[1].OriginalSourceAccountName)
|
||||||
|
|
||||||
|
assert.Equal(t, int64(1234567890), allNewTransactions[2].Uid)
|
||||||
|
assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[2].Type)
|
||||||
|
assert.Equal(t, "2025-09-02 01:23:45", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[2].TransactionTime), time.UTC))
|
||||||
|
assert.Equal(t, int64(-345), allNewTransactions[2].Amount)
|
||||||
|
assert.Equal(t, "银行卡", allNewTransactions[2].OriginalSourceAccountName)
|
||||||
|
|
||||||
|
assert.Equal(t, int64(1234567890), allNewTransactions[3].Uid)
|
||||||
|
assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[3].Type)
|
||||||
|
assert.Equal(t, "2025-09-02 02:34:56", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[3].TransactionTime), time.UTC))
|
||||||
|
assert.Equal(t, int64(12345), allNewTransactions[3].Amount)
|
||||||
|
assert.Equal(t, "银行卡", allNewTransactions[3].OriginalSourceAccountName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJDComFinanceCsvFileImporterParseImportedData_ParseInvalidTime(t *testing.T) {
|
||||||
|
converter := JDComFinanceTransactionDataCsvFileImporter
|
||||||
|
context := core.NewNullContext()
|
||||||
|
|
||||||
|
user := &models.User{
|
||||||
|
Uid: 1234567890,
|
||||||
|
DefaultCurrency: "CNY",
|
||||||
|
}
|
||||||
|
|
||||||
|
data1 := "导出信息:\n" +
|
||||||
|
"京东账号名:xxxxxx\n" +
|
||||||
|
"日期区间:2025-01-01 至 2025-09-01\n" +
|
||||||
|
"\n" +
|
||||||
|
"交易时间,商户名称,交易说明,金额,收/付款方式,交易状态,收/支\n" +
|
||||||
|
"2025-09-01T01:23:45,xxx,xxx,0.12,银行卡,交易成功,支出\n"
|
||||||
|
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(data1), 0, nil, nil, nil, nil, nil)
|
||||||
|
assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message)
|
||||||
|
|
||||||
|
data2 := "导出信息:\n" +
|
||||||
|
"京东账号名:xxxxxx\n" +
|
||||||
|
"日期区间:2025-01-01 至 2025-09-01\n" +
|
||||||
|
"\n" +
|
||||||
|
"交易时间,商户名称,交易说明,金额,收/付款方式,交易状态,收/支\n" +
|
||||||
|
"09/01/2025 01:23:45,xxx,xxx,0.12,银行卡,交易成功,支出\n"
|
||||||
|
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data2), 0, nil, nil, nil, nil, nil)
|
||||||
|
assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJDComFinanceCsvFileImporterParseImportedData_ParseInvalidType(t *testing.T) {
|
||||||
|
converter := JDComFinanceTransactionDataCsvFileImporter
|
||||||
|
context := core.NewNullContext()
|
||||||
|
|
||||||
|
user := &models.User{
|
||||||
|
Uid: 1234567890,
|
||||||
|
DefaultCurrency: "CNY",
|
||||||
|
}
|
||||||
|
|
||||||
|
data := "导出信息:\n" +
|
||||||
|
"京东账号名:xxxxxx\n" +
|
||||||
|
"日期区间:2025-01-01 至 2025-09-01\n" +
|
||||||
|
"\n" +
|
||||||
|
"交易时间,商户名称,交易说明,金额,收/付款方式,交易状态,收/支\n" +
|
||||||
|
"2025-09-01 01:23:45,xxx,xxx,0.12,银行卡,交易成功,转账\n"
|
||||||
|
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(data), 0, nil, nil, nil, nil, nil)
|
||||||
|
assert.EqualError(t, err, errs.ErrNotFoundTransactionDataInFile.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJDComFinanceCsvFileImporterParseImportedData_ParseInvalidAmount(t *testing.T) {
|
||||||
|
converter := JDComFinanceTransactionDataCsvFileImporter
|
||||||
|
context := core.NewNullContext()
|
||||||
|
|
||||||
|
user := &models.User{
|
||||||
|
Uid: 1234567890,
|
||||||
|
DefaultCurrency: "CNY",
|
||||||
|
}
|
||||||
|
|
||||||
|
data := "导出信息:\n" +
|
||||||
|
"京东账号名:xxxxxx\n" +
|
||||||
|
"日期区间:2025-01-01 至 2025-09-01\n" +
|
||||||
|
"\n" +
|
||||||
|
"交易时间,商户名称,交易说明,金额,收/付款方式,交易状态,收/支\n" +
|
||||||
|
"2025-09-01 01:23:45,xxx,xxx,¥0.12,银行卡,交易成功,支出\n"
|
||||||
|
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(data), 0, nil, nil, nil, nil, nil)
|
||||||
|
assert.EqualError(t, err, errs.ErrAmountInvalid.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJDComFinanceCsvFileImporterParseImportedData_ParseAccountName(t *testing.T) {
|
||||||
|
converter := JDComFinanceTransactionDataCsvFileImporter
|
||||||
|
context := core.NewNullContext()
|
||||||
|
|
||||||
|
user := &models.User{
|
||||||
|
Uid: 1234567890,
|
||||||
|
DefaultCurrency: "CNY",
|
||||||
|
}
|
||||||
|
|
||||||
|
// transfer to jd.com finance wallet
|
||||||
|
data1 := "导出信息:\n" +
|
||||||
|
"京东账号名:xxxxxx\n" +
|
||||||
|
"日期区间:2025-01-01 至 2025-09-01\n" +
|
||||||
|
"\n" +
|
||||||
|
"交易时间,商户名称,交易说明,金额,收/付款方式,交易状态,收/支,交易分类\n" +
|
||||||
|
"2025-09-01 01:23:45,xxx,京东钱包余额充值,0.05,银行卡,交易成功,不计收支,余额\n"
|
||||||
|
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(data1), 0, nil, nil, nil, nil, nil)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 1, len(allNewTransactions))
|
||||||
|
assert.Equal(t, "银行卡", allNewTransactions[0].OriginalSourceAccountName)
|
||||||
|
assert.Equal(t, "xxx", allNewTransactions[0].OriginalDestinationAccountName)
|
||||||
|
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[0].Type)
|
||||||
|
|
||||||
|
// transfer from jd.com finance wallet
|
||||||
|
data2 := "导出信息:\n" +
|
||||||
|
"京东账号名:xxxxxx\n" +
|
||||||
|
"日期区间:2025-01-01 至 2025-09-01\n" +
|
||||||
|
"\n" +
|
||||||
|
"交易时间,商户名称,交易说明,金额,收/付款方式,交易状态,收/支,交易分类\n" +
|
||||||
|
"2025-09-01 01:23:45,xxx,京东余额提现,0.05,银行卡,交易成功,不计收支,余额\n"
|
||||||
|
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data2), 0, nil, nil, nil, nil, nil)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 1, len(allNewTransactions))
|
||||||
|
assert.Equal(t, "xxx", allNewTransactions[0].OriginalSourceAccountName)
|
||||||
|
assert.Equal(t, "银行卡", allNewTransactions[0].OriginalDestinationAccountName)
|
||||||
|
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[0].Type)
|
||||||
|
|
||||||
|
// transfer from other account
|
||||||
|
data3 := "导出信息:\n" +
|
||||||
|
"京东账号名:xxxxxx\n" +
|
||||||
|
"日期区间:2025-01-01 至 2025-09-01\n" +
|
||||||
|
"\n" +
|
||||||
|
"交易时间,商户名称,交易说明,金额,收/付款方式,交易状态,收/支,交易分类\n" +
|
||||||
|
"2025-09-01 01:23:45,xxx,京东小金库-转入,0.05,余额,交易成功,不计收支,小金库\n"
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data3), 0, nil, nil, nil, nil, nil)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 1, len(allNewTransactions))
|
||||||
|
assert.Equal(t, "余额", allNewTransactions[0].OriginalSourceAccountName)
|
||||||
|
assert.Equal(t, "xxx", allNewTransactions[0].OriginalDestinationAccountName)
|
||||||
|
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[0].Type)
|
||||||
|
|
||||||
|
// transfer to other account
|
||||||
|
data4 := "导出信息:\n" +
|
||||||
|
"京东账号名:xxxxxx\n" +
|
||||||
|
"日期区间:2025-01-01 至 2025-09-01\n" +
|
||||||
|
"\n" +
|
||||||
|
"交易时间,商户名称,交易说明,金额,收/付款方式,交易状态,收/支,交易分类\n" +
|
||||||
|
"2025-09-01 01:23:45,xxx,京东小金库-转出,0.05,余额,交易成功,不计收支,小金库\n"
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data4), 0, nil, nil, nil, nil, nil)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 1, len(allNewTransactions))
|
||||||
|
assert.Equal(t, "xxx", allNewTransactions[0].OriginalSourceAccountName)
|
||||||
|
assert.Equal(t, "余额", allNewTransactions[0].OriginalDestinationAccountName)
|
||||||
|
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[0].Type)
|
||||||
|
|
||||||
|
// refund
|
||||||
|
data5 := "导出信息:\n" +
|
||||||
|
"京东账号名:xxxxxx\n" +
|
||||||
|
"日期区间:2025-01-01 至 2025-09-01\n" +
|
||||||
|
"\n" +
|
||||||
|
"交易时间,商户名称,交易说明,金额,收/付款方式,交易状态,收/支,交易分类\n" +
|
||||||
|
"2025-09-01 01:23:45,xxx,价保退款,0.05,银行卡,交易成功,不计收支,其他\n"
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data5), 0, nil, nil, nil, nil, nil)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 1, len(allNewTransactions))
|
||||||
|
assert.Equal(t, "银行卡", allNewTransactions[0].OriginalSourceAccountName)
|
||||||
|
assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[0].Type)
|
||||||
|
|
||||||
|
// repayment
|
||||||
|
data6 := "导出信息:\n" +
|
||||||
|
"京东账号名:xxxxxx\n" +
|
||||||
|
"日期区间:2025-01-01 至 2025-09-01\n" +
|
||||||
|
"\n" +
|
||||||
|
"交易时间,商户名称,交易说明,金额,收/付款方式,交易状态,收/支,交易分类\n" +
|
||||||
|
"2025-09-01 01:23:45,xxx,白条主动还款,0.05,银行卡,交易成功,不计收支,白条\n"
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data6), 0, nil, nil, nil, nil, nil)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 1, len(allNewTransactions))
|
||||||
|
assert.Equal(t, "银行卡", allNewTransactions[0].OriginalSourceAccountName)
|
||||||
|
assert.Equal(t, "xxx", allNewTransactions[0].OriginalDestinationAccountName)
|
||||||
|
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[0].Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJDComFinanceCsvFileImporterParseImportedData_ParseDescription(t *testing.T) {
|
||||||
|
converter := JDComFinanceTransactionDataCsvFileImporter
|
||||||
|
context := core.NewNullContext()
|
||||||
|
|
||||||
|
user := &models.User{
|
||||||
|
Uid: 1234567890,
|
||||||
|
DefaultCurrency: "CNY",
|
||||||
|
}
|
||||||
|
|
||||||
|
data1 := "导出信息:\n" +
|
||||||
|
"京东账号名:xxxxxx\n" +
|
||||||
|
"日期区间:2025-01-01 至 2025-09-01\n" +
|
||||||
|
"\n" +
|
||||||
|
"交易时间,商户名称,交易说明,金额,收/付款方式,交易状态,收/支\n" +
|
||||||
|
"2025-09-01 01:23:45,xxx,,0.12,银行卡,交易成功,支出\n"
|
||||||
|
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(data1), 0, nil, nil, nil, nil, nil)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 1, len(allNewTransactions))
|
||||||
|
assert.Equal(t, "", allNewTransactions[0].Comment)
|
||||||
|
|
||||||
|
data2 := "导出信息:\n" +
|
||||||
|
"京东账号名:xxxxxx\n" +
|
||||||
|
"日期区间:2025-01-01 至 2025-09-01\n" +
|
||||||
|
"\n" +
|
||||||
|
"交易时间,商户名称,交易说明,交易说明,金额,收/付款方式,交易状态,收/支,备注\n" +
|
||||||
|
"2025-09-01 01:23:45,xxx,xxx,Test,0.12,银行卡,交易成功,支出,\"foo\"\"bar,\ntest\"\n"
|
||||||
|
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data2), 0, nil, nil, nil, nil, nil)
|
||||||
|
assert.Equal(t, 1, len(allNewTransactions))
|
||||||
|
assert.Equal(t, "foo\"bar,\ntest", allNewTransactions[0].Comment)
|
||||||
|
|
||||||
|
data3 := "导出信息:\n" +
|
||||||
|
"京东账号名:xxxxxx\n" +
|
||||||
|
"日期区间:2025-01-01 至 2025-09-01\n" +
|
||||||
|
"\n" +
|
||||||
|
"交易时间,商户名称,交易说明,交易说明,金额,收/付款方式,交易状态,收/支,备注\n" +
|
||||||
|
"2025-09-01 01:23:45,xxx,xxx,Test,0.12,银行卡,交易成功,支出,\n"
|
||||||
|
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data3), 0, nil, nil, nil, nil, nil)
|
||||||
|
assert.Equal(t, 1, len(allNewTransactions))
|
||||||
|
assert.Equal(t, "Test", allNewTransactions[0].Comment)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJDComFinanceCsvFileImporterParseImportedData_SkipUnknownStatusTransaction(t *testing.T) {
|
||||||
|
converter := JDComFinanceTransactionDataCsvFileImporter
|
||||||
|
context := core.NewNullContext()
|
||||||
|
|
||||||
|
user := &models.User{
|
||||||
|
Uid: 1234567890,
|
||||||
|
DefaultCurrency: "CNY",
|
||||||
|
}
|
||||||
|
|
||||||
|
data := "导出信息:\n" +
|
||||||
|
"京东账号名:xxxxxx\n" +
|
||||||
|
"日期区间:2025-01-01 至 2025-09-01\n" +
|
||||||
|
"\n" +
|
||||||
|
"交易时间,商户名称,交易说明,金额,收/付款方式,交易状态,收/支\n" +
|
||||||
|
"2025-09-01 01:23:45,xxx,xxx,0.12,银行卡,xxxx,支出\n"
|
||||||
|
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(data), 0, nil, nil, nil, nil, nil)
|
||||||
|
assert.EqualError(t, err, errs.ErrNotFoundTransactionDataInFile.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJDComFinanceCsvFileImporterParseImportedData_SkipUnknownMemoTransferTransaction(t *testing.T) {
|
||||||
|
converter := JDComFinanceTransactionDataCsvFileImporter
|
||||||
|
context := core.NewNullContext()
|
||||||
|
|
||||||
|
user := &models.User{
|
||||||
|
Uid: 1234567890,
|
||||||
|
DefaultCurrency: "CNY",
|
||||||
|
}
|
||||||
|
|
||||||
|
data := "导出信息:\n" +
|
||||||
|
"京东账号名:xxxxxx\n" +
|
||||||
|
"日期区间:2025-01-01 至 2025-09-01\n" +
|
||||||
|
"\n" +
|
||||||
|
"交易时间,商户名称,交易说明,金额,收/付款方式,交易状态,收/支\n" +
|
||||||
|
"2025-09-01 01:23:45,xxx,xxx,0.12,银行卡,交易成功,不计收支\n"
|
||||||
|
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(data), 0, nil, nil, nil, nil, nil)
|
||||||
|
assert.EqualError(t, err, errs.ErrNotFoundTransactionDataInFile.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJDComFinanceCsvFileImporterParseImportedData_MissingFileHeader(t *testing.T) {
|
||||||
|
converter := JDComFinanceTransactionDataCsvFileImporter
|
||||||
|
context := core.NewNullContext()
|
||||||
|
|
||||||
|
user := &models.User{
|
||||||
|
Uid: 1,
|
||||||
|
DefaultCurrency: "CNY",
|
||||||
|
}
|
||||||
|
|
||||||
|
data := "交易时间,商户名称,交易说明,金额,收/付款方式,交易状态,收/支\n" +
|
||||||
|
"2025-09-01 01:23:45,xxx,xxx,0.12,银行卡,交易成功,支出\n"
|
||||||
|
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(data), 0, nil, nil, nil, nil, nil)
|
||||||
|
assert.EqualError(t, err, errs.ErrInvalidFileHeader.Message)
|
||||||
|
|
||||||
|
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(""), 0, nil, nil, nil, nil, nil)
|
||||||
|
assert.EqualError(t, err, errs.ErrInvalidFileHeader.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJDComFinanceCsvFileImporterParseImportedData_MissingRequiredColumn(t *testing.T) {
|
||||||
|
converter := JDComFinanceTransactionDataCsvFileImporter
|
||||||
|
context := core.NewNullContext()
|
||||||
|
|
||||||
|
user := &models.User{
|
||||||
|
Uid: 1,
|
||||||
|
DefaultCurrency: "CNY",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Missing Time Column
|
||||||
|
data1 := "导出信息:\n" +
|
||||||
|
"京东账号名:xxxxxx\n" +
|
||||||
|
"日期区间:2025-01-01 至 2025-09-01\n" +
|
||||||
|
"\n" +
|
||||||
|
"商户名称,交易说明,金额,收/付款方式,交易状态,收/支\n" +
|
||||||
|
"xxx,xxx,0.12,银行卡,交易成功,支出\n"
|
||||||
|
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(data1), 0, nil, nil, nil, nil, nil)
|
||||||
|
assert.EqualError(t, err, errs.ErrInvalidFileHeader.Message)
|
||||||
|
|
||||||
|
// Missing Merchant Name Column
|
||||||
|
data2 := "导出信息:\n" +
|
||||||
|
"京东账号名:xxxxxx\n" +
|
||||||
|
"日期区间:2025-01-01 至 2025-09-01\n" +
|
||||||
|
"\n" +
|
||||||
|
"交易时间,交易说明,金额,收/付款方式,交易状态,收/支\n" +
|
||||||
|
"2025-09-01 01:23:45,xxx,0.12,银行卡,交易成功,支出\n"
|
||||||
|
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data2), 0, nil, nil, nil, nil, nil)
|
||||||
|
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
|
||||||
|
|
||||||
|
// Missing Transaction Memo Column
|
||||||
|
data3 := "导出信息:\n" +
|
||||||
|
"京东账号名:xxxxxx\n" +
|
||||||
|
"日期区间:2025-01-01 至 2025-09-01\n" +
|
||||||
|
"\n" +
|
||||||
|
"交易时间,商户名称,金额,收/付款方式,交易状态,收/支\n" +
|
||||||
|
"2025-09-01 01:23:45,xxx,0.12,银行卡,交易成功,支出\n"
|
||||||
|
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data3), 0, nil, nil, nil, nil, nil)
|
||||||
|
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
|
||||||
|
|
||||||
|
// Missing Amount Column
|
||||||
|
data4 := "导出信息:\n" +
|
||||||
|
"京东账号名:xxxxxx\n" +
|
||||||
|
"日期区间:2025-01-01 至 2025-09-01\n" +
|
||||||
|
"\n" +
|
||||||
|
"交易时间,商户名称,交易说明,收/付款方式,交易状态,收/支\n" +
|
||||||
|
"2025-09-01 01:23:45,xxx,xxx,银行卡,交易成功,支出\n"
|
||||||
|
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data4), 0, nil, nil, nil, nil, nil)
|
||||||
|
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
|
||||||
|
|
||||||
|
// Missing Related Account Column
|
||||||
|
data5 := "导出信息:\n" +
|
||||||
|
"京东账号名:xxxxxx\n" +
|
||||||
|
"日期区间:2025-01-01 至 2025-09-01\n" +
|
||||||
|
"\n" +
|
||||||
|
"交易时间,商户名称,交易说明,金额,交易状态,收/支\n" +
|
||||||
|
"2025-09-01 01:23:45,xxx,xxx,0.12,交易成功,支出\n"
|
||||||
|
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data5), 0, nil, nil, nil, nil, nil)
|
||||||
|
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
|
||||||
|
|
||||||
|
// Missing Status Column
|
||||||
|
data6 := "导出信息:\n" +
|
||||||
|
"京东账号名:xxxxxx\n" +
|
||||||
|
"日期区间:2025-01-01 至 2025-09-01\n" +
|
||||||
|
"\n" +
|
||||||
|
"交易时间,商户名称,交易说明,金额,收/付款方式,收/支\n" +
|
||||||
|
"2025-09-01 01:23:45,xxx,xxx,0.12,银行卡,支出\n"
|
||||||
|
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data6), 0, nil, nil, nil, nil, nil)
|
||||||
|
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
|
||||||
|
|
||||||
|
// Missing Type Column
|
||||||
|
data7 := "导出信息:\n" +
|
||||||
|
"京东账号名:xxxxxx\n" +
|
||||||
|
"日期区间:2025-01-01 至 2025-09-01\n" +
|
||||||
|
"\n" +
|
||||||
|
"交易时间,商户名称,交易说明,金额,收/付款方式,交易状态\n" +
|
||||||
|
"2025-09-01 01:23:45,xxx,xxx,0.12,银行卡,交易成功\n"
|
||||||
|
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data7), 0, nil, nil, nil, nil, nil)
|
||||||
|
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJDComFinanceCsvFileImporterParseImportedData_NoTransactionData(t *testing.T) {
|
||||||
|
converter := JDComFinanceTransactionDataCsvFileImporter
|
||||||
|
context := core.NewNullContext()
|
||||||
|
|
||||||
|
user := &models.User{
|
||||||
|
Uid: 1234567890,
|
||||||
|
DefaultCurrency: "CNY",
|
||||||
|
}
|
||||||
|
|
||||||
|
data := "导出信息:\n" +
|
||||||
|
"京东账号名:xxxxxx\n" +
|
||||||
|
"日期区间:2025-01-01 至 2025-09-01\n" +
|
||||||
|
"\n" +
|
||||||
|
"交易时间,商户名称,交易说明,金额,收/付款方式,交易状态,收/支\n"
|
||||||
|
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(data), 0, nil, nil, nil, nil, nil)
|
||||||
|
assert.EqualError(t, err, errs.ErrNotFoundTransactionDataInFile.Message)
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package jdcom
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/converters/csv"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
func createNewJDComFinanceTransactionBasicDataTable(ctx core.Context, originalDataTable datatable.BasicDataTable) (datatable.BasicDataTable, error) {
|
||||||
|
iterator := originalDataTable.DataRowIterator()
|
||||||
|
allOriginalLines := make([][]string, 0)
|
||||||
|
hasFileHeader := false
|
||||||
|
foundDataHeaderLine := false
|
||||||
|
|
||||||
|
for iterator.HasNext() {
|
||||||
|
row := iterator.Next()
|
||||||
|
|
||||||
|
if !hasFileHeader {
|
||||||
|
if row.ColumnCount() <= 0 {
|
||||||
|
continue
|
||||||
|
} else if strings.Index(row.GetData(0), jdComFinanceTransactionDataCsvFileHeader) == 0 {
|
||||||
|
hasFileHeader = true
|
||||||
|
continue
|
||||||
|
} else {
|
||||||
|
log.Warnf(ctx, "[jdcom_finance_transaction_data_extrator.createNewJDComFinanceTransactionBasicDataTable] read unexpected line in row \"%s\" before read file header", iterator.CurrentRowId())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !foundDataHeaderLine {
|
||||||
|
if row.ColumnCount() <= 0 {
|
||||||
|
continue
|
||||||
|
} else if row.GetData(0) == jdComFinanceTransactionTimeColumnName {
|
||||||
|
foundDataHeaderLine = true
|
||||||
|
} else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if foundDataHeaderLine {
|
||||||
|
if row.ColumnCount() <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]string, row.ColumnCount())
|
||||||
|
|
||||||
|
for i := 0; i < row.ColumnCount(); i++ {
|
||||||
|
items[i] = strings.TrimRight(strings.Trim(row.GetData(i), " "), "\t")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(allOriginalLines) > 0 && len(items) < len(allOriginalLines[0]) {
|
||||||
|
log.Errorf(ctx, "[jdcom_finance_transaction_data_extrator.createNewJDComFinanceTransactionBasicDataTable] cannot parse row \"%s\", because may missing some columns (column count %d in data row is less than header column count %d)", iterator.CurrentRowId(), len(items), len(allOriginalLines[0]))
|
||||||
|
return nil, errs.ErrFewerFieldsInDataRowThanInHeaderRow
|
||||||
|
}
|
||||||
|
|
||||||
|
allOriginalLines = append(allOriginalLines, items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasFileHeader || !foundDataHeaderLine {
|
||||||
|
return nil, errs.ErrInvalidFileHeader
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(allOriginalLines) < 2 {
|
||||||
|
log.Errorf(ctx, "[jdcom_finance_transaction_data_extrator.createNewJDComFinanceTransactionBasicDataTable] cannot parse import data, because data table row count is less 1")
|
||||||
|
return nil, errs.ErrNotFoundTransactionDataInFile
|
||||||
|
}
|
||||||
|
|
||||||
|
return csv.CreateNewCustomCsvBasicDataTable(allOriginalLines, true), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package jdcom
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
const jdComFinanceTransactionDataCsvFileHeader = "导出信息:"
|
||||||
|
|
||||||
|
const jdComFinanceTransactionTimeColumnName = "交易时间"
|
||||||
|
const jdComFinanceTransactionMerchantNameColumnName = "商户名称"
|
||||||
|
const jdComFinanceTransactionMemoColumnName = "交易说明"
|
||||||
|
const jdComFinanceTransactionAmountColumnName = "金额"
|
||||||
|
const jdComFinanceTransactionRelatedAccountColumnName = "收/付款方式"
|
||||||
|
const jdComFinanceTransactionStatusColumnName = "交易状态"
|
||||||
|
const jdComFinanceTransactionTypeColumnName = "收/支"
|
||||||
|
const jdComFinanceTransactionCategoryColumnName = "交易分类"
|
||||||
|
const jdComFinanceTransactionDescriptionColumnName = "备注"
|
||||||
|
|
||||||
|
const jdComFinanceTransactionAmountRefundAll = "(已全额退款)"
|
||||||
|
|
||||||
|
const jdComFinanceTransactionMemoTransferToWalletPrefix = "充值"
|
||||||
|
const jdComFinanceTransactionMemoTransferFromWalletPrefix = "提现"
|
||||||
|
const jdComFinanceTransactionMemoTransferInText = "转入"
|
||||||
|
const jdComFinanceTransactionMemoTransferOutText = "转出"
|
||||||
|
const jdComFinanceTransactionMemoRepaymentText = "还款"
|
||||||
|
const jdComFinanceTransactionMemoRefundText = "退款"
|
||||||
|
|
||||||
|
const jdComFinanceTransactionDataStatusSuccessName = "交易成功"
|
||||||
|
const jdComFinanceTransactionDataStatusRefundSuccessName = "退款成功"
|
||||||
|
|
||||||
|
var jdComFinanceTransactionSupportedColumns = map[datatable.TransactionDataTableColumn]bool{
|
||||||
|
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME: true,
|
||||||
|
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: true,
|
||||||
|
datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY: true,
|
||||||
|
datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME: true,
|
||||||
|
datatable.TRANSACTION_DATA_TABLE_AMOUNT: true,
|
||||||
|
datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME: true,
|
||||||
|
datatable.TRANSACTION_DATA_TABLE_DESCRIPTION: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
var jdComFinanceTransactionTypeNameMapping = map[models.TransactionType]string{
|
||||||
|
models.TRANSACTION_TYPE_INCOME: "收入",
|
||||||
|
models.TRANSACTION_TYPE_EXPENSE: "支出",
|
||||||
|
models.TRANSACTION_TYPE_TRANSFER: "不计收支",
|
||||||
|
}
|
||||||
|
|
||||||
|
// jdComFinanceTransactionDataRowParser defines the structure of jd.com finance transaction data row parser
|
||||||
|
type jdComFinanceTransactionDataRowParser struct {
|
||||||
|
existedOriginalDataColumns map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse returns the converted transaction data row
|
||||||
|
func (p *jdComFinanceTransactionDataRowParser) Parse(ctx core.Context, user *models.User, dataRow datatable.CommonDataTableRow, rowId string) (rowData map[datatable.TransactionDataTableColumn]string, rowDataValid bool, err error) {
|
||||||
|
if dataRow.GetData(jdComFinanceTransactionTypeColumnName) != jdComFinanceTransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME] &&
|
||||||
|
dataRow.GetData(jdComFinanceTransactionTypeColumnName) != jdComFinanceTransactionTypeNameMapping[models.TRANSACTION_TYPE_EXPENSE] &&
|
||||||
|
dataRow.GetData(jdComFinanceTransactionTypeColumnName) != jdComFinanceTransactionTypeNameMapping[models.TRANSACTION_TYPE_TRANSFER] {
|
||||||
|
log.Warnf(ctx, "[jdcom_finance_transaction_data_row_parser.Parse] skip parsing transaction in row \"%s\", because type is \"%s\"", rowId, dataRow.GetData(jdComFinanceTransactionTypeColumnName))
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
statusName := dataRow.GetData(jdComFinanceTransactionStatusColumnName)
|
||||||
|
|
||||||
|
if statusName != jdComFinanceTransactionDataStatusSuccessName &&
|
||||||
|
statusName != jdComFinanceTransactionDataStatusRefundSuccessName {
|
||||||
|
log.Warnf(ctx, "[jdcom_finance_transaction_data_row_parser.Parse] skip parsing transaction in row \"%s\", because status is \"%s\"", rowId, statusName)
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
data := make(map[datatable.TransactionDataTableColumn]string, len(jdComFinanceTransactionSupportedColumns))
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME] = dataRow.GetData(jdComFinanceTransactionTimeColumnName)
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = dataRow.GetData(jdComFinanceTransactionTypeColumnName)
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = dataRow.GetData(jdComFinanceTransactionCategoryColumnName)
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = dataRow.GetData(jdComFinanceTransactionRelatedAccountColumnName)
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = ""
|
||||||
|
|
||||||
|
if strings.Index(dataRow.GetData(jdComFinanceTransactionAmountColumnName), "(") >= 0 {
|
||||||
|
// If a transaction includes a refund, the original transaction amount will like "-xx.xx(已全额退款)" or "-xx.xx(已退款yy.yy)", along with another refund transaction
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = strings.Split(dataRow.GetData(jdComFinanceTransactionAmountColumnName), "(")[0]
|
||||||
|
} else {
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = dataRow.GetData(jdComFinanceTransactionAmountColumnName)
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.hasOriginalColumn(jdComFinanceTransactionDescriptionColumnName) && dataRow.GetData(jdComFinanceTransactionDescriptionColumnName) != "" {
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = dataRow.GetData(jdComFinanceTransactionDescriptionColumnName)
|
||||||
|
} else if p.hasOriginalColumn(jdComFinanceTransactionMemoColumnName) && dataRow.GetData(jdComFinanceTransactionMemoColumnName) != "" {
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = dataRow.GetData(jdComFinanceTransactionMemoColumnName)
|
||||||
|
} else {
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] == jdComFinanceTransactionTypeNameMapping[models.TRANSACTION_TYPE_TRANSFER] {
|
||||||
|
memo := dataRow.GetData(jdComFinanceTransactionMemoColumnName)
|
||||||
|
|
||||||
|
if statusName == jdComFinanceTransactionDataStatusRefundSuccessName || strings.Index(memo, jdComFinanceTransactionMemoRefundText) >= 0 { // refund
|
||||||
|
amount, err := utils.ParseAmount(data[datatable.TRANSACTION_DATA_TABLE_AMOUNT])
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = jdComFinanceTransactionTypeNameMapping[models.TRANSACTION_TYPE_EXPENSE]
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(-amount)
|
||||||
|
}
|
||||||
|
} else if strings.Index(dataRow.GetData(jdComFinanceTransactionAmountColumnName), jdComFinanceTransactionAmountRefundAll) > 0 { // expense transaction (but include a full refund)
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = jdComFinanceTransactionTypeNameMapping[models.TRANSACTION_TYPE_EXPENSE]
|
||||||
|
} else { // transfer
|
||||||
|
if strings.Index(memo, jdComFinanceTransactionMemoTransferToWalletPrefix) >= 0 { // transfer to jd.com finance wallet
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = dataRow.GetData(jdComFinanceTransactionMerchantNameColumnName)
|
||||||
|
} else if strings.Index(memo, jdComFinanceTransactionMemoTransferFromWalletPrefix) >= 0 { // transfer from jd.com finance wallet
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME]
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = dataRow.GetData(jdComFinanceTransactionMerchantNameColumnName)
|
||||||
|
} else if strings.Index(memo, jdComFinanceTransactionMemoTransferInText) >= 0 { // transfer in
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = dataRow.GetData(jdComFinanceTransactionMerchantNameColumnName)
|
||||||
|
} else if strings.Index(memo, jdComFinanceTransactionMemoTransferOutText) >= 0 { // transfer out
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME]
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = dataRow.GetData(jdComFinanceTransactionMerchantNameColumnName)
|
||||||
|
} else if strings.Index(memo, jdComFinanceTransactionMemoRepaymentText) >= 0 { // repayment
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = dataRow.GetData(jdComFinanceTransactionMerchantNameColumnName)
|
||||||
|
} else {
|
||||||
|
log.Warnf(ctx, "[jdcom_finance_transaction_data_row_parser.Parse] skip parsing transaction in row \"%s\", because memo (\"%s\") of this transfer transaction is unknown", rowId, memo)
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *jdComFinanceTransactionDataRowParser) hasOriginalColumn(columnName string) bool {
|
||||||
|
_, exists := p.existedOriginalDataColumns[columnName]
|
||||||
|
return exists
|
||||||
|
}
|
||||||
|
|
||||||
|
// createJDComFinanceTransactionDataRowParser returns jd.com finance transaction data row parser
|
||||||
|
func createJDComFinanceTransactionDataRowParser(headerColumnNames []string) datatable.CommonTransactionDataRowParser {
|
||||||
|
existedOriginalDataColumns := make(map[string]bool, len(headerColumnNames))
|
||||||
|
|
||||||
|
for i := 0; i < len(headerColumnNames); i++ {
|
||||||
|
existedOriginalDataColumns[headerColumnNames[i]] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return &jdComFinanceTransactionDataRowParser{
|
||||||
|
existedOriginalDataColumns: existedOriginalDataColumns,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
|
"io"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -269,19 +270,27 @@ func readOFX1FileHeader(ctx core.Context, data []byte) (fileHeader *ofxFileHeade
|
|||||||
|
|
||||||
func readOFX2FileHeader(ctx core.Context, data []byte) (fileHeader *ofxFileHeader, err error) {
|
func readOFX2FileHeader(ctx core.Context, data []byte) (fileHeader *ofxFileHeader, err error) {
|
||||||
reader := bytes.NewReader(data)
|
reader := bytes.NewReader(data)
|
||||||
scanner := bufio.NewScanner(reader)
|
bufReader := bufio.NewReader(reader)
|
||||||
fileHeader = &ofxFileHeader{}
|
fileHeader = &ofxFileHeader{}
|
||||||
headerLine := ""
|
headerLine := ""
|
||||||
|
|
||||||
for scanner.Scan() {
|
for {
|
||||||
line := scanner.Text()
|
line, err := bufReader.ReadString('\n')
|
||||||
|
|
||||||
ofxHeaderStartIndex := strings.Index(line, "<?OFX ")
|
ofxHeaderStartIndex := strings.Index(line, "<?OFX ")
|
||||||
|
|
||||||
if ofxHeaderStartIndex >= 0 {
|
if ofxHeaderStartIndex >= 0 {
|
||||||
headerLine = ofx2HeaderPattern.FindString(line)
|
headerLine = ofx2HeaderPattern.FindString(line)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
} else {
|
||||||
|
log.Errorf(ctx, "[ofx_data_reader.readOFX2FileHeader] cannot read ofx 2.x file, because %s", err.Error())
|
||||||
|
return nil, errs.ErrInvalidOFXFile
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if headerLine == "" {
|
if headerLine == "" {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/mayswind/ezbookkeeping/pkg/converters/fireflyIII"
|
"github.com/mayswind/ezbookkeeping/pkg/converters/fireflyIII"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/converters/gnucash"
|
"github.com/mayswind/ezbookkeeping/pkg/converters/gnucash"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/converters/iif"
|
"github.com/mayswind/ezbookkeeping/pkg/converters/iif"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/converters/jdcom"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/converters/mt"
|
"github.com/mayswind/ezbookkeeping/pkg/converters/mt"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/converters/ofx"
|
"github.com/mayswind/ezbookkeeping/pkg/converters/ofx"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/converters/qif"
|
"github.com/mayswind/ezbookkeeping/pkg/converters/qif"
|
||||||
@@ -37,6 +38,8 @@ func GetTransactionDataImporter(fileType string) (converter.TransactionDataImpor
|
|||||||
return _default.DefaultTransactionDataCSVFileConverter, nil
|
return _default.DefaultTransactionDataCSVFileConverter, nil
|
||||||
} else if fileType == "ezbookkeeping_tsv" {
|
} else if fileType == "ezbookkeeping_tsv" {
|
||||||
return _default.DefaultTransactionDataTSVFileConverter, nil
|
return _default.DefaultTransactionDataTSVFileConverter, nil
|
||||||
|
} else if fileType == "ezbookkeeping_json" {
|
||||||
|
return _default.DefaultTransactionDataJsonFileImporter, nil
|
||||||
} else if fileType == "ofx" {
|
} else if fileType == "ofx" {
|
||||||
return ofx.OFXTransactionDataImporter, nil
|
return ofx.OFXTransactionDataImporter, nil
|
||||||
} else if fileType == "qfx" {
|
} else if fileType == "qfx" {
|
||||||
@@ -69,8 +72,12 @@ func GetTransactionDataImporter(fileType string) (converter.TransactionDataImpor
|
|||||||
return alipay.AlipayAppTransactionDataCsvFileImporter, nil
|
return alipay.AlipayAppTransactionDataCsvFileImporter, nil
|
||||||
} else if fileType == "alipay_web_csv" {
|
} else if fileType == "alipay_web_csv" {
|
||||||
return alipay.AlipayWebTransactionDataCsvFileImporter, nil
|
return alipay.AlipayWebTransactionDataCsvFileImporter, nil
|
||||||
|
} else if fileType == "wechat_pay_app_xlsx" {
|
||||||
|
return wechat.WeChatPayTransactionDataXlsxFileImporter, nil
|
||||||
} else if fileType == "wechat_pay_app_csv" {
|
} else if fileType == "wechat_pay_app_csv" {
|
||||||
return wechat.WeChatPayTransactionDataCsvFileImporter, nil
|
return wechat.WeChatPayTransactionDataCsvFileImporter, nil
|
||||||
|
} else if fileType == "jdcom_finance_app_csv" {
|
||||||
|
return jdcom.JDComFinanceTransactionDataCsvFileImporter, nil
|
||||||
} else {
|
} else {
|
||||||
return nil, errs.ErrImportFileTypeNotSupported
|
return nil, errs.ErrImportFileTypeNotSupported
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,12 @@ package wechat
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/csv"
|
|
||||||
"golang.org/x/text/encoding/unicode"
|
"golang.org/x/text/encoding/unicode"
|
||||||
"golang.org/x/text/transform"
|
"golang.org/x/text/transform"
|
||||||
"io"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/converters/converter"
|
"github.com/mayswind/ezbookkeeping/pkg/converters/converter"
|
||||||
csvdatatable "github.com/mayswind/ezbookkeeping/pkg/converters/csv"
|
"github.com/mayswind/ezbookkeeping/pkg/converters/csv"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
|
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||||
@@ -17,22 +15,6 @@ import (
|
|||||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
var wechatPayTransactionSupportedColumns = map[datatable.TransactionDataTableColumn]bool{
|
|
||||||
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME: true,
|
|
||||||
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: true,
|
|
||||||
datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY: true,
|
|
||||||
datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME: true,
|
|
||||||
datatable.TRANSACTION_DATA_TABLE_AMOUNT: true,
|
|
||||||
datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME: true,
|
|
||||||
datatable.TRANSACTION_DATA_TABLE_DESCRIPTION: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
var wechatPayTransactionTypeNameMapping = map[models.TransactionType]string{
|
|
||||||
models.TRANSACTION_TYPE_INCOME: "收入",
|
|
||||||
models.TRANSACTION_TYPE_EXPENSE: "支出",
|
|
||||||
models.TRANSACTION_TYPE_TRANSFER: "/",
|
|
||||||
}
|
|
||||||
|
|
||||||
// wechatPayTransactionDataCsvFileImporter defines the structure of wechatPay csv importer for transaction data
|
// wechatPayTransactionDataCsvFileImporter defines the structure of wechatPay csv importer for transaction data
|
||||||
type wechatPayTransactionDataCsvFileImporter struct {
|
type wechatPayTransactionDataCsvFileImporter struct {
|
||||||
fileHeaderLineBeginning string
|
fileHeaderLineBeginning string
|
||||||
@@ -49,7 +31,13 @@ func (c *wechatPayTransactionDataCsvFileImporter) ParseImportedData(ctx core.Con
|
|||||||
fallback := unicode.UTF8.NewDecoder()
|
fallback := unicode.UTF8.NewDecoder()
|
||||||
reader := transform.NewReader(bytes.NewReader(data), unicode.BOMOverride(fallback))
|
reader := transform.NewReader(bytes.NewReader(data), unicode.BOMOverride(fallback))
|
||||||
|
|
||||||
dataTable, err := c.createNewWeChatPayBasicDataTable(ctx, reader)
|
csvDataTable, err := csv.CreateNewCsvBasicDataTable(ctx, reader, false)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, nil, nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dataTable, err := createNewWeChatPayTransactionBasicDataTable(ctx, csvDataTable)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, nil, nil, nil, nil, err
|
return nil, nil, nil, nil, nil, nil, err
|
||||||
@@ -72,78 +60,3 @@ func (c *wechatPayTransactionDataCsvFileImporter) ParseImportedData(ctx core.Con
|
|||||||
|
|
||||||
return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezoneOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
|
return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezoneOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *wechatPayTransactionDataCsvFileImporter) createNewWeChatPayBasicDataTable(ctx core.Context, reader io.Reader) (datatable.BasicDataTable, error) {
|
|
||||||
csvReader := csv.NewReader(reader)
|
|
||||||
csvReader.FieldsPerRecord = -1
|
|
||||||
|
|
||||||
allOriginalLines := make([][]string, 0)
|
|
||||||
hasFileHeader := false
|
|
||||||
foundContentBeforeDataHeaderLine := false
|
|
||||||
|
|
||||||
for {
|
|
||||||
items, err := csvReader.Read()
|
|
||||||
|
|
||||||
if err == io.EOF {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf(ctx, "[wechat_pay_transaction_data_csv_file_importer.createNewWeChatPayBasicDataTable] cannot parse wechat pay csv data, because %s", err.Error())
|
|
||||||
return nil, errs.ErrInvalidCSVFile
|
|
||||||
}
|
|
||||||
|
|
||||||
if !hasFileHeader {
|
|
||||||
if len(items) <= 0 {
|
|
||||||
continue
|
|
||||||
} else if strings.Index(items[0], wechatPayTransactionDataCsvFileHeader) == 0 {
|
|
||||||
hasFileHeader = true
|
|
||||||
continue
|
|
||||||
} else {
|
|
||||||
log.Warnf(ctx, "[wechat_pay_transaction_data_csv_file_importer.createNewWeChatPayBasicDataTable] read unexpected line before read file header, line content is %s", strings.Join(items, ","))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !foundContentBeforeDataHeaderLine {
|
|
||||||
if len(items) <= 0 {
|
|
||||||
continue
|
|
||||||
} else if strings.Index(items[0], wechatPayTransactionDataHeaderStartContentBeginning) == 0 {
|
|
||||||
foundContentBeforeDataHeaderLine = true
|
|
||||||
continue
|
|
||||||
} else {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if foundContentBeforeDataHeaderLine {
|
|
||||||
if len(items) <= 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := 0; i < len(items); i++ {
|
|
||||||
items[i] = strings.Trim(items[i], " ")
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(allOriginalLines) > 0 && len(items) < len(allOriginalLines[0]) {
|
|
||||||
log.Errorf(ctx, "[wechat_pay_transaction_data_csv_file_importer.createNewWeChatPayBasicDataTable] cannot parse row \"index:%d\", because may missing some columns (column count %d in data row is less than header column count %d)", len(allOriginalLines), len(items), len(allOriginalLines[0]))
|
|
||||||
return nil, errs.ErrFewerFieldsInDataRowThanInHeaderRow
|
|
||||||
}
|
|
||||||
|
|
||||||
allOriginalLines = append(allOriginalLines, items)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !hasFileHeader || !foundContentBeforeDataHeaderLine {
|
|
||||||
return nil, errs.ErrInvalidFileHeader
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(allOriginalLines) < 2 {
|
|
||||||
log.Errorf(ctx, "[wechat_pay_transaction_data_csv_file_importer.createNewWeChatPayBasicDataTable] cannot parse import data, because data table row count is less 1")
|
|
||||||
return nil, errs.ErrNotFoundTransactionDataInFile
|
|
||||||
}
|
|
||||||
|
|
||||||
dataTable := csvdatatable.CreateNewCustomCsvBasicDataTable(allOriginalLines)
|
|
||||||
|
|
||||||
return dataTable, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package wechat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/converters/csv"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
func createNewWeChatPayTransactionBasicDataTable(ctx core.Context, originalDataTable datatable.BasicDataTable) (datatable.BasicDataTable, error) {
|
||||||
|
iterator := originalDataTable.DataRowIterator()
|
||||||
|
allOriginalLines := make([][]string, 0)
|
||||||
|
hasFileHeader := false
|
||||||
|
foundContentBeforeDataHeaderLine := false
|
||||||
|
|
||||||
|
for iterator.HasNext() {
|
||||||
|
row := iterator.Next()
|
||||||
|
|
||||||
|
if !hasFileHeader {
|
||||||
|
if row.ColumnCount() <= 0 {
|
||||||
|
continue
|
||||||
|
} else if strings.Index(row.GetData(0), wechatPayTransactionDataCsvFileHeader) == 0 {
|
||||||
|
hasFileHeader = true
|
||||||
|
continue
|
||||||
|
} else {
|
||||||
|
log.Warnf(ctx, "[wechat_pay_transaction_data_extrator.createNewWeChatPayTransactionBasicDataTable] read unexpected line in row \"%s\" before read file header", iterator.CurrentRowId())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !foundContentBeforeDataHeaderLine {
|
||||||
|
if row.ColumnCount() <= 0 {
|
||||||
|
continue
|
||||||
|
} else if strings.Index(row.GetData(0), wechatPayTransactionDataHeaderStartContentBeginning) == 0 {
|
||||||
|
foundContentBeforeDataHeaderLine = true
|
||||||
|
continue
|
||||||
|
} else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if foundContentBeforeDataHeaderLine {
|
||||||
|
if row.ColumnCount() <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]string, row.ColumnCount())
|
||||||
|
|
||||||
|
for i := 0; i < row.ColumnCount(); i++ {
|
||||||
|
items[i] = strings.Trim(row.GetData(i), " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(allOriginalLines) > 0 && len(items) < len(allOriginalLines[0]) {
|
||||||
|
log.Errorf(ctx, "[wechat_pay_transaction_data_extrator.createNewWeChatPayTransactionBasicDataTable] cannot parse row \"%s\", because may missing some columns (column count %d in data row is less than header column count %d)", iterator.CurrentRowId(), len(items), len(allOriginalLines[0]))
|
||||||
|
return nil, errs.ErrFewerFieldsInDataRowThanInHeaderRow
|
||||||
|
}
|
||||||
|
|
||||||
|
allOriginalLines = append(allOriginalLines, items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasFileHeader || !foundContentBeforeDataHeaderLine {
|
||||||
|
return nil, errs.ErrInvalidFileHeader
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(allOriginalLines) < 2 {
|
||||||
|
log.Errorf(ctx, "[wechat_pay_transaction_data_extrator.createNewWeChatPayTransactionBasicDataTable] cannot parse import data, because data table row count is less 1")
|
||||||
|
return nil, errs.ErrNotFoundTransactionDataInFile
|
||||||
|
}
|
||||||
|
|
||||||
|
return csv.CreateNewCustomCsvBasicDataTable(allOriginalLines, true), nil
|
||||||
|
}
|
||||||
@@ -29,6 +29,22 @@ const wechatPayTransactionDataCategoryTransferFromWeChatWallet = "零钱提现"
|
|||||||
|
|
||||||
const wechatPayTransactionDataStatusRefundName = "退款"
|
const wechatPayTransactionDataStatusRefundName = "退款"
|
||||||
|
|
||||||
|
var wechatPayTransactionSupportedColumns = map[datatable.TransactionDataTableColumn]bool{
|
||||||
|
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME: true,
|
||||||
|
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: true,
|
||||||
|
datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY: true,
|
||||||
|
datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME: true,
|
||||||
|
datatable.TRANSACTION_DATA_TABLE_AMOUNT: true,
|
||||||
|
datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME: true,
|
||||||
|
datatable.TRANSACTION_DATA_TABLE_DESCRIPTION: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
var wechatPayTransactionTypeNameMapping = map[models.TransactionType]string{
|
||||||
|
models.TRANSACTION_TYPE_INCOME: "收入",
|
||||||
|
models.TRANSACTION_TYPE_EXPENSE: "支出",
|
||||||
|
models.TRANSACTION_TYPE_TRANSFER: "/",
|
||||||
|
}
|
||||||
|
|
||||||
// weChatPayTransactionDataRowParser defines the structure of wechat pay transaction data row parser
|
// weChatPayTransactionDataRowParser defines the structure of wechat pay transaction data row parser
|
||||||
type weChatPayTransactionDataRowParser struct {
|
type weChatPayTransactionDataRowParser struct {
|
||||||
existedOriginalDataColumns map[string]bool
|
existedOriginalDataColumns map[string]bool
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package wechat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/converters/converter"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/converters/excel"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// wechatPayTransactionDataXlsxFileImporter defines the structure of wechatPay xlsx importer for transaction data
|
||||||
|
type wechatPayTransactionDataXlsxFileImporter struct {
|
||||||
|
dataHeaderStartContentBeginning string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize a webchat pay transaction data xlsx file importer singleton instance
|
||||||
|
var (
|
||||||
|
WeChatPayTransactionDataXlsxFileImporter = &wechatPayTransactionDataXlsxFileImporter{}
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParseImportedData returns the imported data by parsing the wechat pay transaction csv data
|
||||||
|
func (c *wechatPayTransactionDataXlsxFileImporter) ParseImportedData(ctx core.Context, user *models.User, data []byte, defaultTimezoneOffset int16, accountMap map[string]*models.Account, expenseCategoryMap map[string]map[string]*models.TransactionCategory, incomeCategoryMap map[string]map[string]*models.TransactionCategory, transferCategoryMap map[string]map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) (models.ImportedTransactionSlice, []*models.Account, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionTag, error) {
|
||||||
|
xlsxDataTable, err := excel.CreateNewExcelOOXMLFileBasicDataTable(data, false)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, nil, nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dataTable, err := createNewWeChatPayTransactionBasicDataTable(ctx, xlsxDataTable)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, nil, nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
commonDataTable := datatable.CreateNewCommonDataTableFromBasicDataTable(dataTable)
|
||||||
|
|
||||||
|
if !commonDataTable.HasColumn(wechatPayTransactionTimeColumnName) ||
|
||||||
|
!commonDataTable.HasColumn(wechatPayTransactionCategoryColumnName) ||
|
||||||
|
!commonDataTable.HasColumn(wechatPayTransactionTypeColumnName) ||
|
||||||
|
!commonDataTable.HasColumn(wechatPayTransactionAmountColumnName) ||
|
||||||
|
!commonDataTable.HasColumn(wechatPayTransactionStatusColumnName) {
|
||||||
|
log.Errorf(ctx, "[wechat_pay_transaction_data_xlsx_file_importer.ParseImportedData] cannot parse wechat pay xlsx data, because missing essential columns in header row")
|
||||||
|
return nil, nil, nil, nil, nil, nil, errs.ErrMissingRequiredFieldInHeaderRow
|
||||||
|
}
|
||||||
|
|
||||||
|
transactionRowParser := createWeChatPayTransactionDataRowParser(dataTable.HeaderColumnNames())
|
||||||
|
transactionDataTable := datatable.CreateNewTransactionDataTableFromCommonDataTable(commonDataTable, wechatPayTransactionSupportedColumns, transactionRowParser)
|
||||||
|
dataTableImporter := converter.CreateNewSimpleImporterWithTypeNameMapping(wechatPayTransactionTypeNameMapping)
|
||||||
|
|
||||||
|
return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezoneOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// CalendarDisplayType represents calendar display type
|
||||||
|
type CalendarDisplayType byte
|
||||||
|
|
||||||
|
// Calendar Display Type
|
||||||
|
const (
|
||||||
|
CALENDAR_DISPLAY_TYPE_DEFAULT CalendarDisplayType = 0
|
||||||
|
CALENDAR_DISPLAY_TYPE_GREGORAIN CalendarDisplayType = 1
|
||||||
|
CALENDAR_DISPLAY_TYPE_BUDDHIST CalendarDisplayType = 2
|
||||||
|
CALENDAR_DISPLAY_TYPE_GREGORAIN_WITH_CHINESE CalendarDisplayType = 3
|
||||||
|
CALENDAR_DISPLAY_TYPE_GREGORAIN_WITH_PERSIAN CalendarDisplayType = 4
|
||||||
|
CALENDAR_DISPLAY_TYPE_INVALID CalendarDisplayType = 255
|
||||||
|
)
|
||||||
|
|
||||||
|
// String returns a textual representation of the calendar display type enum
|
||||||
|
func (f CalendarDisplayType) String() string {
|
||||||
|
switch f {
|
||||||
|
case CALENDAR_DISPLAY_TYPE_DEFAULT:
|
||||||
|
return "Default"
|
||||||
|
case CALENDAR_DISPLAY_TYPE_GREGORAIN:
|
||||||
|
return "Gregorian"
|
||||||
|
case CALENDAR_DISPLAY_TYPE_BUDDHIST:
|
||||||
|
return "Buddhist"
|
||||||
|
case CALENDAR_DISPLAY_TYPE_GREGORAIN_WITH_CHINESE:
|
||||||
|
return "Gregorian with Chinese Calendar"
|
||||||
|
case CALENDAR_DISPLAY_TYPE_GREGORAIN_WITH_PERSIAN:
|
||||||
|
return "Gregorian with Persian Calendar"
|
||||||
|
case CALENDAR_DISPLAY_TYPE_INVALID:
|
||||||
|
return "Invalid"
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("Invalid(%d)", int(f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DateDisplayType represents date display type
|
||||||
|
type DateDisplayType byte
|
||||||
|
|
||||||
|
// Date Display Type
|
||||||
|
const (
|
||||||
|
DATE_DISPLAY_TYPE_DEFAULT DateDisplayType = 0
|
||||||
|
DATE_DISPLAY_TYPE_GREGORAIN DateDisplayType = 1
|
||||||
|
DATE_DISPLAY_TYPE_BUDDHIST DateDisplayType = 2
|
||||||
|
DATE_DISPLAY_TYPE_PERSIAN DateDisplayType = 3
|
||||||
|
DATE_DISPLAY_TYPE_INVALID DateDisplayType = 255
|
||||||
|
)
|
||||||
|
|
||||||
|
// String returns a textual representation of the date display type enum
|
||||||
|
func (f DateDisplayType) String() string {
|
||||||
|
switch f {
|
||||||
|
case DATE_DISPLAY_TYPE_DEFAULT:
|
||||||
|
return "Default"
|
||||||
|
case DATE_DISPLAY_TYPE_GREGORAIN:
|
||||||
|
return "Gregorian"
|
||||||
|
case DATE_DISPLAY_TYPE_BUDDHIST:
|
||||||
|
return "Buddhist"
|
||||||
|
case DATE_DISPLAY_TYPE_PERSIAN:
|
||||||
|
return "Persian"
|
||||||
|
case DATE_DISPLAY_TYPE_INVALID:
|
||||||
|
return "Invalid"
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("Invalid(%d)", int(f))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
// O is a shortcut for map[string]any
|
||||||
|
type O map[string]any
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
package core
|
package core
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
// Context is the base context of ezBookkeeping
|
// Context is the base context of ezBookkeeping
|
||||||
type Context interface {
|
type Context interface {
|
||||||
|
context.Context
|
||||||
GetContextId() string
|
GetContextId() string
|
||||||
GetClientLocale() string
|
GetClientLocale() string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package core
|
|||||||
import (
|
import (
|
||||||
"net"
|
"net"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
@@ -23,6 +24,11 @@ const RemoteClientPortHeader = "X-Real-Port"
|
|||||||
// ClientTimezoneOffsetHeaderName represents the header name of client timezone offset
|
// ClientTimezoneOffsetHeaderName represents the header name of client timezone offset
|
||||||
const ClientTimezoneOffsetHeaderName = "X-Timezone-Offset"
|
const ClientTimezoneOffsetHeaderName = "X-Timezone-Offset"
|
||||||
|
|
||||||
|
const tokenHeaderName = "Authorization"
|
||||||
|
const tokenHeaderValuePrefix = "bearer "
|
||||||
|
const tokenQueryStringParam = "token"
|
||||||
|
const tokenCookieParam = "ebk_auth_token"
|
||||||
|
|
||||||
// WebContext represents the request and response context
|
// WebContext represents the request and response context
|
||||||
type WebContext struct {
|
type WebContext struct {
|
||||||
*gin.Context
|
*gin.Context
|
||||||
@@ -118,6 +124,41 @@ func (c *WebContext) GetCurrentUid() int64 {
|
|||||||
return claims.Uid
|
return claims.Uid
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetTokenStringFromHeader returns the token string from the request header
|
||||||
|
func (c *WebContext) GetTokenStringFromHeader() string {
|
||||||
|
tokenHeader := c.GetHeader(tokenHeaderName)
|
||||||
|
|
||||||
|
if len(tokenHeader) < 7 || !strings.EqualFold(tokenHeader[:7], tokenHeaderValuePrefix) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokenHeader[7:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTokenStringFromQueryString returns the token string from the request query string
|
||||||
|
func (c *WebContext) GetTokenStringFromQueryString() string {
|
||||||
|
return c.Query(tokenQueryStringParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTokenStringFromCookie returns the token string from the request cookie
|
||||||
|
func (c *WebContext) GetTokenStringFromCookie() string {
|
||||||
|
tokenCookie, err := c.Cookie(tokenCookieParam)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokenCookie
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WebContext) SetTokenStringToCookie(token string, tokenExpiredTime int, path string) {
|
||||||
|
if token != "" {
|
||||||
|
c.SetCookie(tokenCookieParam, token, tokenExpiredTime, path, "", false, true)
|
||||||
|
} else {
|
||||||
|
c.SetCookie(tokenCookieParam, "", -1, path, "", false, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// GetClientLocale returns the client locale name
|
// GetClientLocale returns the client locale name
|
||||||
func (c *WebContext) GetClientLocale() string {
|
func (c *WebContext) GetClientLocale() string {
|
||||||
value := c.GetHeader(AcceptLanguageHeaderName)
|
value := c.GetHeader(AcceptLanguageHeaderName)
|
||||||
|
|||||||
+41
-4
@@ -4,6 +4,40 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// NumeralSystem represents the type of numeral system
|
||||||
|
type NumeralSystem byte
|
||||||
|
|
||||||
|
// Numeral System
|
||||||
|
const (
|
||||||
|
NUMERAL_SYSTEM_DEFAULT NumeralSystem = 0
|
||||||
|
NUMERAL_SYSTEM_WESTERN_ARABIC_NUMERALS NumeralSystem = 1
|
||||||
|
NUMERAL_SYSTEM_EASTERN_ARABIC_NUMERALS NumeralSystem = 2
|
||||||
|
NUMERAL_SYSTEM_PERSIAN_DIGITS NumeralSystem = 3
|
||||||
|
NUMERAL_SYSTEM_BURMESE_NUMERALS NumeralSystem = 4
|
||||||
|
NUMERAL_SYSTEM_DEVANAGARI_NUMERALS NumeralSystem = 5
|
||||||
|
NUMERAL_SYSTEM_INVALID NumeralSystem = 255
|
||||||
|
)
|
||||||
|
|
||||||
|
// String returns a textual representation of the decimal separator enum
|
||||||
|
func (f NumeralSystem) String() string {
|
||||||
|
switch f {
|
||||||
|
case NUMERAL_SYSTEM_DEFAULT:
|
||||||
|
return "Default"
|
||||||
|
case NUMERAL_SYSTEM_WESTERN_ARABIC_NUMERALS:
|
||||||
|
return "Western Arabic Numerals"
|
||||||
|
case NUMERAL_SYSTEM_EASTERN_ARABIC_NUMERALS:
|
||||||
|
return "Eastern Arabic Numerals"
|
||||||
|
case NUMERAL_SYSTEM_PERSIAN_DIGITS:
|
||||||
|
return "Persian Digits"
|
||||||
|
case NUMERAL_SYSTEM_BURMESE_NUMERALS:
|
||||||
|
return "Burmese Numerals"
|
||||||
|
case NUMERAL_SYSTEM_DEVANAGARI_NUMERALS:
|
||||||
|
return "Devanagari Numerals"
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("Invalid(%d)", int(f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// DecimalSeparator represents the type of decimal separator
|
// DecimalSeparator represents the type of decimal separator
|
||||||
type DecimalSeparator byte
|
type DecimalSeparator byte
|
||||||
|
|
||||||
@@ -69,10 +103,11 @@ type DigitGroupingType byte
|
|||||||
|
|
||||||
// Digit Grouping Type
|
// Digit Grouping Type
|
||||||
const (
|
const (
|
||||||
DIGIT_GROUPING_TYPE_DEFAULT DigitGroupingType = 0
|
DIGIT_GROUPING_TYPE_DEFAULT DigitGroupingType = 0
|
||||||
DIGIT_GROUPING_TYPE_NONE DigitGroupingType = 1
|
DIGIT_GROUPING_TYPE_NONE DigitGroupingType = 1
|
||||||
DIGIT_GROUPING_TYPE_THOUSANDS_SEPARATOR DigitGroupingType = 2
|
DIGIT_GROUPING_TYPE_THOUSANDS_SEPARATOR DigitGroupingType = 2
|
||||||
DIGIT_GROUPING_TYPE_INVALID DigitGroupingType = 255
|
DIGIT_GROUPING_TYPE_INDIAN_NUMBER_GROUPING DigitGroupingType = 3
|
||||||
|
DIGIT_GROUPING_TYPE_INVALID DigitGroupingType = 255
|
||||||
)
|
)
|
||||||
|
|
||||||
// String returns a textual representation of the digit grouping type enum
|
// String returns a textual representation of the digit grouping type enum
|
||||||
@@ -84,6 +119,8 @@ func (d DigitGroupingType) String() string {
|
|||||||
return "None"
|
return "None"
|
||||||
case DIGIT_GROUPING_TYPE_THOUSANDS_SEPARATOR:
|
case DIGIT_GROUPING_TYPE_THOUSANDS_SEPARATOR:
|
||||||
return "Thousands Separator"
|
return "Thousands Separator"
|
||||||
|
case DIGIT_GROUPING_TYPE_INDIAN_NUMBER_GROUPING:
|
||||||
|
return "Indian Number Grouping"
|
||||||
case DIGIT_GROUPING_TYPE_INVALID:
|
case DIGIT_GROUPING_TYPE_INVALID:
|
||||||
return "Invalid"
|
return "Invalid"
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -76,23 +76,24 @@ type UserFeatureRestrictionType uint64
|
|||||||
|
|
||||||
// User Feature Restriction Type
|
// User Feature Restriction Type
|
||||||
const (
|
const (
|
||||||
USER_FEATURE_RESTRICTION_TYPE_UPDATE_PASSWORD UserFeatureRestrictionType = 1
|
USER_FEATURE_RESTRICTION_TYPE_UPDATE_PASSWORD UserFeatureRestrictionType = 1
|
||||||
USER_FEATURE_RESTRICTION_TYPE_UPDATE_EMAIL UserFeatureRestrictionType = 2
|
USER_FEATURE_RESTRICTION_TYPE_UPDATE_EMAIL UserFeatureRestrictionType = 2
|
||||||
USER_FEATURE_RESTRICTION_TYPE_UPDATE_PROFILE_BASIC_INFO UserFeatureRestrictionType = 3
|
USER_FEATURE_RESTRICTION_TYPE_UPDATE_PROFILE_BASIC_INFO UserFeatureRestrictionType = 3
|
||||||
USER_FEATURE_RESTRICTION_TYPE_UPDATE_AVATAR UserFeatureRestrictionType = 4
|
USER_FEATURE_RESTRICTION_TYPE_UPDATE_AVATAR UserFeatureRestrictionType = 4
|
||||||
USER_FEATURE_RESTRICTION_TYPE_REVOKE_OTHER_SESSION UserFeatureRestrictionType = 5
|
USER_FEATURE_RESTRICTION_TYPE_REVOKE_OTHER_SESSION UserFeatureRestrictionType = 5
|
||||||
USER_FEATURE_RESTRICTION_TYPE_ENABLE_2FA UserFeatureRestrictionType = 6
|
USER_FEATURE_RESTRICTION_TYPE_ENABLE_2FA UserFeatureRestrictionType = 6
|
||||||
USER_FEATURE_RESTRICTION_TYPE_DISABLE_2FA UserFeatureRestrictionType = 7
|
USER_FEATURE_RESTRICTION_TYPE_DISABLE_2FA UserFeatureRestrictionType = 7
|
||||||
USER_FEATURE_RESTRICTION_TYPE_FORGET_PASSWORD UserFeatureRestrictionType = 8
|
USER_FEATURE_RESTRICTION_TYPE_FORGET_PASSWORD UserFeatureRestrictionType = 8
|
||||||
USER_FEATURE_RESTRICTION_TYPE_IMPORT_TRANSACTION UserFeatureRestrictionType = 9
|
USER_FEATURE_RESTRICTION_TYPE_IMPORT_TRANSACTION UserFeatureRestrictionType = 9
|
||||||
USER_FEATURE_RESTRICTION_TYPE_EXPORT_TRANSACTION UserFeatureRestrictionType = 10
|
USER_FEATURE_RESTRICTION_TYPE_EXPORT_TRANSACTION UserFeatureRestrictionType = 10
|
||||||
USER_FEATURE_RESTRICTION_TYPE_CLEAR_ALL_DATA UserFeatureRestrictionType = 11
|
USER_FEATURE_RESTRICTION_TYPE_CLEAR_ALL_DATA UserFeatureRestrictionType = 11
|
||||||
USER_FEATURE_RESTRICTION_TYPE_SYNC_APPLICATION_SETTINGS UserFeatureRestrictionType = 12
|
USER_FEATURE_RESTRICTION_TYPE_SYNC_APPLICATION_SETTINGS UserFeatureRestrictionType = 12
|
||||||
USER_FEATURE_RESTRICTION_TYPE_MCP_ACCESS UserFeatureRestrictionType = 13
|
USER_FEATURE_RESTRICTION_TYPE_MCP_ACCESS UserFeatureRestrictionType = 13
|
||||||
|
USER_FEATURE_RESTRICTION_TYPE_CREATE_TRANSACTION_FROM_AI_IMAGE_RECOGNITION UserFeatureRestrictionType = 14
|
||||||
)
|
)
|
||||||
|
|
||||||
const userFeatureRestrictionTypeMinValue UserFeatureRestrictionType = USER_FEATURE_RESTRICTION_TYPE_UPDATE_PASSWORD
|
const userFeatureRestrictionTypeMinValue UserFeatureRestrictionType = USER_FEATURE_RESTRICTION_TYPE_UPDATE_PASSWORD
|
||||||
const userFeatureRestrictionTypeMaxValue UserFeatureRestrictionType = USER_FEATURE_RESTRICTION_TYPE_MCP_ACCESS
|
const userFeatureRestrictionTypeMaxValue UserFeatureRestrictionType = USER_FEATURE_RESTRICTION_TYPE_CREATE_TRANSACTION_FROM_AI_IMAGE_RECOGNITION
|
||||||
|
|
||||||
// String returns a textual representation of the restriction type of user features
|
// String returns a textual representation of the restriction type of user features
|
||||||
func (t UserFeatureRestrictionType) String() string {
|
func (t UserFeatureRestrictionType) String() string {
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ func TestCronJobSchedulerContainerRepeatRun(t *testing.T) {
|
|||||||
InMemoryDuplicateCheckerCleanupIntervalDuration: 60 * time.Second,
|
InMemoryDuplicateCheckerCleanupIntervalDuration: 60 * time.Second,
|
||||||
})
|
})
|
||||||
|
|
||||||
duplicatechecker.Container.Current = checker
|
duplicatechecker.SetDuplicateChecker(checker)
|
||||||
|
|
||||||
container := &CronJobSchedulerContainer{
|
container := &CronJobSchedulerContainer{
|
||||||
allJobsMap: make(map[string]*CronJob),
|
allJobsMap: make(map[string]*CronJob),
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ func (j *CronJob) doRun() {
|
|||||||
start := time.Now()
|
start := time.Now()
|
||||||
c := core.NewCronJobContext(j.Name, j.Period.GetInterval())
|
c := core.NewCronJobContext(j.Name, j.Period.GetInterval())
|
||||||
|
|
||||||
if duplicatechecker.Container.Current != nil {
|
if duplicatechecker.Container.IsEnabled() {
|
||||||
localAddr, err := utils.GetLocalIPAddressesString()
|
localAddr, err := utils.GetLocalIPAddressesString()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -128,16 +128,16 @@ func getMysqlConnectionString(dbConfig *settings.DatabaseConfig) (string, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getPostgresConnectionString(dbConfig *settings.DatabaseConfig) (string, error) {
|
func getPostgresConnectionString(dbConfig *settings.DatabaseConfig) (string, error) {
|
||||||
host, port, err := net.SplitHostPort(dbConfig.DatabaseHost)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return "", errs.ErrDatabaseHostInvalid
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.HasPrefix(dbConfig.DatabaseHost, "/") { // unix socket path
|
if strings.HasPrefix(dbConfig.DatabaseHost, "/") { // unix socket path
|
||||||
return fmt.Sprintf("postgres://%s:%s@:%s/%s?sslmode=%s&host=%s",
|
return fmt.Sprintf("postgres:///%s?sslmode=%s&host=%s&user=%s&password=%s",
|
||||||
url.QueryEscape(dbConfig.DatabaseUser), url.QueryEscape(dbConfig.DatabasePassword), port, dbConfig.DatabaseName, dbConfig.DatabaseSSLMode, host), nil
|
dbConfig.DatabaseName, dbConfig.DatabaseSSLMode, dbConfig.DatabaseHost, url.QueryEscape(dbConfig.DatabaseUser), url.QueryEscape(dbConfig.DatabasePassword)), nil
|
||||||
} else {
|
} else {
|
||||||
|
host, port, err := net.SplitHostPort(dbConfig.DatabaseHost)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return "", errs.ErrDatabaseHostInvalid
|
||||||
|
}
|
||||||
|
|
||||||
return fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=%s",
|
return fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=%s",
|
||||||
url.QueryEscape(dbConfig.DatabaseUser), url.QueryEscape(dbConfig.DatabasePassword), host, port, dbConfig.DatabaseName, dbConfig.DatabaseSSLMode), nil
|
url.QueryEscape(dbConfig.DatabaseUser), url.QueryEscape(dbConfig.DatabasePassword), host, port, dbConfig.DatabaseName, dbConfig.DatabaseSSLMode), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package datastore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetMysqlConnectionString_TCP(t *testing.T) {
|
||||||
|
expectedValue := "username:password@tcp(1.2.3.4:3306)/dbname?charset=utf8mb4&parseTime=true"
|
||||||
|
actualValue, err := getMysqlConnectionString(&settings.DatabaseConfig{
|
||||||
|
DatabaseType: "mysql",
|
||||||
|
DatabaseHost: "1.2.3.4:3306",
|
||||||
|
DatabaseName: "dbname",
|
||||||
|
DatabaseUser: "username",
|
||||||
|
DatabasePassword: "password",
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, expectedValue, actualValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetMysqlConnectionString_UnixSocket(t *testing.T) {
|
||||||
|
expectedValue := "username:password@unix(/path/to/mysql.sock)/dbname?charset=utf8mb4&parseTime=true"
|
||||||
|
actualValue, err := getMysqlConnectionString(&settings.DatabaseConfig{
|
||||||
|
DatabaseType: "mysql",
|
||||||
|
DatabaseHost: "/path/to/mysql.sock",
|
||||||
|
DatabaseName: "dbname",
|
||||||
|
DatabaseUser: "username",
|
||||||
|
DatabasePassword: "password",
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, expectedValue, actualValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetPostgreSQLConnectionString_TCP(t *testing.T) {
|
||||||
|
expectedValue := "postgres://username:password@1.2.3.4:5432/dbname?sslmode=disable"
|
||||||
|
actualValue, err := getPostgresConnectionString(&settings.DatabaseConfig{
|
||||||
|
DatabaseType: "postgres",
|
||||||
|
DatabaseHost: "1.2.3.4:5432",
|
||||||
|
DatabaseName: "dbname",
|
||||||
|
DatabaseUser: "username",
|
||||||
|
DatabasePassword: "password",
|
||||||
|
DatabaseSSLMode: "disable",
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, expectedValue, actualValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetPostgreSQLConnectionString_UnixSocket(t *testing.T) {
|
||||||
|
expectedValue := "postgres:///dbname?sslmode=disable&host=/path/to/postgres.sock&user=username&password=password"
|
||||||
|
actualValue, err := getPostgresConnectionString(&settings.DatabaseConfig{
|
||||||
|
DatabaseType: "postgres",
|
||||||
|
DatabaseHost: "/path/to/postgres.sock",
|
||||||
|
DatabaseName: "dbname",
|
||||||
|
DatabaseUser: "username",
|
||||||
|
DatabasePassword: "password",
|
||||||
|
DatabaseSSLMode: "disable",
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, expectedValue, actualValue)
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
|
|
||||||
// DuplicateCheckerContainer contains the current duplicate checker
|
// DuplicateCheckerContainer contains the current duplicate checker
|
||||||
type DuplicateCheckerContainer struct {
|
type DuplicateCheckerContainer struct {
|
||||||
Current DuplicateChecker
|
current DuplicateChecker
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize a duplicate checker container singleton instance
|
// Initialize a duplicate checker container singleton instance
|
||||||
@@ -21,7 +21,7 @@ var (
|
|||||||
func InitializeDuplicateChecker(config *settings.Config) error {
|
func InitializeDuplicateChecker(config *settings.Config) error {
|
||||||
if config.DuplicateCheckerType == settings.InMemoryDuplicateCheckerType {
|
if config.DuplicateCheckerType == settings.InMemoryDuplicateCheckerType {
|
||||||
checker, err := NewInMemoryDuplicateChecker(config)
|
checker, err := NewInMemoryDuplicateChecker(config)
|
||||||
Container.Current = checker
|
Container.current = checker
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -29,37 +29,75 @@ func InitializeDuplicateChecker(config *settings.Config) error {
|
|||||||
return errs.ErrInvalidDuplicateCheckerType
|
return errs.ErrInvalidDuplicateCheckerType
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetDuplicateChecker sets the current duplicate checker
|
||||||
|
func SetDuplicateChecker(checker DuplicateChecker) {
|
||||||
|
Container.current = checker
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsEnabled returns whether the duplicate checker is enabled
|
||||||
|
func (c *DuplicateCheckerContainer) IsEnabled() bool {
|
||||||
|
return c.current != nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetSubmissionRemark returns whether the same submission has been processed and related remark by the current duplicate checker
|
// GetSubmissionRemark returns whether the same submission has been processed and related remark by the current duplicate checker
|
||||||
func (c *DuplicateCheckerContainer) GetSubmissionRemark(checkerType DuplicateCheckerType, uid int64, identification string) (bool, string) {
|
func (c *DuplicateCheckerContainer) GetSubmissionRemark(checkerType DuplicateCheckerType, uid int64, identification string) (bool, string) {
|
||||||
return c.Current.GetSubmissionRemark(checkerType, uid, identification)
|
if c.current == nil {
|
||||||
|
return false, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.current.GetSubmissionRemark(checkerType, uid, identification)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetSubmissionRemark saves the identification and remark by the current duplicate checker
|
// SetSubmissionRemark saves the identification and remark by the current duplicate checker
|
||||||
func (c *DuplicateCheckerContainer) SetSubmissionRemark(checkerType DuplicateCheckerType, uid int64, identification string, remark string) {
|
func (c *DuplicateCheckerContainer) SetSubmissionRemark(checkerType DuplicateCheckerType, uid int64, identification string, remark string) {
|
||||||
c.Current.SetSubmissionRemark(checkerType, uid, identification, remark)
|
if c.current == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.current.SetSubmissionRemark(checkerType, uid, identification, remark)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveSubmissionRemark removes the identification and remark by the current duplicate checker
|
// RemoveSubmissionRemark removes the identification and remark by the current duplicate checker
|
||||||
func (c *DuplicateCheckerContainer) RemoveSubmissionRemark(checkerType DuplicateCheckerType, uid int64, identification string) {
|
func (c *DuplicateCheckerContainer) RemoveSubmissionRemark(checkerType DuplicateCheckerType, uid int64, identification string) {
|
||||||
c.Current.RemoveSubmissionRemark(checkerType, uid, identification)
|
if c.current == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.current.RemoveSubmissionRemark(checkerType, uid, identification)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetOrSetCronJobRunningInfo returns the running info when the cron job is running or saves the running info by the current duplicate checker
|
// GetOrSetCronJobRunningInfo returns the running info when the cron job is running or saves the running info by the current duplicate checker
|
||||||
func (c *DuplicateCheckerContainer) GetOrSetCronJobRunningInfo(jobName string, runningInfo string, runningInterval time.Duration) (bool, string) {
|
func (c *DuplicateCheckerContainer) GetOrSetCronJobRunningInfo(jobName string, runningInfo string, runningInterval time.Duration) (bool, string) {
|
||||||
return c.Current.GetOrSetCronJobRunningInfo(jobName, runningInfo, runningInterval)
|
if c.current == nil {
|
||||||
|
return false, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.current.GetOrSetCronJobRunningInfo(jobName, runningInfo, runningInterval)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveCronJobRunningInfo removes the running info of the cron job by the current duplicate checker
|
// RemoveCronJobRunningInfo removes the running info of the cron job by the current duplicate checker
|
||||||
func (c *DuplicateCheckerContainer) RemoveCronJobRunningInfo(jobName string) {
|
func (c *DuplicateCheckerContainer) RemoveCronJobRunningInfo(jobName string) {
|
||||||
c.Current.RemoveCronJobRunningInfo(jobName)
|
if c.current == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.current.RemoveCronJobRunningInfo(jobName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetFailureCount returns the failure count of the specified failure key
|
// GetFailureCount returns the failure count of the specified failure key
|
||||||
func (c *DuplicateCheckerContainer) GetFailureCount(failureKey string) uint32 {
|
func (c *DuplicateCheckerContainer) GetFailureCount(failureKey string) uint32 {
|
||||||
return c.Current.GetFailureCount(failureKey)
|
if c.current == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.current.GetFailureCount(failureKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// IncreaseFailureCount increases the failure count of the specified failure key
|
// IncreaseFailureCount increases the failure count of the specified failure key
|
||||||
func (c *DuplicateCheckerContainer) IncreaseFailureCount(failureKey string) uint32 {
|
func (c *DuplicateCheckerContainer) IncreaseFailureCount(failureKey string) uint32 {
|
||||||
return c.Current.IncreaseFailureCount(failureKey)
|
if c.current == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.current.IncreaseFailureCount(failureKey)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,4 +30,5 @@ var (
|
|||||||
ErrInvalidAmountExpression = NewNormalError(NormalSubcategoryConverter, 23, http.StatusBadRequest, "invalid amount expression")
|
ErrInvalidAmountExpression = NewNormalError(NormalSubcategoryConverter, 23, http.StatusBadRequest, "invalid amount expression")
|
||||||
ErrInvalidXmlFile = NewNormalError(NormalSubcategoryConverter, 24, http.StatusBadRequest, "invalid xml file")
|
ErrInvalidXmlFile = NewNormalError(NormalSubcategoryConverter, 24, http.StatusBadRequest, "invalid xml file")
|
||||||
ErrInvalidMT940File = NewNormalError(NormalSubcategoryConverter, 25, http.StatusBadRequest, "invalid mt940 file")
|
ErrInvalidMT940File = NewNormalError(NormalSubcategoryConverter, 25, http.StatusBadRequest, "invalid mt940 file")
|
||||||
|
ErrInvalidJSONFile = NewNormalError(NormalSubcategoryConverter, 26, http.StatusBadRequest, "invalid json file")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ const (
|
|||||||
NormalSubcategoryConverter = 12
|
NormalSubcategoryConverter = 12
|
||||||
NormalSubcategoryUserCustomExchangeRate = 13
|
NormalSubcategoryUserCustomExchangeRate = 13
|
||||||
NormalSubcategoryModelContextProtocol = 14
|
NormalSubcategoryModelContextProtocol = 14
|
||||||
|
NormalSubcategoryLargeLanguageModel = 15
|
||||||
)
|
)
|
||||||
|
|
||||||
// Error represents the specific error returned to user
|
// Error represents the specific error returned to user
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package errs
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
// Error codes related to large language model features
|
||||||
|
var (
|
||||||
|
ErrLargeLanguageModelProviderNotEnabled = NewNormalError(NormalSubcategoryLargeLanguageModel, 0, http.StatusBadRequest, "llm provider is not enabled")
|
||||||
|
ErrNoAIRecognitionImage = NewNormalError(NormalSubcategoryLargeLanguageModel, 1, http.StatusBadRequest, "no image for AI recognition")
|
||||||
|
ErrAIRecognitionImageIsEmpty = NewNormalError(NormalSubcategoryLargeLanguageModel, 2, http.StatusBadRequest, "image for AI recognition is empty")
|
||||||
|
ErrExceedMaxAIRecognitionImageFileSize = NewNormalError(NormalSubcategoryLargeLanguageModel, 3, http.StatusBadRequest, "exceed the maximum size of image file for AI recognition")
|
||||||
|
ErrNoTransactionInformationInImage = NewNormalError(NormalSubcategoryLargeLanguageModel, 4, http.StatusBadRequest, "no transaction information detected")
|
||||||
|
)
|
||||||
@@ -24,4 +24,6 @@ var (
|
|||||||
ErrInvalidPasswordResetTokenExpiredTime = NewSystemError(SystemSubcategorySetting, 17, http.StatusInternalServerError, "invalid password reset token expired time")
|
ErrInvalidPasswordResetTokenExpiredTime = NewSystemError(SystemSubcategorySetting, 17, http.StatusInternalServerError, "invalid password reset token expired time")
|
||||||
ErrInvalidExchangeRatesDataSource = NewSystemError(SystemSubcategorySetting, 18, http.StatusInternalServerError, "invalid exchange rates data source")
|
ErrInvalidExchangeRatesDataSource = NewSystemError(SystemSubcategorySetting, 18, http.StatusInternalServerError, "invalid exchange rates data source")
|
||||||
ErrInvalidIpAddressPattern = NewSystemError(SystemSubcategorySetting, 19, http.StatusInternalServerError, "invalid ip address pattern")
|
ErrInvalidIpAddressPattern = NewSystemError(SystemSubcategorySetting, 19, http.StatusInternalServerError, "invalid ip address pattern")
|
||||||
|
ErrInvalidLLMProvider = NewSystemError(SystemSubcategorySetting, 20, http.StatusInternalServerError, "invalid llm provider")
|
||||||
|
ErrInvalidLLMModelId = NewSystemError(SystemSubcategorySetting, 21, http.StatusInternalServerError, "invalid llm model id")
|
||||||
)
|
)
|
||||||
|
|||||||
+16
-17
@@ -2,7 +2,6 @@ package exchangerates
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -25,13 +24,13 @@ type HttpExchangeRatesDataSource interface {
|
|||||||
Parse(c core.Context, content []byte) (*models.LatestExchangeRateResponse, error)
|
Parse(c core.Context, content []byte) (*models.LatestExchangeRateResponse, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CommonHttpExchangeRatesDataSource defines the structure of common http exchange rates data source
|
// CommonHttpExchangeRatesDataProvider defines the structure of common http exchange rates data provider
|
||||||
type CommonHttpExchangeRatesDataSource struct {
|
type CommonHttpExchangeRatesDataProvider struct {
|
||||||
ExchangeRatesDataSource
|
ExchangeRatesDataProvider
|
||||||
dataSource HttpExchangeRatesDataSource
|
dataSource HttpExchangeRatesDataSource
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *CommonHttpExchangeRatesDataSource) GetLatestExchangeRates(c core.Context, uid int64, currentConfig *settings.Config) (*models.LatestExchangeRateResponse, error) {
|
func (e *CommonHttpExchangeRatesDataProvider) GetLatestExchangeRates(c core.Context, uid int64, currentConfig *settings.Config) (*models.LatestExchangeRateResponse, error) {
|
||||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||||
utils.SetProxyUrl(transport, currentConfig.ExchangeRatesProxy)
|
utils.SetProxyUrl(transport, currentConfig.ExchangeRatesProxy)
|
||||||
|
|
||||||
@@ -49,7 +48,7 @@ func (e *CommonHttpExchangeRatesDataSource) GetLatestExchangeRates(c core.Contex
|
|||||||
requests, err := e.dataSource.BuildRequests()
|
requests, err := e.dataSource.BuildRequests()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf(c, "[http_exchange_rates_datasource.GetLatestExchangeRates] failed to build requests for user \"uid:%d\", because %s", uid, err.Error())
|
log.Errorf(c, "[common_http_exchange_rates_data_provider.GetLatestExchangeRates] failed to build requests for user \"uid:%d\", because %s", uid, err.Error())
|
||||||
return nil, errs.ErrFailedToRequestRemoteApi
|
return nil, errs.ErrFailedToRequestRemoteApi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +58,7 @@ func (e *CommonHttpExchangeRatesDataSource) GetLatestExchangeRates(c core.Contex
|
|||||||
req := requests[i]
|
req := requests[i]
|
||||||
|
|
||||||
if len(req.Header.Values("User-Agent")) < 1 {
|
if len(req.Header.Values("User-Agent")) < 1 {
|
||||||
req.Header.Set("User-Agent", fmt.Sprintf("ezBookkeeping/%s", settings.Version))
|
req.Header.Set("User-Agent", settings.GetUserAgent())
|
||||||
} else if req.Header.Get("User-Agent") == "" {
|
} else if req.Header.Get("User-Agent") == "" {
|
||||||
req.Header.Del("User-Agent")
|
req.Header.Del("User-Agent")
|
||||||
}
|
}
|
||||||
@@ -67,24 +66,24 @@ func (e *CommonHttpExchangeRatesDataSource) GetLatestExchangeRates(c core.Contex
|
|||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf(c, "[http_exchange_rates_datasource.GetLatestExchangeRates] failed to request latest exchange rate data for user \"uid:%d\", because %s", uid, err.Error())
|
log.Errorf(c, "[common_http_exchange_rates_data_provider.GetLatestExchangeRates] failed to request latest exchange rate data for user \"uid:%d\", because %s", uid, err.Error())
|
||||||
return nil, errs.ErrFailedToRequestRemoteApi
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
log.Errorf(c, "[http_exchange_rates_datasource.GetLatestExchangeRates] failed to get latest exchange rate data response for user \"uid:%d\", because response code is not 200", uid)
|
|
||||||
return nil, errs.ErrFailedToRequestRemoteApi
|
return nil, errs.ErrFailedToRequestRemoteApi
|
||||||
}
|
}
|
||||||
|
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
|
||||||
log.Debugf(c, "[http_exchange_rates_datasource.GetLatestExchangeRates] response#%d is %s", i, body)
|
log.Debugf(c, "[common_http_exchange_rates_data_provider.GetLatestExchangeRates] response#%d is %s", i, body)
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
log.Errorf(c, "[common_http_exchange_rates_data_provider.GetLatestExchangeRates] failed to get latest exchange rate data response for user \"uid:%d\", because response code is not %d", uid, resp.StatusCode)
|
||||||
|
return nil, errs.ErrFailedToRequestRemoteApi
|
||||||
|
}
|
||||||
|
|
||||||
exchangeRateResp, err := e.dataSource.Parse(c, body)
|
exchangeRateResp, err := e.dataSource.Parse(c, body)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf(c, "[http_exchange_rates_datasource.GetLatestExchangeRates] failed to parse response for user \"uid:%d\", because %s", uid, err.Error())
|
log.Errorf(c, "[common_http_exchange_rates_data_provider.GetLatestExchangeRates] failed to parse response for user \"uid:%d\", because %s", uid, err.Error())
|
||||||
return nil, errs.Or(err, errs.ErrFailedToRequestRemoteApi)
|
return nil, errs.Or(err, errs.ErrFailedToRequestRemoteApi)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,8 +125,8 @@ func (e *CommonHttpExchangeRatesDataSource) GetLatestExchangeRates(c core.Contex
|
|||||||
return finalExchangeRateResponse, nil
|
return finalExchangeRateResponse, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func newCommonHttpExchangeRatesDataSource(dataSource HttpExchangeRatesDataSource) *CommonHttpExchangeRatesDataSource {
|
func newCommonHttpExchangeRatesDataProvider(dataSource HttpExchangeRatesDataSource) *CommonHttpExchangeRatesDataProvider {
|
||||||
return &CommonHttpExchangeRatesDataSource{
|
return &CommonHttpExchangeRatesDataProvider{
|
||||||
dataSource: dataSource,
|
dataSource: dataSource,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+1
-4
@@ -326,15 +326,12 @@ func executeLatestExchangeRateHandler(t *testing.T, dataSourceType string) *mode
|
|||||||
err := InitializeExchangeRatesDataSource(config)
|
err := InitializeExchangeRatesDataSource(config)
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
|
|
||||||
dataSource := Container.Current
|
|
||||||
assert.NotNil(t, dataSource)
|
|
||||||
|
|
||||||
ginContext, _ := gin.CreateTestContext(httptest.NewRecorder())
|
ginContext, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||||
context := &core.WebContext{
|
context := &core.WebContext{
|
||||||
Context: ginContext,
|
Context: ginContext,
|
||||||
}
|
}
|
||||||
|
|
||||||
exchangeRateResponse, err := dataSource.GetLatestExchangeRates(context, context.GetCurrentUid(), config)
|
exchangeRateResponse, err := Container.GetLatestExchangeRates(context, context.GetCurrentUid(), config)
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
assert.NotNil(t, exchangeRateResponse)
|
assert.NotNil(t, exchangeRateResponse)
|
||||||
|
|
||||||
+2
-2
@@ -6,8 +6,8 @@ import (
|
|||||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ExchangeRatesDataSource defines the structure of exchange rates data source
|
// ExchangeRatesDataProvider defines the structure of exchange rates data provider
|
||||||
type ExchangeRatesDataSource interface {
|
type ExchangeRatesDataProvider interface {
|
||||||
// GetLatestExchangeRates returns the common response entities
|
// GetLatestExchangeRates returns the common response entities
|
||||||
GetLatestExchangeRates(c core.Context, uid int64, currentConfig *settings.Config) (*models.LatestExchangeRateResponse, error)
|
GetLatestExchangeRates(c core.Context, uid int64, currentConfig *settings.Config) (*models.LatestExchangeRateResponse, error)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package exchangerates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExchangeRatesDataProviderContainer contains the current exchange rates data provider
|
||||||
|
type ExchangeRatesDataProviderContainer struct {
|
||||||
|
current ExchangeRatesDataProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize a exchange rates data provider container singleton instance
|
||||||
|
var (
|
||||||
|
Container = &ExchangeRatesDataProviderContainer{}
|
||||||
|
)
|
||||||
|
|
||||||
|
// InitializeExchangeRatesDataSource initializes the current exchange rates data source according to the config
|
||||||
|
func InitializeExchangeRatesDataSource(config *settings.Config) error {
|
||||||
|
if config.ExchangeRatesDataSource == settings.ReserveBankOfAustraliaDataSource {
|
||||||
|
Container.current = newCommonHttpExchangeRatesDataProvider(&ReserveBankOfAustraliaDataSource{})
|
||||||
|
return nil
|
||||||
|
} else if config.ExchangeRatesDataSource == settings.BankOfCanadaDataSource {
|
||||||
|
Container.current = newCommonHttpExchangeRatesDataProvider(&BankOfCanadaDataSource{})
|
||||||
|
return nil
|
||||||
|
} else if config.ExchangeRatesDataSource == settings.CzechNationalBankDataSource {
|
||||||
|
Container.current = newCommonHttpExchangeRatesDataProvider(&CzechNationalBankDataSource{})
|
||||||
|
return nil
|
||||||
|
} else if config.ExchangeRatesDataSource == settings.DanmarksNationalbankDataSource {
|
||||||
|
Container.current = newCommonHttpExchangeRatesDataProvider(&DanmarksNationalbankDataSource{})
|
||||||
|
return nil
|
||||||
|
} else if config.ExchangeRatesDataSource == settings.EuroCentralBankDataSource {
|
||||||
|
Container.current = newCommonHttpExchangeRatesDataProvider(&EuroCentralBankDataSource{})
|
||||||
|
return nil
|
||||||
|
} else if config.ExchangeRatesDataSource == settings.NationalBankOfGeorgiaDataSource {
|
||||||
|
Container.current = newCommonHttpExchangeRatesDataProvider(&NationalBankOfGeorgiaDataSource{})
|
||||||
|
return nil
|
||||||
|
} else if config.ExchangeRatesDataSource == settings.CentralBankOfHungaryDataSource {
|
||||||
|
Container.current = newCommonHttpExchangeRatesDataProvider(&CentralBankOfHungaryDataSource{})
|
||||||
|
return nil
|
||||||
|
} else if config.ExchangeRatesDataSource == settings.BankOfIsraelDataSource {
|
||||||
|
Container.current = newCommonHttpExchangeRatesDataProvider(&BankOfIsraelDataSource{})
|
||||||
|
return nil
|
||||||
|
} else if config.ExchangeRatesDataSource == settings.CentralBankOfMyanmarDataSource {
|
||||||
|
Container.current = newCommonHttpExchangeRatesDataProvider(&CentralBankOfMyanmarDataSource{})
|
||||||
|
return nil
|
||||||
|
} else if config.ExchangeRatesDataSource == settings.NorgesBankDataSource {
|
||||||
|
Container.current = newCommonHttpExchangeRatesDataProvider(&NorgesBankDataSource{})
|
||||||
|
return nil
|
||||||
|
} else if config.ExchangeRatesDataSource == settings.NationalBankOfPolandDataSource {
|
||||||
|
Container.current = newCommonHttpExchangeRatesDataProvider(&NationalBankOfPolandDataSource{})
|
||||||
|
return nil
|
||||||
|
} else if config.ExchangeRatesDataSource == settings.NationalBankOfRomaniaDataSource {
|
||||||
|
Container.current = newCommonHttpExchangeRatesDataProvider(&NationalBankOfRomaniaDataSource{})
|
||||||
|
return nil
|
||||||
|
} else if config.ExchangeRatesDataSource == settings.BankOfRussiaDataSource {
|
||||||
|
Container.current = newCommonHttpExchangeRatesDataProvider(&BankOfRussiaDataSource{})
|
||||||
|
return nil
|
||||||
|
} else if config.ExchangeRatesDataSource == settings.SwissNationalBankDataSource {
|
||||||
|
Container.current = newCommonHttpExchangeRatesDataProvider(&SwissNationalBankDataSource{})
|
||||||
|
return nil
|
||||||
|
} else if config.ExchangeRatesDataSource == settings.NationalBankOfUkraineDataSource {
|
||||||
|
Container.current = newCommonHttpExchangeRatesDataProvider(&NationalBankOfUkraineDataSource{})
|
||||||
|
return nil
|
||||||
|
} else if config.ExchangeRatesDataSource == settings.CentralBankOfUzbekistanDataSource {
|
||||||
|
Container.current = newCommonHttpExchangeRatesDataProvider(&CentralBankOfUzbekistanDataSource{})
|
||||||
|
return nil
|
||||||
|
} else if config.ExchangeRatesDataSource == settings.InternationalMonetaryFundDataSource {
|
||||||
|
Container.current = newCommonHttpExchangeRatesDataProvider(&InternationalMonetaryFundDataSource{})
|
||||||
|
return nil
|
||||||
|
} else if config.ExchangeRatesDataSource == settings.UserCustomExchangeRatesDataSource {
|
||||||
|
Container.current = newUserCustomExchangeRatesDataProvider()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return errs.ErrInvalidExchangeRatesDataSource
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLatestExchangeRates returns the latest exchange rates data from the current exchange rates data source
|
||||||
|
func (e *ExchangeRatesDataProviderContainer) GetLatestExchangeRates(c core.Context, uid int64, currentConfig *settings.Config) (*models.LatestExchangeRateResponse, error) {
|
||||||
|
if Container.current == nil {
|
||||||
|
return nil, errs.ErrInvalidExchangeRatesDataSource
|
||||||
|
}
|
||||||
|
|
||||||
|
return e.current.GetLatestExchangeRates(c, uid, currentConfig)
|
||||||
|
}
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
package exchangerates
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ExchangeRatesDataSourceContainer contains the current exchange rates data source
|
|
||||||
type ExchangeRatesDataSourceContainer struct {
|
|
||||||
Current ExchangeRatesDataSource
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize a exchange rates data source container singleton instance
|
|
||||||
var (
|
|
||||||
Container = &ExchangeRatesDataSourceContainer{}
|
|
||||||
)
|
|
||||||
|
|
||||||
// InitializeExchangeRatesDataSource initializes the current exchange rates data source according to the config
|
|
||||||
func InitializeExchangeRatesDataSource(config *settings.Config) error {
|
|
||||||
if config.ExchangeRatesDataSource == settings.ReserveBankOfAustraliaDataSource {
|
|
||||||
Container.Current = newCommonHttpExchangeRatesDataSource(&ReserveBankOfAustraliaDataSource{})
|
|
||||||
return nil
|
|
||||||
} else if config.ExchangeRatesDataSource == settings.BankOfCanadaDataSource {
|
|
||||||
Container.Current = newCommonHttpExchangeRatesDataSource(&BankOfCanadaDataSource{})
|
|
||||||
return nil
|
|
||||||
} else if config.ExchangeRatesDataSource == settings.CzechNationalBankDataSource {
|
|
||||||
Container.Current = newCommonHttpExchangeRatesDataSource(&CzechNationalBankDataSource{})
|
|
||||||
return nil
|
|
||||||
} else if config.ExchangeRatesDataSource == settings.DanmarksNationalbankDataSource {
|
|
||||||
Container.Current = newCommonHttpExchangeRatesDataSource(&DanmarksNationalbankDataSource{})
|
|
||||||
return nil
|
|
||||||
} else if config.ExchangeRatesDataSource == settings.EuroCentralBankDataSource {
|
|
||||||
Container.Current = newCommonHttpExchangeRatesDataSource(&EuroCentralBankDataSource{})
|
|
||||||
return nil
|
|
||||||
} else if config.ExchangeRatesDataSource == settings.NationalBankOfGeorgiaDataSource {
|
|
||||||
Container.Current = newCommonHttpExchangeRatesDataSource(&NationalBankOfGeorgiaDataSource{})
|
|
||||||
return nil
|
|
||||||
} else if config.ExchangeRatesDataSource == settings.CentralBankOfHungaryDataSource {
|
|
||||||
Container.Current = newCommonHttpExchangeRatesDataSource(&CentralBankOfHungaryDataSource{})
|
|
||||||
return nil
|
|
||||||
} else if config.ExchangeRatesDataSource == settings.BankOfIsraelDataSource {
|
|
||||||
Container.Current = newCommonHttpExchangeRatesDataSource(&BankOfIsraelDataSource{})
|
|
||||||
return nil
|
|
||||||
} else if config.ExchangeRatesDataSource == settings.CentralBankOfMyanmarDataSource {
|
|
||||||
Container.Current = newCommonHttpExchangeRatesDataSource(&CentralBankOfMyanmarDataSource{})
|
|
||||||
return nil
|
|
||||||
} else if config.ExchangeRatesDataSource == settings.NorgesBankDataSource {
|
|
||||||
Container.Current = newCommonHttpExchangeRatesDataSource(&NorgesBankDataSource{})
|
|
||||||
return nil
|
|
||||||
} else if config.ExchangeRatesDataSource == settings.NationalBankOfPolandDataSource {
|
|
||||||
Container.Current = newCommonHttpExchangeRatesDataSource(&NationalBankOfPolandDataSource{})
|
|
||||||
return nil
|
|
||||||
} else if config.ExchangeRatesDataSource == settings.NationalBankOfRomaniaDataSource {
|
|
||||||
Container.Current = newCommonHttpExchangeRatesDataSource(&NationalBankOfRomaniaDataSource{})
|
|
||||||
return nil
|
|
||||||
} else if config.ExchangeRatesDataSource == settings.BankOfRussiaDataSource {
|
|
||||||
Container.Current = newCommonHttpExchangeRatesDataSource(&BankOfRussiaDataSource{})
|
|
||||||
return nil
|
|
||||||
} else if config.ExchangeRatesDataSource == settings.SwissNationalBankDataSource {
|
|
||||||
Container.Current = newCommonHttpExchangeRatesDataSource(&SwissNationalBankDataSource{})
|
|
||||||
return nil
|
|
||||||
} else if config.ExchangeRatesDataSource == settings.NationalBankOfUkraineDataSource {
|
|
||||||
Container.Current = newCommonHttpExchangeRatesDataSource(&NationalBankOfUkraineDataSource{})
|
|
||||||
return nil
|
|
||||||
} else if config.ExchangeRatesDataSource == settings.CentralBankOfUzbekistanDataSource {
|
|
||||||
Container.Current = newCommonHttpExchangeRatesDataSource(&CentralBankOfUzbekistanDataSource{})
|
|
||||||
return nil
|
|
||||||
} else if config.ExchangeRatesDataSource == settings.InternationalMonetaryFundDataSource {
|
|
||||||
Container.Current = newCommonHttpExchangeRatesDataSource(&InternationalMonetaryFundDataSource{})
|
|
||||||
return nil
|
|
||||||
} else if config.ExchangeRatesDataSource == settings.UserCustomExchangeRatesDataSource {
|
|
||||||
Container.Current = newUserCustomExchangeRatesDataSource()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return errs.ErrInvalidExchangeRatesDataSource
|
|
||||||
}
|
|
||||||
+8
-8
@@ -15,25 +15,25 @@ import (
|
|||||||
|
|
||||||
const userDataSourceType = "user_custom"
|
const userDataSourceType = "user_custom"
|
||||||
|
|
||||||
// UserCustomExchangeRatesDataSource defines the structure of user custom exchange rates data source
|
// UserCustomExchangeRatesDataProvider defines the structure of user custom exchange rates data provider
|
||||||
type UserCustomExchangeRatesDataSource struct {
|
type UserCustomExchangeRatesDataProvider struct {
|
||||||
ExchangeRatesDataSource
|
ExchangeRatesDataProvider
|
||||||
users *services.UserService
|
users *services.UserService
|
||||||
userCustomExchangeRates *services.UserCustomExchangeRatesService
|
userCustomExchangeRates *services.UserCustomExchangeRatesService
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *UserCustomExchangeRatesDataSource) GetLatestExchangeRates(c core.Context, uid int64, currentConfig *settings.Config) (*models.LatestExchangeRateResponse, error) {
|
func (e *UserCustomExchangeRatesDataProvider) GetLatestExchangeRates(c core.Context, uid int64, currentConfig *settings.Config) (*models.LatestExchangeRateResponse, error) {
|
||||||
user, err := e.users.GetUserById(c, uid)
|
user, err := e.users.GetUserById(c, uid)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf(c, "[user_custom_datasource.GetLatestExchangeRates] failed to get user \"uid:%d\", because %s", uid, err.Error())
|
log.Errorf(c, "[user_custom_data_provider.GetLatestExchangeRates] failed to get user \"uid:%d\", because %s", uid, err.Error())
|
||||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||||
}
|
}
|
||||||
|
|
||||||
customExchangeRates, err := e.userCustomExchangeRates.GetAllCustomExchangeRatesByUid(c, uid)
|
customExchangeRates, err := e.userCustomExchangeRates.GetAllCustomExchangeRatesByUid(c, uid)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf(c, "[user_custom_datasource.GetLatestExchangeRates] failed to get user custom exchange rates for user \"uid:%d\", because %s", uid, err.Error())
|
log.Errorf(c, "[user_custom_data_provider.GetLatestExchangeRates] failed to get user custom exchange rates for user \"uid:%d\", because %s", uid, err.Error())
|
||||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,8 +93,8 @@ func (e *UserCustomExchangeRatesDataSource) GetLatestExchangeRates(c core.Contex
|
|||||||
return finalExchangeRateResponse, nil
|
return finalExchangeRateResponse, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func newUserCustomExchangeRatesDataSource() *UserCustomExchangeRatesDataSource {
|
func newUserCustomExchangeRatesDataProvider() *UserCustomExchangeRatesDataProvider {
|
||||||
return &UserCustomExchangeRatesDataSource{
|
return &UserCustomExchangeRatesDataProvider{
|
||||||
users: services.Users,
|
users: services.Users,
|
||||||
userCustomExchangeRates: services.UserCustomExchangeRates,
|
userCustomExchangeRates: services.UserCustomExchangeRates,
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package data
|
||||||
|
|
||||||
|
import "reflect"
|
||||||
|
|
||||||
|
type LargeLanguageModelRequestPromptType byte
|
||||||
|
|
||||||
|
// Large Language Model Request Prompt Type
|
||||||
|
const (
|
||||||
|
LARGE_LANGUAGE_MODEL_REQUEST_PROMPT_TYPE_TEXT LargeLanguageModelRequestPromptType = 0
|
||||||
|
LARGE_LANGUAGE_MODEL_REQUEST_PROMPT_TYPE_IMAGE_URL LargeLanguageModelRequestPromptType = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
type LargeLanguageModelResponseFormat byte
|
||||||
|
|
||||||
|
// Large Language Model Response Format
|
||||||
|
const (
|
||||||
|
LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_TEXT LargeLanguageModelResponseFormat = 0
|
||||||
|
LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON LargeLanguageModelResponseFormat = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
// LargeLanguageModelRequest represents a request to a large language model
|
||||||
|
type LargeLanguageModelRequest struct {
|
||||||
|
Stream bool
|
||||||
|
SystemPrompt string
|
||||||
|
UserPrompt []byte
|
||||||
|
UserPromptType LargeLanguageModelRequestPromptType
|
||||||
|
UserPromptContentType string
|
||||||
|
ResponseJsonObjectType reflect.Type
|
||||||
|
}
|
||||||
|
|
||||||
|
// LargeLanguageModelTextualResponse represents a textual response from a large language model
|
||||||
|
type LargeLanguageModelTextualResponse struct {
|
||||||
|
Content string
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/llm/data"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/llm/provider"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/llm/provider/googleai"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/llm/provider/ollama"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/llm/provider/openai"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LargeLanguageModelProviderContainer contains the current large language model provider
|
||||||
|
type LargeLanguageModelProviderContainer struct {
|
||||||
|
receiptImageRecognitionCurrentProvider provider.LargeLanguageModelProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize a large language model provider container singleton instance
|
||||||
|
var (
|
||||||
|
Container = &LargeLanguageModelProviderContainer{}
|
||||||
|
)
|
||||||
|
|
||||||
|
// InitializeLargeLanguageModelProvider initializes the current large language model provider according to the config
|
||||||
|
func InitializeLargeLanguageModelProvider(config *settings.Config) error {
|
||||||
|
var err error = nil
|
||||||
|
|
||||||
|
if config.ReceiptImageRecognitionLLMConfig != nil {
|
||||||
|
Container.receiptImageRecognitionCurrentProvider, err = initializeLargeLanguageModelProvider(config.ReceiptImageRecognitionLLMConfig)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func initializeLargeLanguageModelProvider(llmConfig *settings.LLMConfig) (provider.LargeLanguageModelProvider, error) {
|
||||||
|
if llmConfig.LLMProvider == settings.OpenAILLMProvider {
|
||||||
|
return openai.NewOpenAILargeLanguageModelProvider(llmConfig), nil
|
||||||
|
} else if llmConfig.LLMProvider == settings.OpenAICompatibleLLMProvider {
|
||||||
|
return openai.NewOpenAICompatibleLargeLanguageModelProvider(llmConfig), nil
|
||||||
|
} else if llmConfig.LLMProvider == settings.OpenRouterLLMProvider {
|
||||||
|
return openai.NewOpenRouterLargeLanguageModelProvider(llmConfig), nil
|
||||||
|
} else if llmConfig.LLMProvider == settings.OllamaLLMProvider {
|
||||||
|
return ollama.NewOllamaLargeLanguageModelProvider(llmConfig), nil
|
||||||
|
} else if llmConfig.LLMProvider == settings.GoogleAILLMProvider {
|
||||||
|
return googleai.NewGoogleAILargeLanguageModelProvider(llmConfig), nil
|
||||||
|
} else if llmConfig.LLMProvider == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, errs.ErrInvalidLLMProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetJsonResponseByReceiptImageRecognitionModel returns the json response from the current large language model provider by receipt image recognition model
|
||||||
|
func (l *LargeLanguageModelProviderContainer) GetJsonResponseByReceiptImageRecognitionModel(c core.Context, uid int64, currentConfig *settings.Config, request *data.LargeLanguageModelRequest) (*data.LargeLanguageModelTextualResponse, error) {
|
||||||
|
if currentConfig.ReceiptImageRecognitionLLMConfig == nil || Container.receiptImageRecognitionCurrentProvider == nil {
|
||||||
|
return nil, errs.ErrInvalidLLMProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
return l.receiptImageRecognitionCurrentProvider.GetJsonResponse(c, uid, currentConfig.ReceiptImageRecognitionLLMConfig, request)
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/llm/data"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/llm/provider"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HttpLargeLanguageModelAdapter defines the structure of http large language model adapter
|
||||||
|
type HttpLargeLanguageModelAdapter interface {
|
||||||
|
// BuildTextualRequest returns the http request by the provider api definition
|
||||||
|
BuildTextualRequest(c core.Context, uid int64, request *data.LargeLanguageModelRequest, responseType data.LargeLanguageModelResponseFormat) (*http.Request, error)
|
||||||
|
|
||||||
|
// ParseTextualResponse returns the textual response entity by the provider api definition
|
||||||
|
ParseTextualResponse(c core.Context, uid int64, body []byte, responseType data.LargeLanguageModelResponseFormat) (*data.LargeLanguageModelTextualResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommonHttpLargeLanguageModelProvider defines the structure of common http large language model provider
|
||||||
|
type CommonHttpLargeLanguageModelProvider struct {
|
||||||
|
provider.LargeLanguageModelProvider
|
||||||
|
adapter HttpLargeLanguageModelAdapter
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetJsonResponse returns the json response from common http large language model provider
|
||||||
|
func (p *CommonHttpLargeLanguageModelProvider) GetJsonResponse(c core.Context, uid int64, currentLLMConfig *settings.LLMConfig, request *data.LargeLanguageModelRequest) (*data.LargeLanguageModelTextualResponse, error) {
|
||||||
|
response, err := p.getTextualResponse(c, uid, currentLLMConfig, request, data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(response.Content, "```json") && strings.HasSuffix(response.Content, "```") {
|
||||||
|
response.Content = strings.TrimPrefix(response.Content, "```json")
|
||||||
|
response.Content = strings.TrimSuffix(response.Content, "```")
|
||||||
|
} else if strings.HasPrefix(response.Content, "```") && strings.HasSuffix(response.Content, "```") {
|
||||||
|
response.Content = strings.TrimPrefix(response.Content, "```")
|
||||||
|
response.Content = strings.TrimSuffix(response.Content, "```")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *CommonHttpLargeLanguageModelProvider) getTextualResponse(c core.Context, uid int64, currentLLMConfig *settings.LLMConfig, request *data.LargeLanguageModelRequest, responseType data.LargeLanguageModelResponseFormat) (*data.LargeLanguageModelTextualResponse, error) {
|
||||||
|
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||||
|
utils.SetProxyUrl(transport, currentLLMConfig.LargeLanguageModelAPIProxy)
|
||||||
|
|
||||||
|
if currentLLMConfig.LargeLanguageModelAPISkipTLSVerify {
|
||||||
|
transport.TLSClientConfig = &tls.Config{
|
||||||
|
InsecureSkipVerify: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{
|
||||||
|
Transport: transport,
|
||||||
|
Timeout: time.Duration(currentLLMConfig.LargeLanguageModelAPIRequestTimeout) * time.Millisecond,
|
||||||
|
}
|
||||||
|
|
||||||
|
httpRequest, err := p.adapter.BuildTextualRequest(c, uid, request, responseType)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf(c, "[common_http_large_language_model_provider.getTextualResponse] failed to build requests for user \"uid:%d\", because %s", uid, err.Error())
|
||||||
|
return nil, errs.ErrFailedToRequestRemoteApi
|
||||||
|
}
|
||||||
|
|
||||||
|
httpRequest.Header.Set("User-Agent", settings.GetUserAgent())
|
||||||
|
|
||||||
|
resp, err := client.Do(httpRequest)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf(c, "[common_http_large_language_model_provider.getTextualResponse] failed to request large language model api for user \"uid:%d\", because %s", uid, err.Error())
|
||||||
|
return nil, errs.ErrFailedToRequestRemoteApi
|
||||||
|
}
|
||||||
|
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
|
||||||
|
log.Debugf(c, "[common_http_large_language_model_provider.getTextualResponse] response is %s", body)
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
log.Errorf(c, "[common_http_large_language_model_provider.getTextualResponse] failed to get large language model api response for user \"uid:%d\", because response code is %d", uid, resp.StatusCode)
|
||||||
|
return nil, errs.ErrFailedToRequestRemoteApi
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.adapter.ParseTextualResponse(c, uid, body, responseType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCommonHttpLargeLanguageModelProvider creates a http adapter based large language model provider instance
|
||||||
|
func NewCommonHttpLargeLanguageModelProvider(adapter HttpLargeLanguageModelAdapter) *CommonHttpLargeLanguageModelProvider {
|
||||||
|
return &CommonHttpLargeLanguageModelProvider{
|
||||||
|
adapter: adapter,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
package googleai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/llm/data"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/llm/provider"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/llm/provider/common"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const googleAIGenerateContentAPIFormat = "https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent"
|
||||||
|
|
||||||
|
// GoogleAILargeLanguageModelAdapter defines the structure of Google AI large language model adapter
|
||||||
|
type GoogleAILargeLanguageModelAdapter struct {
|
||||||
|
common.HttpLargeLanguageModelAdapter
|
||||||
|
GoogleAIAPIKey string
|
||||||
|
GoogleAIModelID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// GoogleAIGenerateContentRequest defines the structure of Google AI generate content request
|
||||||
|
type GoogleAIGenerateContentRequest struct {
|
||||||
|
Contents []*GoogleAIGenerateContentRequestContent `json:"contents"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GoogleAIGenerateContentRequestContent defines the structure of Google AI generate content request content
|
||||||
|
type GoogleAIGenerateContentRequestContent struct {
|
||||||
|
Parts []*GoogleAIGenerateContentRequestContentPart `json:"parts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GoogleAIGenerateContentRequestContentPart defines the structure of Google AI generate content request content part
|
||||||
|
type GoogleAIGenerateContentRequestContentPart struct {
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
InlineData *GoogleAIGenerateContentRequestInlineData `json:"inlineData,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GoogleAIGenerateContentRequestInlineData defines the structure of Google AI generate content request inline data
|
||||||
|
type GoogleAIGenerateContentRequestInlineData struct {
|
||||||
|
MimeType string `json:"mimeType"`
|
||||||
|
Data string `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GoogleAIGenerateContentResponse defines the structure of Google AI generate content response
|
||||||
|
type GoogleAIGenerateContentResponse struct {
|
||||||
|
Candidates []*GoogleAIGenerateContentResponseCandidate `json:"candidates"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GoogleAIGenerateContentResponseCandidate defines the structure of Google AI generate content response candidate
|
||||||
|
type GoogleAIGenerateContentResponseCandidate struct {
|
||||||
|
Content *GoogleAIGenerateContentResponseContent `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GoogleAIGenerateContentResponseContent defines the structure of Google AI generate content response content
|
||||||
|
type GoogleAIGenerateContentResponseContent struct {
|
||||||
|
Part []*GoogleAIGenerateContentResponseContentPart `json:"parts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GoogleAIGenerateContentResponseContentPart defines the structure of Google AI generate content response content part
|
||||||
|
type GoogleAIGenerateContentResponseContentPart struct {
|
||||||
|
Text *string `json:"text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildTextualRequest returns the http request by Google AI large language model adapter
|
||||||
|
func (p *GoogleAILargeLanguageModelAdapter) BuildTextualRequest(c core.Context, uid int64, request *data.LargeLanguageModelRequest, responseType data.LargeLanguageModelResponseFormat) (*http.Request, error) {
|
||||||
|
requestBody, err := p.buildJsonRequestBody(c, uid, request, responseType)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
requestUrl := fmt.Sprintf(googleAIGenerateContentAPIFormat, p.GoogleAIModelID)
|
||||||
|
httpRequest, err := http.NewRequest("POST", requestUrl, bytes.NewReader(requestBody))
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
httpRequest.Header.Set("Content-Type", "application/json")
|
||||||
|
httpRequest.Header.Set("X-goog-api-key", p.GoogleAIAPIKey)
|
||||||
|
|
||||||
|
return httpRequest, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseTextualResponse returns the textual response by Google AI large language model adapter
|
||||||
|
func (p *GoogleAILargeLanguageModelAdapter) ParseTextualResponse(c core.Context, uid int64, body []byte, responseType data.LargeLanguageModelResponseFormat) (*data.LargeLanguageModelTextualResponse, error) {
|
||||||
|
generateContentResponse := &GoogleAIGenerateContentResponse{}
|
||||||
|
err := json.Unmarshal(body, &generateContentResponse)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf(c, "[google_ai_large_language_model_adapter.ParseTextualResponse] failed to parse generate content response for user \"uid:%d\", because %s", uid, err.Error())
|
||||||
|
return nil, errs.ErrFailedToRequestRemoteApi
|
||||||
|
}
|
||||||
|
|
||||||
|
if generateContentResponse == nil || generateContentResponse.Candidates == nil || len(generateContentResponse.Candidates) < 1 ||
|
||||||
|
generateContentResponse.Candidates[0].Content == nil || len(generateContentResponse.Candidates[0].Content.Part) < 1 ||
|
||||||
|
generateContentResponse.Candidates[0].Content.Part[0].Text == nil {
|
||||||
|
log.Errorf(c, "[google_ai_large_language_model_adapter.ParseTextualResponse] generate content response is invalid for user \"uid:%d\"", uid)
|
||||||
|
return nil, errs.ErrFailedToRequestRemoteApi
|
||||||
|
}
|
||||||
|
|
||||||
|
textualResponse := &data.LargeLanguageModelTextualResponse{
|
||||||
|
Content: *generateContentResponse.Candidates[0].Content.Part[0].Text,
|
||||||
|
}
|
||||||
|
|
||||||
|
return textualResponse, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *GoogleAILargeLanguageModelAdapter) buildJsonRequestBody(c core.Context, uid int64, request *data.LargeLanguageModelRequest, responseType data.LargeLanguageModelResponseFormat) ([]byte, error) {
|
||||||
|
if p.GoogleAIModelID == "" {
|
||||||
|
return nil, errs.ErrInvalidLLMModelId
|
||||||
|
}
|
||||||
|
|
||||||
|
generateContentRequest := &GoogleAIGenerateContentRequest{
|
||||||
|
Contents: []*GoogleAIGenerateContentRequestContent{
|
||||||
|
{
|
||||||
|
Parts: make([]*GoogleAIGenerateContentRequestContentPart, 0, 2),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.SystemPrompt != "" {
|
||||||
|
generateContentRequest.Contents[0].Parts = append(generateContentRequest.Contents[0].Parts, &GoogleAIGenerateContentRequestContentPart{
|
||||||
|
Text: request.SystemPrompt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(request.UserPrompt) > 0 {
|
||||||
|
if request.UserPromptType == data.LARGE_LANGUAGE_MODEL_REQUEST_PROMPT_TYPE_IMAGE_URL {
|
||||||
|
imageBase64Data := base64.StdEncoding.EncodeToString(request.UserPrompt)
|
||||||
|
generateContentRequest.Contents[0].Parts = append(generateContentRequest.Contents[0].Parts, &GoogleAIGenerateContentRequestContentPart{
|
||||||
|
InlineData: &GoogleAIGenerateContentRequestInlineData{
|
||||||
|
MimeType: request.UserPromptContentType,
|
||||||
|
Data: imageBase64Data,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
generateContentRequest.Contents[0].Parts = append(generateContentRequest.Contents[0].Parts, &GoogleAIGenerateContentRequestContentPart{
|
||||||
|
Text: string(request.UserPrompt),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
requestBodyBytes, err := json.Marshal(generateContentRequest)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf(c, "[google_ai_large_language_model_adapter.buildJsonRequestBody] failed to marshal request body for user \"uid:%d\", because %s", uid, err.Error())
|
||||||
|
return nil, errs.ErrOperationFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debugf(c, "[google_ai_large_language_model_adapter.buildJsonRequestBody] request body is %s", requestBodyBytes)
|
||||||
|
return requestBodyBytes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGoogleAILargeLanguageModelProvider creates a new Google AI large language model provider instance
|
||||||
|
func NewGoogleAILargeLanguageModelProvider(llmConfig *settings.LLMConfig) provider.LargeLanguageModelProvider {
|
||||||
|
return common.NewCommonHttpLargeLanguageModelProvider(&GoogleAILargeLanguageModelAdapter{
|
||||||
|
GoogleAIAPIKey: llmConfig.GoogleAIAPIKey,
|
||||||
|
GoogleAIModelID: llmConfig.GoogleAIModelID,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package googleai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/llm/data"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGoogleAILargeLanguageModelAdapter_buildJsonRequestBody_TextualUserPrompt(t *testing.T) {
|
||||||
|
adapter := &GoogleAILargeLanguageModelAdapter{
|
||||||
|
GoogleAIModelID: "test",
|
||||||
|
}
|
||||||
|
|
||||||
|
request := &data.LargeLanguageModelRequest{
|
||||||
|
SystemPrompt: "You are a helpful assistant.",
|
||||||
|
UserPrompt: []byte("Hello, how are you?"),
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBytes, err := adapter.buildJsonRequestBody(core.NewNullContext(), 0, request, data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
var body map[string]interface{}
|
||||||
|
err = json.Unmarshal(bodyBytes, &body)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, "{\"contents\":[{\"parts\":[{\"text\":\"You are a helpful assistant.\"},{\"text\":\"Hello, how are you?\"}]}]}", string(bodyBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGoogleAILargeLanguageModelAdapter_buildJsonRequestBody_ImageUserPrompt(t *testing.T) {
|
||||||
|
adapter := &GoogleAILargeLanguageModelAdapter{
|
||||||
|
GoogleAIModelID: "test",
|
||||||
|
}
|
||||||
|
|
||||||
|
request := &data.LargeLanguageModelRequest{
|
||||||
|
SystemPrompt: "What's in this image?",
|
||||||
|
UserPrompt: []byte("fakedata"),
|
||||||
|
UserPromptType: data.LARGE_LANGUAGE_MODEL_REQUEST_PROMPT_TYPE_IMAGE_URL,
|
||||||
|
UserPromptContentType: "image/png",
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBytes, err := adapter.buildJsonRequestBody(core.NewNullContext(), 0, request, data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
var body map[string]interface{}
|
||||||
|
err = json.Unmarshal(bodyBytes, &body)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, "{\"contents\":[{\"parts\":[{\"text\":\"What's in this image?\"},{\"inlineData\":{\"mimeType\":\"image/png\",\"data\":\"ZmFrZWRhdGE=\"}}]}]}", string(bodyBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGoogleAILargeLanguageModelAdapter_ParseTextualResponse_ValidJsonResponse(t *testing.T) {
|
||||||
|
adapter := &GoogleAILargeLanguageModelAdapter{
|
||||||
|
GoogleAIModelID: "test",
|
||||||
|
}
|
||||||
|
|
||||||
|
response := `{
|
||||||
|
"responseId": "test-123",
|
||||||
|
"modelVersion": "test",
|
||||||
|
"usageMetadata": {
|
||||||
|
"promptTokenCount": 13,
|
||||||
|
"candidatesTokenCount": 7,
|
||||||
|
"totalTokenCount": 20
|
||||||
|
},
|
||||||
|
"candidates": [
|
||||||
|
{
|
||||||
|
"content": {
|
||||||
|
"parts": [
|
||||||
|
{
|
||||||
|
"text": "This is a test response"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"finish_reason": "stop",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
|
||||||
|
result, err := adapter.ParseTextualResponse(core.NewNullContext(), 0, []byte(response), data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, "This is a test response", result.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGoogleAILargeLanguageModelAdapter_ParseTextualResponse_EmptyResponse(t *testing.T) {
|
||||||
|
adapter := &GoogleAILargeLanguageModelAdapter{
|
||||||
|
GoogleAIModelID: "test",
|
||||||
|
}
|
||||||
|
|
||||||
|
response := `{
|
||||||
|
"responseId": "test-123",
|
||||||
|
"modelVersion": "test",
|
||||||
|
"usageMetadata": {
|
||||||
|
"promptTokenCount": 13,
|
||||||
|
"candidatesTokenCount": 7,
|
||||||
|
"totalTokenCount": 20
|
||||||
|
},
|
||||||
|
"candidates": [
|
||||||
|
{
|
||||||
|
"content": {
|
||||||
|
"parts": [
|
||||||
|
{
|
||||||
|
"text": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"finish_reason": "stop",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
|
||||||
|
result, err := adapter.ParseTextualResponse(core.NewNullContext(), 0, []byte(response), data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, "", result.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGoogleAILargeLanguageModelAdapter_ParseTextualResponse_EmptyCandidates(t *testing.T) {
|
||||||
|
adapter := &GoogleAILargeLanguageModelAdapter{
|
||||||
|
GoogleAIModelID: "test",
|
||||||
|
}
|
||||||
|
|
||||||
|
response := `{
|
||||||
|
"responseId": "test-123",
|
||||||
|
"modelVersion": "test",
|
||||||
|
"usageMetadata": {
|
||||||
|
"promptTokenCount": 13,
|
||||||
|
"candidatesTokenCount": 7,
|
||||||
|
"totalTokenCount": 20
|
||||||
|
},
|
||||||
|
"candidates": []
|
||||||
|
}`
|
||||||
|
|
||||||
|
_, err := adapter.ParseTextualResponse(core.NewNullContext(), 0, []byte(response), data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
|
||||||
|
assert.EqualError(t, err, "failed to request third party api")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGoogleAILargeLanguageModelAdapter_ParseTextualResponse_NoPartText(t *testing.T) {
|
||||||
|
adapter := &GoogleAILargeLanguageModelAdapter{
|
||||||
|
GoogleAIModelID: "test",
|
||||||
|
}
|
||||||
|
|
||||||
|
response := `{
|
||||||
|
"responseId": "test-123",
|
||||||
|
"modelVersion": "test",
|
||||||
|
"usageMetadata": {
|
||||||
|
"promptTokenCount": 13,
|
||||||
|
"candidatesTokenCount": 7,
|
||||||
|
"totalTokenCount": 20
|
||||||
|
},
|
||||||
|
"candidates": [
|
||||||
|
{
|
||||||
|
"content": {
|
||||||
|
"parts": [
|
||||||
|
{
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"finish_reason": "stop",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
|
||||||
|
_, err := adapter.ParseTextualResponse(core.NewNullContext(), 0, []byte(response), data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
|
||||||
|
assert.EqualError(t, err, "failed to request third party api")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGoogleAILargeLanguageModelAdapter_ParseTextualResponse_InvalidJson(t *testing.T) {
|
||||||
|
adapter := &GoogleAILargeLanguageModelAdapter{
|
||||||
|
GoogleAIModelID: "test",
|
||||||
|
}
|
||||||
|
|
||||||
|
response := "error"
|
||||||
|
|
||||||
|
_, err := adapter.ParseTextualResponse(core.NewNullContext(), 0, []byte(response), data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
|
||||||
|
assert.EqualError(t, err, "failed to request third party api")
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package provider
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/llm/data"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LargeLanguageModelProvider defines the structure of large language model provider
|
||||||
|
type LargeLanguageModelProvider interface {
|
||||||
|
// GetJsonResponse returns the json response from the large language model provider
|
||||||
|
GetJsonResponse(c core.Context, uid int64, currentLLMConfig *settings.LLMConfig, request *data.LargeLanguageModelRequest) (*data.LargeLanguageModelTextualResponse, error)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user