diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..40cf554
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,2 @@
+backend/mvnw.cmd text eol=crlf whitespace=-trailing-space
+frontend/CHANGELOG.md text whitespace=-trailing-space
diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml
new file mode 100644
index 0000000..4d01a0a
--- /dev/null
+++ b/.gitea/workflows/release.yml
@@ -0,0 +1,116 @@
+name: Release
+
+on:
+ push:
+ tags:
+ - 'v*'
+
+permissions:
+ contents: write
+
+jobs:
+ release:
+ runs-on: ubuntu-24.04
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Validate release tag
+ id: release_meta
+ env:
+ GITEA_REF_NAME: ${{ github.ref_name }}
+ GITEA_SHA: ${{ github.sha }}
+ run: |
+ VERSION=${GITEA_REF_NAME#v}
+ test "$GITEA_REF_NAME" = "v$VERSION"
+ ./scripts/check-semver.sh "$VERSION"
+ test "$(git rev-parse "refs/tags/$GITEA_REF_NAME^{commit}")" = "$GITEA_SHA"
+ test -z "$(git status --porcelain --untracked-files=all)"
+ printf 'version=%s\n' "$VERSION" >> "$GITHUB_OUTPUT"
+
+ - uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: '17'
+ cache: maven
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+ cache: npm
+ cache-dependency-path: frontend/package-lock.json
+
+ - name: Verify backend
+ env:
+ RELEASE_VERSION: ${{ steps.release_meta.outputs.version }}
+ run: ./backend/mvnw -f backend/pom.xml -Drevision="$RELEASE_VERSION" test
+
+ - name: Verify frontend
+ working-directory: frontend
+ run: |
+ HUSKY=0 npm ci
+ npm run lint -- --no-fix
+ npm run stylelint
+ npm test
+ npm run audit:dependencies
+ npm run build
+ npx playwright install --with-deps chromium
+ npm run test:e2e
+
+ - name: Verify release contracts and scripts
+ run: |
+ ./scripts/check-openapi.sh openapi.yaml
+ ./scripts/test-install-fixture.sh
+ ./scripts/test-git-install-fixture.sh
+ ./scripts/test-update-fixture.sh
+ ./scripts/test-gitea-publish-fixture.sh
+ shellcheck deploy/install.sh deploy/install-from-git.sh deploy/update.sh scripts/check-semver.sh \
+ scripts/package-release.sh scripts/publish-gitea-release.sh \
+ scripts/generate-release-key.sh scripts/test-install-fixture.sh \
+ scripts/test-git-install-fixture.sh \
+ scripts/test-update-fixture.sh scripts/test-gitea-publish-fixture.sh \
+ scripts/verify-release.sh
+
+ - name: Build and sign release assets
+ env:
+ RELEASE_SIGNING_KEY_B64: ${{ secrets.RELEASE_SIGNING_KEY_B64 }}
+ RELEASE_VERSION: ${{ steps.release_meta.outputs.version }}
+ KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256: ${{ vars.KAIDI_RELEASE_PUBLIC_KEY_SHA256 }}
+ KAIDI_RELEASE_NOTES: Kaidi Finance ${{ github.ref_name }}
+ KAIDI_SOURCE_REVISION: ${{ github.sha }}
+ KAIDI_SOURCE_REF: ${{ github.ref_name }}
+ KAIDI_SOURCE_DIRTY: 'false'
+ run: |
+ test -n "$RELEASE_SIGNING_KEY_B64"
+ trap 'shred -u "$RUNNER_TEMP/release-key.pem" 2>/dev/null || rm -f "$RUNNER_TEMP/release-key.pem"' EXIT
+ printf '%s' "$RELEASE_SIGNING_KEY_B64" | base64 --decode > "$RUNNER_TEMP/release-key.pem"
+ chmod 600 "$RUNNER_TEMP/release-key.pem"
+ KAIDI_RELEASE_SIGNING_KEY="$RUNNER_TEMP/release-key.pem" \
+ ./scripts/package-release.sh "$RELEASE_VERSION"
+
+ - name: Verify signed release assets
+ env:
+ KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256: ${{ vars.KAIDI_RELEASE_PUBLIC_KEY_SHA256 }}
+ KAIDI_EXPECTED_SOURCE_REVISION: ${{ github.sha }}
+ KAIDI_EXPECTED_SOURCE_REF: ${{ github.ref_name }}
+ KAIDI_EXPECTED_SOURCE_DIRTY: 'false'
+ run: ./scripts/verify-release.sh dist/release
+
+ - name: Publish Gitea release
+ env:
+ GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
+ GITEA_SERVER_URL: ${{ github.server_url }}
+ GITEA_REPOSITORY: ${{ github.repository }}
+ GITEA_REF_NAME: ${{ github.ref_name }}
+ GITEA_SHA: ${{ github.sha }}
+ run: ./scripts/publish-gitea-release.sh
+
+ - name: Upload Playwright report after failure
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: playwright-report-${{ github.run_id }}
+ path: frontend/playwright-report
+ if-no-files-found: ignore
+ retention-days: 7
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..5c75742
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,15 @@
+.DS_Store
+backend/target/
+frontend/dist/
+frontend/node_modules/
+frontend/playwright-report/
+frontend/test-results/
+.idea/
+.vscode/
+*.iml
+*.log
+.env.local
+runtime/
+/dist/
+test-results/
+*signing-private.pem
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..bb3e396
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,32 @@
+# 项目执行约束
+
+## 禁止空跑
+
+- 禁止执行 `true`、`:`、无内容的 `echo` / `printf`、仅用于占位的 `sleep`,以及其他不产生验证证据或项目进展的命令。
+- 禁止空 `functions.exec` 脚本、仅含注释的脚本(如 `// placeholder`)、只输出空白的调用(如 `text(" ")`),以及只为制造一条工具记录而输出“正在委派”等状态文字的调用。
+- `functions.exec`、`exec_command` 及 shell 不能用作子代理编排的心跳、轮次衔接、保活或等待屏障;编排状态使用对话更新,等待只使用对应的 `wait_agent` / `wait` / `write_stdin`。
+- 禁止为了等待子代理、工具或长任务而插入占位命令。等待运行中命令必须使用对应会话的 `write_stdin` / `wait`;等待子代理必须使用代理状态或等待工具。
+- 每次命令调用前必须能明确回答:要验证什么、预期得到什么证据、结果将影响哪个下一步。回答不出来就不执行。
+- 对成功时本来静默的有效检查,必须显式输出结论,例如 `git diff --check && printf 'diff-check: PASS\n'`,避免把“检查通过”与“无意义空跑”混在一起。
+
+## 截止与接管
+
+- 同一任务连续两次没有新增证据、文件变化、错误定位或可执行结论时,停止继续轮询,由主代理立即接管。
+- 子代理等待以最多 120 秒为一个时间片。单个子代理累计运行 10 分钟仍未交付时,主代理必须只检查一次现有状态:有部分证据就接收后终止,无证据就直接终止,并由主代理接管或拆成更小任务;不得继续盲等。
+- 同一子代理连续两个等待时间片没有任何新增信息时,不等满 10 分钟,立即执行上述接管规则。状态检查本身不算项目进展,禁止用更多状态检查延长截止时间。
+- 一旦出现空命令、空工具脚本或空白输出占位,立即停止该轮委派/轮询节奏;不得换成另一种空操作重试,由主代理直接继续实际工作。
+- 长时间运行的构建、测试和服务只跟踪原会话,不另起占位命令。30 秒以上无新输出时只报告当前阶段;确认卡死后终止并诊断。
+- 同一失败命令最多原样重试一次。再次失败时必须改变诊断手段、缩小范围或由主代理直接处理。
+- 所有截止均由工具自身的超时参数或对应等待工具实现,不得用 `true`、空输出、`sleep` 或新建无关命令模拟截止。
+
+## 有效进展标准
+
+以下至少满足一项,才算一次有效执行:
+
+- 读取到完成当前判断所需的文件或运行状态;
+- 产生明确的测试、构建、Lint、类型检查、迁移或接口结果;
+- 定位到具体错误、文件、行号或根因;
+- 完成文件修改并得到针对性验证;
+- 获得子代理可核验的结论或明确的阻断条件。
+
+状态更新只报告上述有效进展,不报告占位、空轮询或没有信息增量的动作。
diff --git a/README.md b/README.md
index cbdd4f5..bd9c549 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,282 @@
-# Kaifi
+# Kaidi 财务项目基础系统
-ERP 团队项目。
+本仓库当前处于 R1 开发执行阶段,需求基线见下方唯一总方案。
+
+唯一需求、开发、测试和交付基线:
+
+- [财务项目基础系统开发交付总方案](docs/财务系统开发交付总方案.md)
## 本地开发
-项目环境与启动方式将在代码接入后补充。
+后端默认连接本机 MySQL `127.0.0.1:3307`,启动 Java 服务:
+
+```bash
+cd backend
+./mvnw spring-boot:run
+```
+
+前端使用官方 TDesign Vue Next Starter,来源记录见 [`frontend/UPSTREAM.md`](frontend/UPSTREAM.md)。
+
+```bash
+cd frontend
+HUSKY=0 npm ci
+npm run dev:linux -- --host 0.0.0.0 --port 3002
+```
+
+访问:
+
+本地 `local` profile 会创建 `admin`、`project`、`finance`、`archive`、`demo` 演示账号,初始密码均为
+`LocalOnly@123`;该固定密码只用于本机开发,生产安装会生成随机管理员密码。
+
+正式构建:
+
+```bash
+cd frontend
+npm run build
+```
+
+后端运行后更新唯一 OpenAPI 机器制品:
+
+```bash
+./scripts/export-openapi.sh http://127.0.0.1:18080
+```
+
+## R1 Preview 一键安装
+
+Release 发布后,在 Linux 服务器执行下面一组命令即可安装。代码仓库和更新源固定为私有 Gitea
+`https://git.awaioi.com/ERP-Team/kaidi`;服务器读取
+`https://git.awaioi.com/api/v1/repos/ERP-Team/kaidi/releases/latest`,不在生产机执行 `git pull` 或现场编译。
+先从受信任的 CI 输出或内部发布记录取得 `RELEASE_TAG`、`INSTALLER_SHA256` 和 `PUBLIC_KEY_SHA256`。
+后两项也会写入 Release 的 `bootstrap-checksums.txt`,但首次安装必须通过独立渠道核对,不能把同源下载值
+直接当作信任根。私有仓库 Token 只授予仓库/Release 读取权限,不得复用 CI 的发布写 Token。
+
+执行命令的机器需预装 `bash`、`sudo`、`curl`、`mktemp` 和 `sha256sum`,并能访问目标 Gitea;`jq`、Java、
+Nginx 和数据库客户端由安装器补齐。应用固定安装到 `/opt/kaidi`、`/var/lib/kaidi`、
+`/var/lib/kaidi-update` 和 `/etc/kaidi`。目标机应为专用主机,或确认现有 Nginx 默认站点可以被替换且
+80 端口可用;安装器会接管默认 HTTP 站点。
+
+### 直接 curl 安装
+
+```bash
+(
+ set -Eeuo pipefail
+ RELEASE_TAG=v1.0.0-preview.8
+ INSTALLER_SHA256=33d4921bcad7ef2be12f20bc0512a5f6df4227ea6dd3b7e6225164077d9cd9e7
+ PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9
+ REINSTALL=false
+ SETUP_WIZARD=true
+ read -rsp 'Gitea 只读 Token: ' GITEA_READ_TOKEN; printf '\n'
+ token_file=$(mktemp); header_file=$(mktemp); installer=$(mktemp)
+ trap 'rm -f "$token_file" "$header_file" "$installer"' EXIT
+ printf '%s' "$GITEA_READ_TOKEN" > "$token_file"
+ printf 'Authorization: token %s\n' "$GITEA_READ_TOKEN" > "$header_file"
+ unset GITEA_READ_TOKEN
+ chmod 0600 "$token_file" "$header_file" "$installer"
+ curl --fail --silent --show-error --proto '=https' --tlsv1.2 \
+ --header "@$header_file" \
+ "https://git.awaioi.com/ERP-Team/kaidi/releases/download/$RELEASE_TAG/install.sh" \
+ -o "$installer"
+ printf '%s %s\n' "$INSTALLER_SHA256" "$installer" | sha256sum -c -
+ sudo env \
+ KAIDI_RELEASE_API_URL=https://git.awaioi.com/api/v1/repos/ERP-Team/kaidi/releases/latest \
+ KAIDI_RELEASE_TOKEN_FILE="$token_file" \
+ KAIDI_RELEASE_PUBLIC_KEY_SHA256="$PUBLIC_KEY_SHA256" \
+ KAIDI_REINSTALL="$REINSTALL" \
+ KAIDI_SETUP_WIZARD="$SETUP_WIZARD" \
+ bash "$installer"
+)
+```
+
+### 从固定 Git tag 拉取后安装
+
+需要保留源码快照时,可以拉取与 Release 对应的固定 tag,再运行仓库内包装器。私有仓库会在 `git clone`
+时要求 Gitea 凭据,包装器随后单独要求只读 Release Token;它会在 `sudo` 前校验 `deploy/install.sh`
+的固定 SHA-256,再按同一公钥信任链安装最新签名 Release。
+
+```bash
+git clone --branch v1.0.0-preview.8 --depth 1 https://git.awaioi.com/ERP-Team/kaidi.git kaidi-preview
+cd kaidi-preview
+KAIDI_SETUP_WIZARD=true ./deploy/install-from-git.sh
+```
+
+首次安装使用向导时不要传 `KAIDI_DB_URL`、`KAIDI_DB_USERNAME` 或 `KAIDI_DB_PASSWORD`;这些值在浏览器中填写。
+只有已完成安装的修复性重装才从运行时配置读取数据库值,详见下文。
+
+```bash
+KAIDI_DB_URL='jdbc:mysql://MYSQL_HOST:3306/kaidi_finance?useUnicode=true&characterEncoding=utf8&connectionTimeZone=UTC&serverTimezone=UTC' \
+KAIDI_DB_USERNAME=kaidi KAIDI_DB_PASSWORD='DB_PASSWORD' ./deploy/install-from-git.sh
+```
+
+安装器会完成以下动作:
+
+- 仅在 Linux + systemd 环境执行;首版 32 位支持基线为带 systemd 的 Debian/Ubuntu x86 32 位 Linux。
+- 识别 `x86_64`、`aarch64`、`armv7` 或 32 位 `i386/i486/i586/i686`,校验 SHA-256 后安装对应的 Azul Java 17 JRE。
+- 使用安装命令固定的 SHA-256 指纹校验 Release 公钥,再用该公钥验证发布清单 RSA 签名。
+- 首次安装默认启用 `/setup` 向导,不在命令行保存数据库密码;向导只接受 MySQL 8.4.x,并在提交前验证 DDL/DML 权限。
+- 安装签名 Release 到 `/opt/kaidi/releases/`,以 `/opt/kaidi/current` 原子切换当前版本。
+- 安装 Nginx、`kaidi-finance.service`、更新监听服务和健康检查。
+- 向导只初始化一个由操作者填写的 `SYSTEM_ADMIN` 管理员,不创建项目、财务、资料或演示账号。
+- 安装器把一次性安装码写入仅 root 可读的 `/root/kaidi-first-login.txt`;完成向导后写入锁定标记并切换正式应用。
+
+安装完成后先执行 `sudo cat /root/kaidi-first-login.txt`,访问其中的 `/setup` 地址完成数据库和管理员配置;完成后再访问
+`http://SERVER_IP/` 登录。Preview 使用 HTTP 时安装器默认设置
+`SESSION_COOKIE_SECURE=false`;配置 HTTPS 反向代理后,应在 `/etc/kaidi/kaidi.env` 改为
+`SESSION_COOKIE_SECURE=true` 并执行 `sudo systemctl restart kaidi-finance`。
+
+安装后执行以下命令确认应用、反向代理和首次登录信息:
+
+```bash
+curl -fsS http://127.0.0.1/actuator/health | jq -e '.status == "UP"'
+sudo systemctl --no-pager --full status kaidi-finance kaidi-update.path
+sudo cat /root/kaidi-first-login.txt
+```
+
+### Linux 32 位
+
+应用已按 Java 17 字节码构建,安装器会在 32 位 Linux 下载 `i686` JRE。MySQL 8.4 没有可用于该部署方式的
+32 位服务端镜像,因此 32 位主机需要预先连接一台 MySQL 8.4 数据库,之后仍然只执行一个安装命令:
+
+```bash
+(
+ set -Eeuo pipefail
+ RELEASE_TAG=v1.0.0-preview.8; INSTALLER_SHA256=33d4921bcad7ef2be12f20bc0512a5f6df4227ea6dd3b7e6225164077d9cd9e7; PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9
+ read -rsp 'Gitea 只读 Token: ' GITEA_READ_TOKEN; printf '\n'
+ token_file=$(mktemp); header_file=$(mktemp); installer=$(mktemp)
+ trap 'rm -f "$token_file" "$header_file" "$installer"' EXIT
+ printf '%s' "$GITEA_READ_TOKEN" > "$token_file"
+ printf 'Authorization: token %s\n' "$GITEA_READ_TOKEN" > "$header_file"
+ unset GITEA_READ_TOKEN; chmod 0600 "$token_file" "$header_file" "$installer"
+ curl --fail --silent --show-error --proto '=https' --tlsv1.2 --header "@$header_file" \
+ "https://git.awaioi.com/ERP-Team/kaidi/releases/download/$RELEASE_TAG/install.sh" -o "$installer"
+ printf '%s %s\n' "$INSTALLER_SHA256" "$installer" | sha256sum -c -
+ sudo env KAIDI_RELEASE_TOKEN_FILE="$token_file" KAIDI_RELEASE_PUBLIC_KEY_SHA256="$PUBLIC_KEY_SHA256" \
+ KAIDI_SETUP_WIZARD=true bash "$installer"
+)
+```
+
+32 位主机不能运行安装器自动创建的 MySQL 容器,因此在打开向导前,需要预先创建 `kaidi_finance`,并授予安装账号该库的
+DDL、DML 权限。向导会连接数据库、核验 MySQL 8.4.x 版本并用临时表验证权限,全部通过后 Flyway 才会建表。
+数据库管理员可在 MySQL 8.4 中按实际应用服务器地址执行以下基线 SQL:
+
+```sql
+CREATE DATABASE kaidi_finance CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
+CREATE USER 'kaidi'@'KAIDI_SERVER_IP' IDENTIFIED BY 'DB_PASSWORD';
+GRANT ALL PRIVILEGES ON kaidi_finance.* TO 'kaidi'@'KAIDI_SERVER_IP';
+```
+
+### 修复性重装
+
+正常升级统一使用后台“在线更新”。只有安装文件损坏且后台更新不可用时,才在原服务器执行修复性重装;
+安装器会优先读取向导完成后生成的 `/var/lib/kaidi/setup/application.env`,再回退到 `/etc/kaidi/kaidi.env`,保留数据库、字段加密密钥、
+管理员数据和运维人员新增的环境变量:
+
+```bash
+# 使用上方同一安装命令,把 REINSTALL=false 改成 REINSTALL=true。
+```
+
+重装失败会恢复原应用链接、Java 运行时、环境文件、systemd 单元和 Nginx 配置;脚本会明确报告回滚不完整,
+不会把恢复失败吞掉。
+
+如果安装器已完成但向导尚未提交,可使用同一命令同时设置 `REINSTALL=true` 和 `KAIDI_SETUP_WIZARD=true` 重新生成一次性安装码;
+该恢复路径只接受仍处于向导模式且未锁定的安装,正式模式不会被覆盖。
+
+### 停用并移除程序
+
+以下命令移除应用程序和服务,但保留 `/var/lib/kaidi`、`/var/lib/kaidi-update`、`/etc/kaidi` 以及数据库,
+便于审计、备份或重新安装。确认数据备份前不要删除这些保留目录或 MySQL 数据卷。
+安装器已经删除原 Nginx 默认站点;停用后需按该主机原有配置恢复或另行创建默认站点。
+
+```bash
+sudo systemctl disable --now kaidi-update.path kaidi-update.service kaidi-finance.service
+sudo rm -f /etc/systemd/system/kaidi-finance.service /etc/systemd/system/kaidi-update.service /etc/systemd/system/kaidi-update.path
+sudo rm -f /etc/nginx/conf.d/kaidi-finance.conf
+sudo systemctl daemon-reload
+sudo nginx -t && sudo systemctl reload nginx
+sudo rm -rf /opt/kaidi
+```
+
+## Release 与在线更新
+
+首次建立发布仓库时生成一次签名密钥:
+
+```bash
+./scripts/generate-release-key.sh release-signing-private.pem
+base64 < release-signing-private.pem | tr -d '\n'
+```
+
+将私钥的 Base64 内容保存为 Gitea Actions Secret `RELEASE_SIGNING_KEY_B64`,并将命令输出的公钥 SHA-256
+保存为 Gitea Actions Variable `KAIDI_RELEASE_PUBLIC_KEY_SHA256`。Gitea 内建 `GITEA_TOKEN` 只用于该工作流创建
+Release;生产服务器使用另一个只读 Token。私钥不得提交到 Git。`.gitea/workflows/release.yml` 要求
+act_runner 提供 `ubuntu-24.04` 标签,并在 tag 发布时执行后端、前端、OpenAPI、Shell、安装/更新和浏览器门禁。
+
+工作流先建立不可见草稿,再显式上传并核对 9 个资产的名称和大小,最后才发布为 `/releases/latest`。Gitea 的
+`latest` 会排除 `prerelease=true`,因此即使 tag 名含 `preview`,发布记录的 `prerelease` 也固定为 `false`;
+Preview 属性由 SemVer 版本名表达。之后推送 tag 即会构建、测试、签名并发布:
+
+```bash
+git tag v1.0.0-preview.8
+git push origin v1.0.0-preview.8
+```
+
+在线更新使用独立的 TDesign 页面:隔离的系统管理员进入“系统治理 → 系统更新”。权限与配置页只管理用户、角色、数据范围、表单模板和参数版本,不配置系统名称或域名。
+更新源由 root 在 `/etc/kaidi/update.env` 固定为私有 Gitea Latest Release API;页面和普通 API 都看不到 Token,
+也不能提交 URL、脚本或命令。更新流程固定为:
+
+1. 点击“获取更新”,后端先读取 Release 元数据,再自动排队 `DOWNLOAD`;root 更新器从 Gitea 下载 manifest、签名和应用包,执行 RSA、
+ SHA-256、版本、文件名和压缩包路径校验后缓存到 `/var/lib/kaidi-update/cache/`。业务服务不停机。
+3. 页面显示 `READY/等待重启` 后才出现“立即重启”;管理员点击后提交安装。未缓存或版本不一致
+ 的包不能进入安装。
+4. 安装请求持久领取到 `/var/lib/kaidi-update/processing`;进程或主机中断后由 systemd 恢复未完成事务。
+5. root 更新器重新验签和验哈希,确认 `mysqldump` 成功并生成权限为 `0600` 的备份,默认保留最近 5 份。
+6. 校验更新脚本和 systemd 单元后,原子切换 updater、systemd、Nginx 和应用版本。
+7. 同时检查后端直连、Nginx 健康端点、更新 path unit 和静态首页。全部通过后页面显示 10 秒倒计时并自动
+ 刷新;刷新或短暂断线发生在安装中时,页面会恢复 3 秒轮询。任一检查失败则恢复并验证上一版本。
+
+忙碌期间检查、下载和安装按钮保持禁用,防止重复请求;这就是更新执行冷却。10 秒只用于成功后的页面刷新,
+不会延迟服务端切换。systemd 在 300 秒内连续失败 3 次后停止自动重试,避免失败任务空跑。
+
+安装器会把 API 地址和只读 Token 同步写入 root-only 的 `/etc/kaidi/kaidi.env` 与
+`/etc/kaidi/update.env`,两者权限均为 `0600`:前者供 Java 后端“检查更新”读取,后者供 root 更新器下载资产。
+轮换 Token 时必须同时更新两个文件,再执行 `sudo systemctl restart kaidi-finance`;Token 不写入页面、状态 JSON、
+审计参数或更新日志。
+
+数据库迁移必须保持至少一个版本的向后兼容。查看状态和日志:
+
+```bash
+sudo systemctl status kaidi-finance kaidi-update.path
+sudo journalctl -u kaidi-update.service -n 100 --no-pager
+cat /var/lib/kaidi-update/status.json
+```
+
+如果 `status.json` 显示 `FAILED` 且日志提示回滚未完成,不要删除
+`/var/lib/kaidi-update/processing/request.json` 或活动事务目录。systemd 在 300 秒内连续失败 3 次后会停止自动重试,
+修复日志所示的磁盘、权限、Nginx 或旧版本健康问题后执行:
+
+```bash
+sudo systemctl reset-failed kaidi-update.service kaidi-update.path
+sudo systemctl start kaidi-update.service
+sudo journalctl -u kaidi-update.service -n 100 --no-pager
+cat /var/lib/kaidi-update/status.json
+```
+
+只有状态恢复为 `SUCCEEDED`、`CURRENT` 或确定性的终态 `FAILED`,且 `transactions/active` 已处理完成后,
+才算本次恢复结束。后台会保留真实失败状态,不会把待恢复的 processing 请求误显示成普通排队。
+
+## 手工生成 Release
+
+```bash
+KAIDI_RELEASE_SIGNING_KEY=/secure/release-signing-private.pem \
+KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \
+ ./scripts/package-release.sh 1.0.0-preview.8
+KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \
+ ./scripts/verify-release.sh dist/release
+```
+
+输出位于 `dist/release/`,包含安装脚本、签名清单、公钥、SHA-256 清单、前后端 CycloneDX SBOM、
+首次安装指纹文件和完整应用压缩包。
+打包脚本会把 Maven `revision` 与 npm 包版本临时绑定到 Release SemVer,构建结束后恢复工作区源文件;
+JAR、两个 SBOM、签名清单或 tag 的版本只要有一项不一致,发布即失败。
+签名清单同时绑定应用包、两份 SBOM、安装器、公钥、bootstrap 指纹和 Git 源码修订;本地脏工作区会明确记录
+`source.dirty=true`,tag 工作流只接受干净 checkout 并记录 `source.dirty=false`。
+发布命令同时在终端输出 `Trusted release public-key SHA-256` 与 `Installer SHA-256`;把这两个值写入
+受控部署记录,再替换上述一键安装命令中的占位符。
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
new file mode 100644
index 0000000..2398280
--- /dev/null
+++ b/THIRD_PARTY_NOTICES.md
@@ -0,0 +1,12 @@
+# Third-Party Notices
+
+## TDesign Vue Next Starter
+
+- Project: `Tencent/tdesign-vue-next-starter`
+- Source: https://github.com/Tencent/tdesign-vue-next-starter
+- Baseline commit: `1f183fa089d07183235dc69dbd76b8b2c4a6d8bb`
+- Imported: 2026-08-07
+- License: MIT
+- Local license file: `frontend/LICENSE`
+
+The upstream source is used as the frontend application starter and will be modified for this project's authentication, authorization, workflows, pages, and deployment requirements.
diff --git a/backend/.mvn/wrapper/maven-wrapper.properties b/backend/.mvn/wrapper/maven-wrapper.properties
new file mode 100644
index 0000000..216df05
--- /dev/null
+++ b/backend/.mvn/wrapper/maven-wrapper.properties
@@ -0,0 +1,3 @@
+wrapperVersion=3.3.4
+distributionType=only-script
+distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip
diff --git a/backend/mvnw b/backend/mvnw
new file mode 100755
index 0000000..bd8896b
--- /dev/null
+++ b/backend/mvnw
@@ -0,0 +1,295 @@
+#!/bin/sh
+# ----------------------------------------------------------------------------
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+# ----------------------------------------------------------------------------
+
+# ----------------------------------------------------------------------------
+# Apache Maven Wrapper startup batch script, version 3.3.4
+#
+# Optional ENV vars
+# -----------------
+# JAVA_HOME - location of a JDK home dir, required when download maven via java source
+# MVNW_REPOURL - repo url base for downloading maven distribution
+# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
+# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
+# ----------------------------------------------------------------------------
+
+set -euf
+[ "${MVNW_VERBOSE-}" != debug ] || set -x
+
+# OS specific support.
+native_path() { printf %s\\n "$1"; }
+case "$(uname)" in
+CYGWIN* | MINGW*)
+ [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
+ native_path() { cygpath --path --windows "$1"; }
+ ;;
+esac
+
+# set JAVACMD and JAVACCMD
+set_java_home() {
+ # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
+ if [ -n "${JAVA_HOME-}" ]; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ]; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ JAVACCMD="$JAVA_HOME/jre/sh/javac"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ JAVACCMD="$JAVA_HOME/bin/javac"
+
+ if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
+ echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
+ echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
+ return 1
+ fi
+ fi
+ else
+ JAVACMD="$(
+ 'set' +e
+ 'unset' -f command 2>/dev/null
+ 'command' -v java
+ )" || :
+ JAVACCMD="$(
+ 'set' +e
+ 'unset' -f command 2>/dev/null
+ 'command' -v javac
+ )" || :
+
+ if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
+ echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
+ return 1
+ fi
+ fi
+}
+
+# hash string like Java String::hashCode
+hash_string() {
+ str="${1:-}" h=0
+ while [ -n "$str" ]; do
+ char="${str%"${str#?}"}"
+ h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
+ str="${str#?}"
+ done
+ printf %x\\n $h
+}
+
+verbose() { :; }
+[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
+
+die() {
+ printf %s\\n "$1" >&2
+ exit 1
+}
+
+trim() {
+ # MWRAPPER-139:
+ # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
+ # Needed for removing poorly interpreted newline sequences when running in more
+ # exotic environments such as mingw bash on Windows.
+ printf "%s" "${1}" | tr -d '[:space:]'
+}
+
+scriptDir="$(dirname "$0")"
+scriptName="$(basename "$0")"
+
+# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
+while IFS="=" read -r key value; do
+ case "${key-}" in
+ distributionUrl) distributionUrl=$(trim "${value-}") ;;
+ distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
+ esac
+done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
+[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
+
+case "${distributionUrl##*/}" in
+maven-mvnd-*bin.*)
+ MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
+ case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
+ *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
+ :Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
+ :Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
+ :Linux*x86_64*) distributionPlatform=linux-amd64 ;;
+ *)
+ echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
+ distributionPlatform=linux-amd64
+ ;;
+ esac
+ distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
+ ;;
+maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
+*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
+esac
+
+# apply MVNW_REPOURL and calculate MAVEN_HOME
+# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/
+[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
+distributionUrlName="${distributionUrl##*/}"
+distributionUrlNameMain="${distributionUrlName%.*}"
+distributionUrlNameMain="${distributionUrlNameMain%-bin}"
+MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
+MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
+
+exec_maven() {
+ unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
+ exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
+}
+
+if [ -d "$MAVEN_HOME" ]; then
+ verbose "found existing MAVEN_HOME at $MAVEN_HOME"
+ exec_maven "$@"
+fi
+
+case "${distributionUrl-}" in
+*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
+*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
+esac
+
+# prepare tmp dir
+if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
+ clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
+ trap clean HUP INT TERM EXIT
+else
+ die "cannot create temp dir"
+fi
+
+mkdir -p -- "${MAVEN_HOME%/*}"
+
+# Download and Install Apache Maven
+verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
+verbose "Downloading from: $distributionUrl"
+verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
+
+# select .zip or .tar.gz
+if ! command -v unzip >/dev/null; then
+ distributionUrl="${distributionUrl%.zip}.tar.gz"
+ distributionUrlName="${distributionUrl##*/}"
+fi
+
+# verbose opt
+__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
+[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
+
+# normalize http auth
+case "${MVNW_PASSWORD:+has-password}" in
+'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
+has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
+esac
+
+if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
+ verbose "Found wget ... using wget"
+ wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
+elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
+ verbose "Found curl ... using curl"
+ curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
+elif set_java_home; then
+ verbose "Falling back to use Java to download"
+ javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
+ targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
+ cat >"$javaSource" <<-END
+ public class Downloader extends java.net.Authenticator
+ {
+ protected java.net.PasswordAuthentication getPasswordAuthentication()
+ {
+ return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
+ }
+ public static void main( String[] args ) throws Exception
+ {
+ setDefault( new Downloader() );
+ java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
+ }
+ }
+ END
+ # For Cygwin/MinGW, switch paths to Windows format before running javac and java
+ verbose " - Compiling Downloader.java ..."
+ "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
+ verbose " - Running Downloader.java ..."
+ "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
+fi
+
+# If specified, validate the SHA-256 sum of the Maven distribution zip file
+if [ -n "${distributionSha256Sum-}" ]; then
+ distributionSha256Result=false
+ if [ "$MVN_CMD" = mvnd.sh ]; then
+ echo "Checksum validation is not supported for maven-mvnd." >&2
+ echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
+ exit 1
+ elif command -v sha256sum >/dev/null; then
+ if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
+ distributionSha256Result=true
+ fi
+ elif command -v shasum >/dev/null; then
+ if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
+ distributionSha256Result=true
+ fi
+ else
+ echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
+ echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
+ exit 1
+ fi
+ if [ $distributionSha256Result = false ]; then
+ echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
+ echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
+ exit 1
+ fi
+fi
+
+# unzip and move
+if command -v unzip >/dev/null; then
+ unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
+else
+ tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
+fi
+
+# Find the actual extracted directory name (handles snapshots where filename != directory name)
+actualDistributionDir=""
+
+# First try the expected directory name (for regular distributions)
+if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
+ if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
+ actualDistributionDir="$distributionUrlNameMain"
+ fi
+fi
+
+# If not found, search for any directory with the Maven executable (for snapshots)
+if [ -z "$actualDistributionDir" ]; then
+ # enable globbing to iterate over items
+ set +f
+ for dir in "$TMP_DOWNLOAD_DIR"/*; do
+ if [ -d "$dir" ]; then
+ if [ -f "$dir/bin/$MVN_CMD" ]; then
+ actualDistributionDir="$(basename "$dir")"
+ break
+ fi
+ fi
+ done
+ set -f
+fi
+
+if [ -z "$actualDistributionDir" ]; then
+ verbose "Contents of $TMP_DOWNLOAD_DIR:"
+ verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
+ die "Could not find Maven distribution directory in extracted archive"
+fi
+
+verbose "Found extracted Maven distribution directory: $actualDistributionDir"
+printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
+mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
+
+clean || :
+exec_maven "$@"
diff --git a/backend/mvnw.cmd b/backend/mvnw.cmd
new file mode 100644
index 0000000..5761d94
--- /dev/null
+++ b/backend/mvnw.cmd
@@ -0,0 +1,189 @@
+<# : batch portion
+@REM ----------------------------------------------------------------------------
+@REM Licensed to the Apache Software Foundation (ASF) under one
+@REM or more contributor license agreements. See the NOTICE file
+@REM distributed with this work for additional information
+@REM regarding copyright ownership. The ASF licenses this file
+@REM to you under the Apache License, Version 2.0 (the
+@REM "License"); you may not use this file except in compliance
+@REM with the License. You may obtain a copy of the License at
+@REM
+@REM http://www.apache.org/licenses/LICENSE-2.0
+@REM
+@REM Unless required by applicable law or agreed to in writing,
+@REM software distributed under the License is distributed on an
+@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+@REM KIND, either express or implied. See the License for the
+@REM specific language governing permissions and limitations
+@REM under the License.
+@REM ----------------------------------------------------------------------------
+
+@REM ----------------------------------------------------------------------------
+@REM Apache Maven Wrapper startup batch script, version 3.3.4
+@REM
+@REM Optional ENV vars
+@REM MVNW_REPOURL - repo url base for downloading maven distribution
+@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
+@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
+@REM ----------------------------------------------------------------------------
+
+@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
+@SET __MVNW_CMD__=
+@SET __MVNW_ERROR__=
+@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
+@SET PSModulePath=
+@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
+ IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
+)
+@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
+@SET __MVNW_PSMODULEP_SAVE=
+@SET __MVNW_ARG0_NAME__=
+@SET MVNW_USERNAME=
+@SET MVNW_PASSWORD=
+@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
+@echo Cannot start maven from wrapper >&2 && exit /b 1
+@GOTO :EOF
+: end batch / begin powershell #>
+
+$ErrorActionPreference = "Stop"
+if ($env:MVNW_VERBOSE -eq "true") {
+ $VerbosePreference = "Continue"
+}
+
+# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
+$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
+if (!$distributionUrl) {
+ Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
+}
+
+switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
+ "maven-mvnd-*" {
+ $USE_MVND = $true
+ $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
+ $MVN_CMD = "mvnd.cmd"
+ break
+ }
+ default {
+ $USE_MVND = $false
+ $MVN_CMD = $script -replace '^mvnw','mvn'
+ break
+ }
+}
+
+# apply MVNW_REPOURL and calculate MAVEN_HOME
+# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/
+if ($env:MVNW_REPOURL) {
+ $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
+ $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
+}
+$distributionUrlName = $distributionUrl -replace '^.*/',''
+$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
+
+$MAVEN_M2_PATH = "$HOME/.m2"
+if ($env:MAVEN_USER_HOME) {
+ $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
+}
+
+if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
+ New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
+}
+
+$MAVEN_WRAPPER_DISTS = $null
+if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
+ $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
+} else {
+ $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
+}
+
+$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
+$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
+$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
+
+if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
+ Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
+ Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
+ exit $?
+}
+
+if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
+ Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
+}
+
+# prepare tmp dir
+$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
+$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
+$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
+trap {
+ if ($TMP_DOWNLOAD_DIR.Exists) {
+ try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
+ catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
+ }
+}
+
+New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
+
+# Download and Install Apache Maven
+Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
+Write-Verbose "Downloading from: $distributionUrl"
+Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
+
+$webclient = New-Object System.Net.WebClient
+if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
+ $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
+}
+[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
+$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
+
+# If specified, validate the SHA-256 sum of the Maven distribution zip file
+$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
+if ($distributionSha256Sum) {
+ if ($USE_MVND) {
+ Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
+ }
+ Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
+ if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
+ Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
+ }
+}
+
+# unzip and move
+Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
+
+# Find the actual extracted directory name (handles snapshots where filename != directory name)
+$actualDistributionDir = ""
+
+# First try the expected directory name (for regular distributions)
+$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
+$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
+if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
+ $actualDistributionDir = $distributionUrlNameMain
+}
+
+# If not found, search for any directory with the Maven executable (for snapshots)
+if (!$actualDistributionDir) {
+ Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
+ $testPath = Join-Path $_.FullName "bin/$MVN_CMD"
+ if (Test-Path -Path $testPath -PathType Leaf) {
+ $actualDistributionDir = $_.Name
+ }
+ }
+}
+
+if (!$actualDistributionDir) {
+ Write-Error "Could not find Maven distribution directory in extracted archive"
+}
+
+Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
+Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
+try {
+ Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
+} catch {
+ if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
+ Write-Error "fail to move MAVEN_HOME"
+ }
+} finally {
+ try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
+ catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
+}
+
+Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
diff --git a/backend/pom.xml b/backend/pom.xml
new file mode 100644
index 0000000..519450f
--- /dev/null
+++ b/backend/pom.xml
@@ -0,0 +1,186 @@
+
+
+ 4.0.0
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 3.5.16
+
+
+
+ com.kaidi
+ finance-system
+ ${revision}
+ Kaidi Finance System
+ Project finance workflow, accounting preparation and archive system
+
+
+ 1.0.0-SNAPSHOT
+ com.kaidi.finance.FinanceApplication
+ 17
+ 3.0.5
+ 11.20.3
+ 2.8.14
+ 1.21.4
+ 1.4.1
+ 1.80
+ 3.0.6
+ 2.9.3
+
+
+
+
+
+ org.testcontainers
+ testcontainers-bom
+ ${testcontainers.version}
+ pom
+ import
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+ org.springframework.boot
+ spring-boot-starter-validation
+
+
+ org.springframework.boot
+ spring-boot-starter-security
+
+
+ org.springframework.session
+ spring-session-jdbc
+
+
+ org.springframework.boot
+ spring-boot-starter-actuator
+
+
+ org.mybatis.spring.boot
+ mybatis-spring-boot-starter
+ ${mybatis-spring-boot.version}
+
+
+ org.flywaydb
+ flyway-core
+
+
+ org.flywaydb
+ flyway-mysql
+
+
+ com.mysql
+ mysql-connector-j
+ runtime
+
+
+ org.bouncycastle
+ bcprov-jdk18on
+ ${bouncycastle.version}
+
+
+ org.apache.pdfbox
+ pdfbox
+ ${pdfbox.version}
+
+
+ org.springdoc
+ springdoc-openapi-starter-webmvc-ui
+ ${springdoc.version}
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+ org.springframework.security
+ spring-security-test
+ test
+
+
+ org.testcontainers
+ junit-jupiter
+ test
+
+
+ org.testcontainers
+ mysql
+ test
+
+
+ com.tngtech.archunit
+ archunit-junit5
+ ${archunit.version}
+ test
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+ org.apache.maven.plugins
+ maven-enforcer-plugin
+ 3.6.2
+
+
+ enforce-java
+ enforce
+
+
+ [17,22)
+
+
+
+
+
+
+ org.jacoco
+ jacoco-maven-plugin
+ 0.8.13
+
+ prepare-agent
+ reportverifyreport
+
+
+
+ org.cyclonedx
+ cyclonedx-maven-plugin
+ ${cyclonedx.version}
+
+
+ make-runtime-sbom
+ package
+ makeAggregateBom
+
+ application
+ 1.6
+ true
+ true
+ true
+ true
+ true
+ false
+ json
+ backend-sbom
+
+
+
+
+
+
+
diff --git a/backend/src/main/java/com/kaidi/finance/FinanceApplication.java b/backend/src/main/java/com/kaidi/finance/FinanceApplication.java
new file mode 100644
index 0000000..f938809
--- /dev/null
+++ b/backend/src/main/java/com/kaidi/finance/FinanceApplication.java
@@ -0,0 +1,36 @@
+package com.kaidi.finance;
+
+import com.kaidi.finance.iam.application.BootstrapProperties;
+import com.kaidi.finance.shared.file.FileScannerProperties;
+import com.kaidi.finance.shared.file.FileStorageProperties;
+import com.kaidi.finance.update.application.SystemUpdateProperties;
+import com.kaidi.setup.SetupApplication;
+import java.util.Arrays;
+import org.mybatis.spring.annotation.MapperScan;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+
+@SpringBootApplication(exclude = UserDetailsServiceAutoConfiguration.class)
+@MapperScan(basePackages = "com.kaidi.finance", annotationClass = org.apache.ibatis.annotations.Mapper.class)
+@EnableConfigurationProperties({BootstrapProperties.class, FileStorageProperties.class, FileScannerProperties.class,
+ SystemUpdateProperties.class})
+public class FinanceApplication {
+
+ public static void main(String[] args) {
+ if (setupModeEnabled(args)) {
+ SetupApplication.main(args);
+ return;
+ }
+ SpringApplication.run(FinanceApplication.class, args);
+ }
+
+ private static boolean setupModeEnabled(String[] args) {
+ if (Boolean.parseBoolean(System.getenv().getOrDefault("FINANCE_SETUP_ENABLED", "false"))) {
+ return true;
+ }
+ return Arrays.stream(args).anyMatch(argument -> "--finance.setup.enabled=true".equals(argument)
+ || "--FINANCE_SETUP_ENABLED=true".equals(argument));
+ }
+}
diff --git a/backend/src/main/java/com/kaidi/finance/accounting/api/AccountingContracts.java b/backend/src/main/java/com/kaidi/finance/accounting/api/AccountingContracts.java
new file mode 100644
index 0000000..e48e8d0
--- /dev/null
+++ b/backend/src/main/java/com/kaidi/finance/accounting/api/AccountingContracts.java
@@ -0,0 +1,92 @@
+package com.kaidi.finance.accounting.api;
+
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.DecimalMin;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotEmpty;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Pattern;
+import jakarta.validation.constraints.Size;
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.util.List;
+
+public final class AccountingContracts {
+
+ private AccountingContracts() {
+ }
+
+ public record GenerateDraftRequest(
+ @NotNull Long version,
+ @NotBlank @Size(max = 64) String ruleVersion
+ ) {
+ }
+
+ public record VoucherEntryRequest(
+ @NotNull Integer lineNo,
+ @NotBlank @Pattern(regexp = "DEBIT|CREDIT") String direction,
+ @NotBlank @Size(max = 26) String accountId,
+ @NotNull @DecimalMin("0.01") BigDecimal amount,
+ @NotBlank @Size(max = 500) String summary,
+ @Size(max = 4000) String auxiliaryJson
+ ) {
+ }
+
+ public record VoucherUpdateRequest(
+ @NotNull Long version,
+ @NotBlank @Pattern(regexp = "\\d{4}-\\d{2}") String period,
+ @NotNull LocalDate businessDate,
+ @NotBlank @Size(max = 500) String summary,
+ @NotEmpty @Size(max = 100) List<@Valid VoucherEntryRequest> entries,
+ @NotBlank @Size(max = 1000) String changeReason
+ ) {
+ }
+
+ public record VersionCommandRequest(@NotNull Long version) {
+ }
+
+ public record ReturnRequest(
+ @NotNull Long version,
+ @NotBlank @Size(max = 1000) String opinion
+ ) {
+ }
+
+ public record ResultRequest(
+ @NotNull Long version,
+ @NotBlank @Size(max = 100) String externalVoucherNo,
+ @NotNull LocalDate resultAt,
+ @NotEmpty @Size(max = 20) List<@NotBlank @Size(max = 26) String> evidenceFileIds,
+ @Size(max = 1000) String remark
+ ) {
+ }
+
+ public record VerifyResultRequest(
+ @NotNull Long version,
+ @NotBlank @Size(min = 2, max = 1000)
+ @Pattern(regexp = "(?s).*\\S.*\\S.*", message = "must contain at least 2 non-whitespace characters")
+ String opinion
+ ) {
+ }
+
+ public record ReverseResultRequest(
+ @NotNull Long version,
+ @NotBlank @Size(min = 2, max = 1000)
+ @Pattern(regexp = "(?s).*\\S.*\\S.*", message = "must contain at least 2 non-whitespace characters")
+ String reason
+ ) {
+ }
+
+ public record ReopenResultRequest(
+ @NotNull Long version,
+ @NotBlank @Size(min = 2, max = 1000)
+ @Pattern(regexp = "(?s).*\\S.*\\S.*", message = "must contain at least 2 non-whitespace characters")
+ String reason
+ ) {
+ }
+
+ public record VoidRequest(
+ @NotNull Long version,
+ @NotBlank @Size(max = 1000) String reason
+ ) {
+ }
+}
diff --git a/backend/src/main/java/com/kaidi/finance/accounting/api/AccountingController.java b/backend/src/main/java/com/kaidi/finance/accounting/api/AccountingController.java
new file mode 100644
index 0000000..f812c16
--- /dev/null
+++ b/backend/src/main/java/com/kaidi/finance/accounting/api/AccountingController.java
@@ -0,0 +1,262 @@
+package com.kaidi.finance.accounting.api;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.kaidi.finance.accounting.api.AccountingContracts.GenerateDraftRequest;
+import com.kaidi.finance.accounting.api.AccountingContracts.ResultRequest;
+import com.kaidi.finance.accounting.api.AccountingContracts.ReopenResultRequest;
+import com.kaidi.finance.accounting.api.AccountingContracts.ReverseResultRequest;
+import com.kaidi.finance.accounting.api.AccountingContracts.ReturnRequest;
+import com.kaidi.finance.accounting.api.AccountingContracts.VerifyResultRequest;
+import com.kaidi.finance.accounting.api.AccountingContracts.VersionCommandRequest;
+import com.kaidi.finance.accounting.api.AccountingContracts.VoidRequest;
+import com.kaidi.finance.accounting.api.AccountingContracts.VoucherUpdateRequest;
+import com.kaidi.finance.accounting.api.AccountingViews.AccountView;
+import com.kaidi.finance.accounting.api.AccountingViews.AccountingEventView;
+import com.kaidi.finance.accounting.api.AccountingViews.VoucherDetailView;
+import com.kaidi.finance.accounting.api.AccountingViews.VoucherExportView;
+import com.kaidi.finance.accounting.api.AccountingViews.VoucherView;
+import com.kaidi.finance.accounting.application.AccountingApplicationService;
+import com.kaidi.finance.shared.api.ApiResponse;
+import com.kaidi.finance.shared.api.PageResult;
+import com.kaidi.finance.shared.file.FileApplicationService.FileDownload;
+import com.kaidi.finance.shared.idempotency.IdempotencyApplicationService;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.validation.Valid;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import io.swagger.v3.oas.annotations.Operation;
+import org.springframework.core.io.Resource;
+import org.springframework.http.ContentDisposition;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PatchMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/api/v1/accounting")
+public class AccountingController {
+
+ private final AccountingApplicationService service;
+ private final IdempotencyApplicationService idempotencyService;
+
+ public AccountingController(AccountingApplicationService service,
+ IdempotencyApplicationService idempotencyService) {
+ this.service = service;
+ this.idempotencyService = idempotencyService;
+ }
+
+ @GetMapping("/accounts")
+ @Operation(operationId = "listAccountingAccounts", summary = "查询会计科目")
+ @PreAuthorize("@authorizationService.hasPermission('accounting:voucher:view')")
+ public ApiResponse> accounts() {
+ return ApiResponse.ok(service.accounts());
+ }
+
+ @GetMapping("/events")
+ @Operation(operationId = "listAccountingEvents", summary = "查询财务事件")
+ @PreAuthorize("@authorizationService.hasPermission('accounting:event:view')")
+ public ApiResponse> events(
+ @RequestParam(required = false) String period,
+ @RequestParam(required = false) String projectId,
+ @RequestParam(required = false) String eventType,
+ @RequestParam(required = false) String keyword,
+ @RequestParam(defaultValue = "businessDate,asc") String sort,
+ @RequestParam(defaultValue = "1") int page,
+ @RequestParam(defaultValue = "20") int size) {
+ PageResult result = service.listEvents(period, projectId, eventType, keyword, sort,
+ page, size);
+ return ApiResponse.ok(result.items(), result.meta());
+ }
+
+ @GetMapping("/vouchers")
+ @Operation(operationId = "listVouchers", summary = "查询凭证草稿")
+ @PreAuthorize("@authorizationService.hasPermission('accounting:voucher:view')")
+ public ApiResponse> vouchers(
+ @RequestParam(required = false) String tab,
+ @RequestParam(required = false) String period,
+ @RequestParam(required = false) String projectId,
+ @RequestParam(required = false) String eventType,
+ @RequestParam(required = false) String status,
+ @RequestParam(required = false) String externalVoucherNo,
+ @RequestParam(required = false) String keyword,
+ @RequestParam(defaultValue = "updatedAt,desc") String sort,
+ @RequestParam(defaultValue = "1") int page,
+ @RequestParam(defaultValue = "20") int size) {
+ PageResult result = service.listVouchers(tab, period, projectId, eventType, status,
+ externalVoucherNo, keyword, sort, page, size);
+ return ApiResponse.ok(result.items(), result.meta());
+ }
+
+ @GetMapping("/vouchers/{publicId}")
+ @Operation(operationId = "getVoucher", summary = "查询凭证详情")
+ @PreAuthorize("@authorizationService.hasPermission('accounting:voucher:view')")
+ public ApiResponse detail(@PathVariable String publicId) {
+ return ApiResponse.ok(service.getVoucher(publicId));
+ }
+
+ @PostMapping("/events/{publicId}/generate-draft")
+ @Operation(operationId = "generateVoucherDraft", summary = "生成凭证草稿")
+ @PreAuthorize("@authorizationService.hasPermission('accounting:event:generate')")
+ public ApiResponse generateDraft(@PathVariable String publicId,
+ @Valid @RequestBody GenerateDraftRequest request,
+ @RequestHeader(value = "Idempotency-Key", required = false) String key,
+ HttpServletRequest httpRequest) {
+ return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { },
+ () -> service.generateDraft(publicId, request)));
+ }
+
+ @PatchMapping("/vouchers/{publicId}")
+ @Operation(operationId = "updateVoucher", summary = "修改凭证草稿")
+ @PreAuthorize("@authorizationService.hasPermission('accounting:voucher:edit')")
+ public ApiResponse update(@PathVariable String publicId,
+ @Valid @RequestBody VoucherUpdateRequest request,
+ @RequestHeader(value = "Idempotency-Key", required = false) String key,
+ HttpServletRequest httpRequest) {
+ return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { },
+ () -> service.updateVoucher(publicId, request)));
+ }
+
+ @PostMapping("/vouchers/{publicId}/submit")
+ @Operation(operationId = "submitVoucher", summary = "提交凭证复核")
+ @PreAuthorize("@authorizationService.hasPermission('accounting:voucher:edit')")
+ public ApiResponse submit(@PathVariable String publicId,
+ @Valid @RequestBody VersionCommandRequest request,
+ @RequestHeader(value = "Idempotency-Key", required = false) String key,
+ HttpServletRequest httpRequest) {
+ return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { },
+ () -> service.submitVoucher(publicId, request.version())));
+ }
+
+ @PostMapping("/vouchers/{publicId}/approve")
+ @Operation(operationId = "approveVoucher", summary = "复核通过凭证")
+ @PreAuthorize("@authorizationService.hasPermission('accounting:voucher:review')")
+ public ApiResponse approve(@PathVariable String publicId,
+ @Valid @RequestBody VersionCommandRequest request,
+ @RequestHeader(value = "Idempotency-Key", required = false) String key,
+ HttpServletRequest httpRequest) {
+ return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { },
+ () -> service.approveVoucher(publicId, request.version())));
+ }
+
+ @PostMapping("/vouchers/{publicId}/return")
+ @Operation(operationId = "returnVoucher", summary = "退回凭证")
+ @PreAuthorize("@authorizationService.hasPermission('accounting:voucher:review')")
+ public ApiResponse returnVoucher(@PathVariable String publicId,
+ @Valid @RequestBody ReturnRequest request,
+ @RequestHeader(value = "Idempotency-Key", required = false) String key,
+ HttpServletRequest httpRequest) {
+ return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { },
+ () -> service.returnVoucher(publicId, request)));
+ }
+
+ @PostMapping("/vouchers/{publicId}/export")
+ @Operation(operationId = "exportVoucher", summary = "导出凭证")
+ @PreAuthorize("@authorizationService.hasPermission('accounting:voucher:export')")
+ public ApiResponse export(@PathVariable String publicId,
+ @Valid @RequestBody VersionCommandRequest request,
+ @RequestHeader(value = "Idempotency-Key", required = false) String key,
+ HttpServletRequest httpRequest) {
+ return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { },
+ () -> service.exportVoucher(publicId, request.version())));
+ }
+
+ @GetMapping("/vouchers/{publicId}/export-file")
+ @Operation(operationId = "downloadVoucherExport", summary = "下载凭证导出文件")
+ @PreAuthorize("@authorizationService.hasPermission('accounting:voucher:export')")
+ public ResponseEntity exportFile(@PathVariable String publicId) {
+ FileDownload download = service.downloadExport(publicId);
+ return downloadResponse(download);
+ }
+
+ @GetMapping("/vouchers/{voucherPublicId}/result-files/{filePublicId}/content")
+ @Operation(operationId = "downloadVoucherResultEvidence", summary = "下载凭证结果附件")
+ @PreAuthorize("@authorizationService.hasPermission('accounting:voucher:view')")
+ public ResponseEntity resultEvidenceFile(@PathVariable String voucherPublicId,
+ @PathVariable String filePublicId) {
+ FileDownload download = service.downloadResultEvidence(voucherPublicId, filePublicId);
+ return downloadResponse(download);
+ }
+
+ private ResponseEntity downloadResponse(FileDownload download) {
+ var metadata = download.metadata();
+ ContentDisposition disposition = ContentDisposition.attachment()
+ .filename(metadata.getOriginalName(), StandardCharsets.UTF_8).build();
+ return ResponseEntity.ok()
+ .contentType(MediaType.parseMediaType(metadata.getMediaType()))
+ .contentLength(metadata.getSizeBytes())
+ .header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString())
+ .header(HttpHeaders.CACHE_CONTROL, "private, no-store")
+ .header("X-File-SHA256", metadata.getSha256())
+ .header("X-Content-Type-Options", "nosniff")
+ .body(download.resource());
+ }
+
+ @PostMapping("/vouchers/{publicId}/record-result")
+ @Operation(operationId = "recordVoucherResult", summary = "登记人工记账结果")
+ @PreAuthorize("@authorizationService.hasPermission('accounting:voucher:record-result')")
+ public ApiResponse recordResult(@PathVariable String publicId,
+ @Valid @RequestBody ResultRequest request,
+ @RequestHeader(value = "Idempotency-Key", required = false) String key,
+ HttpServletRequest httpRequest) {
+ return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { },
+ () -> service.recordResult(publicId, request)));
+ }
+
+ @PostMapping("/vouchers/{publicId}/verify-result")
+ @Operation(operationId = "verifyVoucherResult", summary = "复核人工记账结果")
+ @PreAuthorize("@authorizationService.hasPermission('accounting:voucher:verify-result')")
+ public ApiResponse verifyResult(@PathVariable String publicId,
+ @Valid @RequestBody VerifyResultRequest request,
+ @RequestHeader(value = "Idempotency-Key", required = false) String key,
+ HttpServletRequest httpRequest) {
+ return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { },
+ () -> service.verifyResult(publicId, request)));
+ }
+
+ @PostMapping("/vouchers/{publicId}/reverse-result")
+ @Operation(operationId = "reverseVoucherResult", summary = "冲销已复核人工记账结果")
+ @PreAuthorize("@authorizationService.hasPermission('accounting:voucher:reverse-result')")
+ public ApiResponse reverseResult(@PathVariable String publicId,
+ @Valid @RequestBody ReverseResultRequest request,
+ @RequestHeader(value = "Idempotency-Key", required = false) String key,
+ HttpServletRequest httpRequest) {
+ return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { },
+ () -> service.reverseResult(publicId, request)));
+ }
+
+ @PostMapping("/vouchers/{publicId}/reopen-result")
+ @Operation(operationId = "reopenVoucherResult", summary = "重开已冲销人工记账结果")
+ @PreAuthorize("@authorizationService.hasPermission('accounting:voucher:reopen-result')")
+ public ApiResponse reopenResult(@PathVariable String publicId,
+ @Valid @RequestBody ReopenResultRequest request,
+ @RequestHeader(value = "Idempotency-Key", required = false) String key,
+ HttpServletRequest httpRequest) {
+ return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { },
+ () -> service.reopenResult(publicId, request)));
+ }
+
+ @PostMapping("/vouchers/{publicId}/void")
+ @Operation(operationId = "voidVoucher", summary = "作废凭证")
+ @PreAuthorize("@authorizationService.hasPermission('accounting:voucher:void')")
+ public ApiResponse voidVoucher(@PathVariable String publicId,
+ @Valid @RequestBody VoidRequest request,
+ @RequestHeader(value = "Idempotency-Key", required = false) String key,
+ HttpServletRequest httpRequest) {
+ return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { },
+ () -> service.voidVoucher(publicId, request)));
+ }
+
+ private T command(String key, HttpServletRequest request, Object body, TypeReference type,
+ java.util.function.Supplier action) {
+ return idempotencyService.execute(key, request.getMethod(), request.getRequestURI(), body, type, action);
+ }
+}
diff --git a/backend/src/main/java/com/kaidi/finance/accounting/api/AccountingViews.java b/backend/src/main/java/com/kaidi/finance/accounting/api/AccountingViews.java
new file mode 100644
index 0000000..f651c00
--- /dev/null
+++ b/backend/src/main/java/com/kaidi/finance/accounting/api/AccountingViews.java
@@ -0,0 +1,149 @@
+package com.kaidi.finance.accounting.api;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.List;
+
+public final class AccountingViews {
+
+ private AccountingViews() {
+ }
+
+ public record ReferenceView(String publicId, String code, String name) {
+ }
+
+ public record AccountView(
+ String publicId,
+ String code,
+ String name,
+ String accountType,
+ boolean auxiliaryRequired
+ ) {
+ }
+
+ public record AccountingEventView(
+ String publicId,
+ String businessNo,
+ String eventType,
+ String sourceType,
+ String sourcePublicId,
+ String sourceBusinessNo,
+ String sourceVersion,
+ int eventSequence,
+ String sourceEventKey,
+ ReferenceView company,
+ ReferenceView project,
+ ReferenceView counterparty,
+ LocalDate businessDate,
+ String period,
+ String amount,
+ String currency,
+ String status,
+ String voucherPublicId,
+ String voucherStatus,
+ long version,
+ LocalDateTime createdAt,
+ List allowedActions
+ ) {
+ }
+
+ public record VoucherEntryView(
+ String publicId,
+ int lineNo,
+ String direction,
+ ReferenceView account,
+ String amount,
+ String summary,
+ String auxiliaryJson
+ ) {
+ }
+
+ public record VoucherView(
+ String publicId,
+ String businessNo,
+ String eventPublicId,
+ String eventBusinessNo,
+ ReferenceView company,
+ ReferenceView project,
+ String period,
+ LocalDate businessDate,
+ String summary,
+ String debitTotal,
+ String creditTotal,
+ String balanceStatus,
+ String status,
+ String submittedByName,
+ String reviewedByName,
+ LocalDateTime exportedAt,
+ String exportBatchNo,
+ String exportSha256,
+ String exportFileId,
+ String ruleVersion,
+ String externalVoucherNo,
+ LocalDate resultAt,
+ int resultCycleNo,
+ List evidenceFileIds,
+ String resultRecordedByName,
+ LocalDateTime resultRecordedAt,
+ String resultRecordRemark,
+ String resultVerifiedByName,
+ LocalDateTime resultVerifiedAt,
+ String resultVerifyOpinion,
+ String voidReason,
+ long version,
+ LocalDateTime createdAt,
+ LocalDateTime updatedAt,
+ List allowedActions
+ ) {
+ }
+
+ public record VoucherDetailView(
+ VoucherView voucher,
+ AccountingEventView event,
+ List entries,
+ List evidenceFiles,
+ List resultActions
+ ) {
+ }
+
+ public record ResultEvidenceFileView(
+ String publicId,
+ String originalName,
+ String mediaType,
+ long sizeBytes,
+ String sha256,
+ String scanStatus
+ ) {
+ }
+
+ public record VoucherResultActionView(
+ String publicId,
+ int sequenceNo,
+ int resultCycleNo,
+ String actionType,
+ String reason,
+ String externalVoucherNo,
+ LocalDate resultAt,
+ String resultRecordedByName,
+ LocalDateTime resultRecordedAt,
+ String resultRecordRemark,
+ String resultVerifiedByName,
+ LocalDateTime resultVerifiedAt,
+ String resultVerifyOpinion,
+ List evidenceFiles,
+ String operatedByName,
+ LocalDateTime operatedAt,
+ long versionBefore,
+ long versionAfter
+ ) {
+ }
+
+ public record VoucherExportView(
+ VoucherView voucher,
+ String batchNo,
+ String sha256,
+ String fileId,
+ int rowCount
+ ) {
+ }
+}
diff --git a/backend/src/main/java/com/kaidi/finance/accounting/application/AccountingApplicationService.java b/backend/src/main/java/com/kaidi/finance/accounting/application/AccountingApplicationService.java
new file mode 100644
index 0000000..26442c0
--- /dev/null
+++ b/backend/src/main/java/com/kaidi/finance/accounting/application/AccountingApplicationService.java
@@ -0,0 +1,1116 @@
+package com.kaidi.finance.accounting.application;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.kaidi.finance.accounting.api.AccountingContracts.GenerateDraftRequest;
+import com.kaidi.finance.accounting.api.AccountingContracts.ResultRequest;
+import com.kaidi.finance.accounting.api.AccountingContracts.ReopenResultRequest;
+import com.kaidi.finance.accounting.api.AccountingContracts.ReverseResultRequest;
+import com.kaidi.finance.accounting.api.AccountingContracts.ReturnRequest;
+import com.kaidi.finance.accounting.api.AccountingContracts.VerifyResultRequest;
+import com.kaidi.finance.accounting.api.AccountingContracts.VoidRequest;
+import com.kaidi.finance.accounting.api.AccountingContracts.VoucherEntryRequest;
+import com.kaidi.finance.accounting.api.AccountingContracts.VoucherUpdateRequest;
+import com.kaidi.finance.accounting.api.AccountingViews.AccountView;
+import com.kaidi.finance.accounting.api.AccountingViews.AccountingEventView;
+import com.kaidi.finance.accounting.api.AccountingViews.ReferenceView;
+import com.kaidi.finance.accounting.api.AccountingViews.ResultEvidenceFileView;
+import com.kaidi.finance.accounting.api.AccountingViews.VoucherDetailView;
+import com.kaidi.finance.accounting.api.AccountingViews.VoucherEntryView;
+import com.kaidi.finance.accounting.api.AccountingViews.VoucherExportView;
+import com.kaidi.finance.accounting.api.AccountingViews.VoucherResultActionView;
+import com.kaidi.finance.accounting.api.AccountingViews.VoucherView;
+import com.kaidi.finance.accounting.infrastructure.AccountingMapper;
+import com.kaidi.finance.accounting.infrastructure.AccountingMapper.AccountRow;
+import com.kaidi.finance.accounting.infrastructure.AccountingMapper.EntryRow;
+import com.kaidi.finance.accounting.infrastructure.AccountingMapper.EvidenceFileRow;
+import com.kaidi.finance.accounting.infrastructure.AccountingMapper.EventRow;
+import com.kaidi.finance.accounting.infrastructure.AccountingMapper.ExportBatchRow;
+import com.kaidi.finance.accounting.infrastructure.AccountingMapper.ResultFileRow;
+import com.kaidi.finance.accounting.infrastructure.AccountingMapper.ResultActionRow;
+import com.kaidi.finance.accounting.infrastructure.AccountingMapper.RuleParameterRow;
+import com.kaidi.finance.accounting.infrastructure.AccountingMapper.VoucherLock;
+import com.kaidi.finance.accounting.infrastructure.AccountingMapper.VoucherRow;
+import com.kaidi.finance.iam.domain.FinancePrincipal;
+import com.kaidi.finance.shared.api.BusinessException;
+import com.kaidi.finance.shared.api.ErrorCode;
+import com.kaidi.finance.shared.api.PageResult;
+import com.kaidi.finance.shared.audit.AuditService;
+import com.kaidi.finance.shared.file.FileApplicationService;
+import com.kaidi.finance.shared.file.FileApplicationService.FileDownload;
+import com.kaidi.finance.shared.file.FileView;
+import com.kaidi.finance.shared.id.UlidGenerator;
+import com.kaidi.finance.shared.security.AuthorizationService;
+import com.kaidi.finance.shared.security.IdentityContext;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.time.LocalDate;
+import java.time.YearMonth;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Pattern;
+import org.springframework.dao.DataIntegrityViolationException;
+import org.springframework.http.HttpStatus;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+public class AccountingApplicationService {
+
+ public static final String RULE_VERSION = "ACCOUNTING-RULE-V1";
+
+ private static final Pattern PUBLIC_ID = Pattern.compile("[0-9A-HJKMNP-TV-Z]{26}");
+ private static final Pattern PERIOD = Pattern.compile("\\d{4}-(0[1-9]|1[0-2])");
+ private static final Set TABS = Set.of(
+ "draft", "review", "exported", "result", "completed", "reversed", "void"
+ );
+ private static final Set EVENT_TYPES = Set.of(
+ "RECEIPT", "INVOICE", "PAYABLE", "PAYMENT", "PAYMENT_REFUND"
+ );
+ private static final Set VOUCHER_STATUSES = Set.of(
+ "DRAFT", "REVIEWING", "RETURNED", "APPROVED", "EXPORTED", "RESULT_RECORDED", "COMPLETED",
+ "RESULT_REVERSED", "VOID"
+ );
+ private static final DateTimeFormatter BATCH_TIME = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
+
+ private final AccountingMapper mapper;
+ private final AuthorizationService authorizationService;
+ private final IdentityContext identityContext;
+ private final UlidGenerator ulidGenerator;
+ private final AuditService auditService;
+ private final FileApplicationService fileService;
+ private final ObjectMapper objectMapper;
+
+ public AccountingApplicationService(AccountingMapper mapper, AuthorizationService authorizationService,
+ IdentityContext identityContext, UlidGenerator ulidGenerator,
+ AuditService auditService, FileApplicationService fileService,
+ ObjectMapper objectMapper) {
+ this.mapper = mapper;
+ this.authorizationService = authorizationService;
+ this.identityContext = identityContext;
+ this.ulidGenerator = ulidGenerator;
+ this.auditService = auditService;
+ this.fileService = fileService;
+ this.objectMapper = objectMapper;
+ }
+
+ @Transactional(readOnly = true)
+ public List accounts() {
+ authorizationService.requirePermission("accounting:voucher:view");
+ return mapper.listAccounts().stream().map(this::accountView).toList();
+ }
+
+ @Transactional(readOnly = true)
+ public PageResult listEvents(String period, String projectId, String eventType,
+ String keyword, String sort, int page, int size) {
+ return listEventPage("PENDING", period, projectId, eventType, keyword, sort, page, size);
+ }
+
+ @Transactional(readOnly = true)
+ public PageResult listProjectLifecycleEvents(
+ String period, String projectId, String eventType, String keyword, String sort, int page, int size) {
+ return listEventPage(null, period, projectId, eventType, keyword, sort, page, size);
+ }
+
+ private PageResult listEventPage(
+ String status, String period, String projectId, String eventType,
+ String keyword, String sort, int page, int size) {
+ authorizationService.requirePermission("accounting:event:view");
+ Actor actor = actor();
+ Page requestedPage = page(page, size);
+ String effectivePeriod = optionalPeriod(period);
+ String effectiveProjectId = optionalId(projectId);
+ String effectiveEventType = optionalEventType(eventType);
+ String effectiveKeyword = clean(keyword, 100);
+ List items = mapper.listEvents(actor.userId(), actor.roleCode(), status,
+ effectivePeriod, effectiveProjectId, effectiveEventType, effectiveKeyword, eventOrderBy(sort),
+ requestedPage.size(), requestedPage.offset()).stream().map(this::eventView).toList();
+ long total = mapper.countEvents(actor.userId(), actor.roleCode(), status, effectivePeriod,
+ effectiveProjectId, effectiveEventType, effectiveKeyword);
+ return new PageResult<>(items, total, requestedPage.page(), requestedPage.size());
+ }
+
+ @Transactional(readOnly = true)
+ public PageResult listVouchers(String tab, String period, String projectId, String eventType,
+ String status, String externalVoucherNo, String keyword,
+ String sort, int page, int size) {
+ String effectiveTab = blank(tab) == null ? "draft" : blank(tab).toLowerCase(Locale.ROOT);
+ if (!TABS.contains(effectiveTab)) throw validation("记账页签参数无效");
+ return listVoucherPage(effectiveTab, period, projectId, eventType, status, externalVoucherNo, keyword,
+ sort, page, size);
+ }
+
+ @Transactional(readOnly = true)
+ public PageResult listProjectLifecycleVouchers(
+ String tab, String period, String projectId, String eventType, String status,
+ String externalVoucherNo, String keyword, String sort, int page, int size) {
+ String requestedTab = blank(tab);
+ String effectiveTab = requestedTab == null ? "all" : requestedTab.toLowerCase(Locale.ROOT);
+ if (!"all".equals(effectiveTab) && !TABS.contains(effectiveTab)) {
+ throw validation("记账页签参数无效");
+ }
+ return listVoucherPage(effectiveTab, period, projectId, eventType, status, externalVoucherNo, keyword,
+ sort, page, size);
+ }
+
+ private PageResult listVoucherPage(
+ String effectiveTab, String period, String projectId, String eventType, String status,
+ String externalVoucherNo, String keyword, String sort, int page, int size) {
+ authorizationService.requirePermission("accounting:voucher:view");
+ Actor actor = actor();
+ Page requestedPage = page(page, size);
+ String effectiveStatus = optionalStatus(status);
+ String effectivePeriod = optionalPeriod(period);
+ String effectiveProjectId = optionalId(projectId);
+ String effectiveEventType = optionalEventType(eventType);
+ String effectiveExternalNo = clean(externalVoucherNo, 100);
+ String effectiveKeyword = clean(keyword, 100);
+ List items = mapper.listVouchers(actor.userId(), actor.roleCode(), effectiveTab,
+ effectivePeriod, effectiveProjectId, effectiveEventType, effectiveStatus, effectiveExternalNo,
+ effectiveKeyword, voucherOrderBy(sort), requestedPage.size(), requestedPage.offset())
+ .stream().map(this::voucherView).toList();
+ long total = mapper.countVouchers(actor.userId(), actor.roleCode(), effectiveTab, effectivePeriod,
+ effectiveProjectId, effectiveEventType, effectiveStatus, effectiveExternalNo, effectiveKeyword);
+ return new PageResult<>(items, total, requestedPage.page(), requestedPage.size());
+ }
+
+ @Transactional(readOnly = true)
+ public VoucherDetailView getVoucher(String publicId) {
+ VoucherRow row = scopedVoucher(publicId, "accounting:voucher:view");
+ return detail(row);
+ }
+
+ @Transactional
+ public VoucherDetailView generateDraft(String eventPublicId, GenerateDraftRequest request) {
+ authorizationService.requirePermission("accounting:event:generate");
+ Actor actor = actor();
+ EventRow found = event(eventPublicId);
+ authorizationService.requireScope("accounting:event:generate", found.companyPublicId(), found.projectPublicId(),
+ found.amount());
+ EventRow event = mapper.lockEvent(found.id());
+ requireVersion(event.version(), request.version());
+ RuleTemplate template = ruleTemplate(request.ruleVersion());
+ if (event.voucherId() != null) {
+ VoucherRow current = mapper.findVoucherById(event.voucherId());
+ if (current != null && !"VOID".equals(current.status())) {
+ return detail(current);
+ }
+ throw invalidState("财务事件当前凭证引用异常,请刷新后重试");
+ }
+ if (!"PENDING".equals(event.status())) throw invalidState("当前财务事件不能生成凭证草稿");
+
+ Rule rule = template.rule(event.eventType());
+ AccountRow debit = activeAccount(rule.debitAccount());
+ AccountRow credit = activeAccount(rule.creditAccount());
+ String summary = eventSummary(event);
+ String voucherPublicId = ulidGenerator.next();
+ String businessNo = "VCH-" + event.period().replace("-", "") + "-" + voucherPublicId.substring(18);
+ mapper.insertVoucher(voucherPublicId, businessNo, event.id(), event.companyId(), event.projectId(),
+ template.version(), template.snapshotJson(), event.period(), event.businessDate(), summary,
+ money(event.amount()), actor.userId());
+ long voucherId = mapper.voucherId(voucherPublicId);
+ mapper.insertEntry(ulidGenerator.next(), voucherId, 1, "DEBIT", debit.id(), money(event.amount()), summary,
+ auxiliaryJson(debit, event));
+ mapper.insertEntry(ulidGenerator.next(), voucherId, 2, "CREDIT", credit.id(), money(event.amount()), summary,
+ auxiliaryJson(credit, event));
+ if (mapper.markEventDrafted(event.id(), voucherId, event.version()) != 1) throw conflict();
+ VoucherRow created = mapper.findVoucherById(voucherId);
+ auditService.recordScoped(event.companyPublicId(), event.projectPublicId(),
+ "ACCOUNTING_DRAFT_GENERATE", "ACCOUNTING_EVENT", event.publicId(), "SUCCESS",
+ template.version(), eventView(event), voucherView(created));
+ return detail(created);
+ }
+
+ @Transactional
+ public VoucherDetailView updateVoucher(String publicId, VoucherUpdateRequest request) {
+ VoucherRow found = scopedVoucher(publicId, "accounting:voucher:edit");
+ Actor actor = actor();
+ VoucherLock voucher = lock(found.id(), request.version());
+ requireState(voucher.status(), "DRAFT", "RETURNED");
+ EventRow event = sourceEvent(voucher.eventId());
+ requireSourcePeriod(event, request.period(), request.businessDate());
+ List entries = resolveEntries(request.entries(), event);
+ Totals totals = totals(entries);
+ Map before = snapshot(voucher, mapper.listEntries(voucher.id()));
+ if (mapper.updateVoucher(voucher.id(), voucher.version(), request.period(), request.businessDate(),
+ request.summary().trim(), totals.debit(), totals.credit(), request.changeReason().trim(), actor.userId()) != 1) {
+ throw conflict();
+ }
+ mapper.deleteEntries(voucher.id());
+ for (ResolvedEntry entry : entries) {
+ mapper.insertEntry(ulidGenerator.next(), voucher.id(), entry.lineNo(), entry.direction(),
+ entry.account().id(), entry.amount(), entry.summary(), entry.auxiliaryJson());
+ }
+ VoucherRow updated = mapper.findVoucherById(voucher.id());
+ Map after = snapshot(updated, mapper.listEntries(voucher.id()));
+ mapper.insertChange(ulidGenerator.next(), voucher.id(), request.changeReason().trim(), json(before), json(after),
+ actor.userId());
+ auditService.recordScoped(found.companyPublicId(), found.projectPublicId(),
+ "ACCOUNTING_VOUCHER_EDIT", "ACCOUNTING_VOUCHER", voucher.publicId(), "SUCCESS",
+ request.changeReason().trim(), before, after);
+ return detail(updated);
+ }
+
+ @Transactional
+ public VoucherDetailView submitVoucher(String publicId, long version) {
+ VoucherRow found = scopedVoucher(publicId, "accounting:voucher:edit");
+ Actor actor = actor();
+ VoucherLock voucher = lock(found.id(), version);
+ requireState(voucher.status(), "DRAFT", "RETURNED");
+ if (mapper.submitVoucher(voucher.id(), voucher.version(), actor.userId()) != 1) throw conflict();
+ VoucherRow updated = mapper.findVoucherById(voucher.id());
+ auditService.recordScoped(found.companyPublicId(), found.projectPublicId(),
+ "ACCOUNTING_VOUCHER_SUBMIT", "ACCOUNTING_VOUCHER", publicId, "SUCCESS", null, voucher, updated);
+ return detail(updated);
+ }
+
+ @Transactional
+ public VoucherDetailView approveVoucher(String publicId, long version) {
+ VoucherRow found = scopedVoucher(publicId, "accounting:voucher:review");
+ Actor actor = actor();
+ VoucherLock voucher = lock(found.id(), version);
+ requireState(voucher.status(), "REVIEWING");
+ requireDifferentVoucherReviewer(voucher, actor.userId());
+ validateForApproval(voucher);
+ if (mapper.approveVoucher(voucher.id(), voucher.version(), actor.userId()) != 1) throw conflict();
+ VoucherRow updated = mapper.findVoucherById(voucher.id());
+ auditService.recordScoped(found.companyPublicId(), found.projectPublicId(),
+ "ACCOUNTING_VOUCHER_APPROVE", "ACCOUNTING_VOUCHER", publicId, "SUCCESS", null, voucher, updated);
+ return detail(updated);
+ }
+
+ @Transactional
+ public VoucherDetailView returnVoucher(String publicId, ReturnRequest request) {
+ VoucherRow found = scopedVoucher(publicId, "accounting:voucher:review");
+ Actor actor = actor();
+ VoucherLock voucher = lock(found.id(), request.version());
+ requireState(voucher.status(), "REVIEWING");
+ requireDifferentVoucherReviewer(voucher, actor.userId());
+ if (mapper.returnVoucher(voucher.id(), voucher.version(), request.opinion().trim(), actor.userId()) != 1) {
+ throw conflict();
+ }
+ VoucherRow updated = mapper.findVoucherById(voucher.id());
+ auditService.recordScoped(found.companyPublicId(), found.projectPublicId(),
+ "ACCOUNTING_VOUCHER_RETURN", "ACCOUNTING_VOUCHER", publicId, "SUCCESS",
+ request.opinion().trim(), voucher, updated);
+ return detail(updated);
+ }
+
+ @Transactional
+ public VoucherExportView exportVoucher(String publicId, long version) {
+ VoucherRow found = scopedVoucher(publicId, "accounting:voucher:export");
+ Actor actor = actor();
+ VoucherLock voucher = lock(found.id(), version);
+ if ("EXPORTED".equals(voucher.status())) {
+ VoucherRow exported = mapper.findVoucherById(voucher.id());
+ return exportView(exported, actor, true);
+ }
+ requireState(voucher.status(), "APPROVED");
+ validateForApproval(voucher);
+ VoucherRow before = mapper.findVoucherById(voucher.id());
+ List entries = mapper.listEntries(voucher.id());
+ String content = csv(before, entries);
+ byte[] bytes = content.getBytes(StandardCharsets.UTF_8);
+ String sha256 = sha256(bytes);
+ String batchNo = "ACC-" + java.time.LocalDateTime.now().format(BATCH_TIME) + "-"
+ + publicId.substring(publicId.length() - 6);
+ FileView file = fileService.storeGenerated(before.businessNo() + "-" + batchNo + ".csv",
+ "text/csv;charset=UTF-8", "csv", bytes);
+ Map range = new LinkedHashMap<>();
+ range.put("voucherPublicIds", List.of(before.publicId()));
+ range.put("companyPublicId", before.companyPublicId());
+ range.put("projectPublicId", before.projectPublicId());
+ range.put("period", before.period());
+ if (mapper.insertExportBatch(ulidGenerator.next(), batchNo, voucher.id(), before.ruleVersion(), json(range),
+ file.publicId(), sha256, entries.size(), actor.userId()) != 1) {
+ throw new IllegalStateException("Accounting export file was not persisted");
+ }
+ if (mapper.markExported(voucher.id(), voucher.version(), sha256, batchNo, actor.userId()) != 1) {
+ throw conflict();
+ }
+ VoucherRow updated = mapper.findVoucherById(voucher.id());
+ auditService.recordScoped(found.companyPublicId(), found.projectPublicId(),
+ "ACCOUNTING_VOUCHER_EXPORT", "ACCOUNTING_VOUCHER", publicId, "SUCCESS", batchNo,
+ before, Map.of("batchNo", batchNo, "sha256", sha256, "fileId", file.publicId(),
+ "rowCount", entries.size()));
+ return new VoucherExportView(voucherView(updated), batchNo, sha256, file.publicId(), entries.size());
+ }
+
+ @Transactional
+ public FileDownload downloadExport(String publicId) {
+ VoucherRow voucher = scopedVoucher(publicId, "accounting:voucher:export");
+ ExportBatchRow batch = mapper.findExportBatch(voucher.id());
+ if (batch == null) throw notFound("记账导出文件不存在");
+ FileDownload download = fileService.downloadForBusiness(batch.filePublicId());
+ auditService.recordScoped(voucher.companyPublicId(), voucher.projectPublicId(),
+ "ACCOUNTING_VOUCHER_EXPORT_DOWNLOAD", "ACCOUNTING_VOUCHER", publicId, "SUCCESS", batch.batchNo(),
+ null, Map.of("batchNo", batch.batchNo(), "fileId", batch.filePublicId(), "sha256", batch.sha256()));
+ return download;
+ }
+
+ @Transactional
+ public FileDownload downloadResultEvidence(String voucherPublicId, String filePublicId) {
+ VoucherRow voucher = scopedVoucher(voucherPublicId, "accounting:voucher:view");
+ requireState(voucher.status(), "EXPORTED", "RESULT_RECORDED", "COMPLETED", "RESULT_REVERSED");
+ ResultFileRow file = mapper.findResultFile(voucher.id(), publicId(filePublicId));
+ if (file == null) throw notFound("记账结果证据不存在");
+ FileDownload download = fileService.downloadForBusiness(file.publicId());
+ auditService.recordScoped(voucher.companyPublicId(), voucher.projectPublicId(),
+ "ACCOUNTING_RESULT_EVIDENCE_DOWNLOAD", "ACCOUNTING_VOUCHER", voucherPublicId, "SUCCESS",
+ file.originalName(), null,
+ Map.of("fileId", file.publicId(), "sha256", file.sha256(), "status", voucher.status()));
+ return download;
+ }
+
+ @Transactional
+ public VoucherDetailView recordResult(String publicId, ResultRequest request) {
+ VoucherRow found = scopedVoucher(publicId, "accounting:voucher:record-result");
+ Actor actor = actor();
+ VoucherLock voucher = lock(found.id(), request.version());
+ requireState(voucher.status(), "EXPORTED");
+ if (request.resultAt().isAfter(LocalDate.now())) throw validation("结果日期不能晚于当前日期");
+ String externalVoucherNo = request.externalVoucherNo().trim();
+ if (mapper.historicalExternalVoucherNoExists(found.companyId(), voucher.period(), externalVoucherNo) > 0) {
+ throw duplicate("同一公司和期间的外部凭证号已经登记");
+ }
+ List evidenceFiles = resolveEvidenceFiles(request.evidenceFileIds(), voucher,
+ found.companyPublicId(), found.projectPublicId());
+ try {
+ if (mapper.recordResult(voucher.id(), voucher.version(), externalVoucherNo,
+ request.resultAt(), clean(request.remark(), 1000), actor.userId()) != 1) throw conflict();
+ for (EvidenceFileRow file : evidenceFiles) {
+ mapper.insertResultFile(ulidGenerator.next(), voucher.id(), voucher.resultCycleNo(), file.id(),
+ actor.userId());
+ }
+ } catch (DataIntegrityViolationException exception) {
+ throw duplicate("同一公司和期间的外部凭证号已经登记");
+ }
+ VoucherRow updated = mapper.findVoucherById(voucher.id());
+ auditService.recordScoped(found.companyPublicId(), found.projectPublicId(),
+ "ACCOUNTING_RESULT_RECORD", "ACCOUNTING_VOUCHER", publicId, "SUCCESS",
+ request.remark(), voucher, updated);
+ return detail(updated);
+ }
+
+ @Transactional
+ public VoucherDetailView verifyResult(String publicId, VerifyResultRequest request) {
+ VoucherRow found = scopedVoucher(publicId, "accounting:voucher:verify-result");
+ Actor actor = actor();
+ VoucherLock voucher = lock(found.id(), request.version());
+ requireState(voucher.status(), "RESULT_RECORDED");
+ if (voucher.resultRecordedBy() != null && voucher.resultRecordedBy() == actor.userId()) {
+ throw sod("人工记账结果登记人与复核人不能是同一人");
+ }
+ if (mapper.verifyResult(voucher.id(), voucher.version(), request.opinion().trim(), actor.userId()) != 1) {
+ throw conflict();
+ }
+ if (mapper.completeEvent(voucher.eventId(), "COMPLETED") != 1) throw conflict();
+ VoucherRow updated = mapper.findVoucherById(voucher.id());
+ auditService.recordScoped(found.companyPublicId(), found.projectPublicId(),
+ "ACCOUNTING_RESULT_VERIFY", "ACCOUNTING_VOUCHER", publicId, "SUCCESS",
+ request.opinion().trim(), voucher, updated);
+ return detail(updated);
+ }
+
+ @Transactional
+ public VoucherDetailView reverseResult(String publicId, ReverseResultRequest request) {
+ VoucherRow found = scopedVoucher(publicId, "accounting:voucher:reverse-result");
+ Actor actor = actor();
+ VoucherLock voucher = lock(found.id(), request.version());
+ requireState(voucher.status(), "COMPLETED");
+ if (voucher.resultRecordedBy() != null && voucher.resultRecordedBy() == actor.userId()) {
+ throw sod("人工记账结果登记人不能冲销本人登记的结果");
+ }
+ VoucherRow before = mapper.findVoucherById(voucher.id());
+ ResultSnapshot snapshot = resultSnapshot(before, mapper.listResultFiles(voucher.id()));
+ String reason = request.reason().trim();
+ int sequence = mapper.nextResultActionSequence(voucher.id());
+ if (mapper.reverseResult(voucher.id(), voucher.version(), actor.userId()) != 1) throw conflict();
+ if (mapper.reopenCompletedEvent(voucher.eventId(), voucher.id()) != 1) throw conflict();
+ VoucherRow updated = mapper.findVoucherById(voucher.id());
+ if (mapper.insertResultAction(ulidGenerator.next(), voucher.id(), found.companyId(), voucher.period(),
+ sequence, voucher.resultCycleNo(), "REVERSE", reason, before.externalVoucherNo(), json(snapshot),
+ actor.userId(), voucher.version(), updated.version()) != 1) {
+ throw new IllegalStateException("Accounting result reversal was not persisted");
+ }
+ auditService.recordScoped(found.companyPublicId(), found.projectPublicId(),
+ "ACCOUNTING_RESULT_REVERSE", "ACCOUNTING_VOUCHER", publicId, "SUCCESS", reason, before, updated);
+ return detail(updated);
+ }
+
+ @Transactional
+ public VoucherDetailView reopenResult(String publicId, ReopenResultRequest request) {
+ VoucherRow found = scopedVoucher(publicId, "accounting:voucher:reopen-result");
+ Actor actor = actor();
+ VoucherLock voucher = lock(found.id(), request.version());
+ requireState(voucher.status(), "RESULT_REVERSED");
+ ResultActionRow reversal = mapper.latestResultAction(voucher.id());
+ if (reversal == null || !"REVERSE".equals(reversal.actionType())
+ || reversal.resultCycleNo() != voucher.resultCycleNo()) {
+ throw invalidState("当前凭证缺少可重开的冲销记录");
+ }
+ if (reversal.operatedBy() == actor.userId()) {
+ throw sod("记账结果冲销人与重开人不能是同一人");
+ }
+ requireState(sourceEvent(voucher.eventId()).status(), "DRAFTED");
+ VoucherRow before = mapper.findVoucherById(voucher.id());
+ ResultSnapshot snapshot = resultSnapshot(before, mapper.listResultFiles(voucher.id()));
+ String reason = request.reason().trim();
+ int sequence = mapper.nextResultActionSequence(voucher.id());
+ if (mapper.reopenResult(voucher.id(), voucher.version(), actor.userId()) != 1) throw conflict();
+ VoucherRow updated = mapper.findVoucherById(voucher.id());
+ if (mapper.insertResultAction(ulidGenerator.next(), voucher.id(), found.companyId(), voucher.period(),
+ sequence, voucher.resultCycleNo(), "REOPEN", reason, null, json(snapshot), actor.userId(),
+ voucher.version(), updated.version()) != 1) {
+ throw new IllegalStateException("Accounting result reopen was not persisted");
+ }
+ auditService.recordScoped(found.companyPublicId(), found.projectPublicId(),
+ "ACCOUNTING_RESULT_REOPEN", "ACCOUNTING_VOUCHER", publicId, "SUCCESS", reason, before, updated);
+ return detail(updated);
+ }
+
+ @Transactional
+ public VoucherDetailView voidVoucher(String publicId, VoidRequest request) {
+ VoucherRow found = scopedVoucher(publicId, "accounting:voucher:void");
+ Actor actor = actor();
+ VoucherLock voucher = lock(found.id(), request.version());
+ requireState(voucher.status(), "DRAFT", "RETURNED");
+ if (mapper.voidVoucher(voucher.id(), voucher.version(), request.reason().trim(), actor.userId()) != 1) {
+ throw conflict();
+ }
+ if (mapper.releaseVoidedEvent(voucher.eventId(), voucher.id()) != 1) throw conflict();
+ VoucherRow updated = mapper.findVoucherById(voucher.id());
+ auditService.recordScoped(found.companyPublicId(), found.projectPublicId(),
+ "ACCOUNTING_VOUCHER_VOID", "ACCOUNTING_VOUCHER", publicId, "SUCCESS",
+ request.reason().trim(), voucher, updated);
+ return detail(updated);
+ }
+
+ private void validateForApproval(VoucherLock voucher) {
+ EventRow event = sourceEvent(voucher.eventId());
+ requireSourcePeriod(event, voucher.period(), voucher.businessDate());
+ List entries = mapper.listEntries(voucher.id());
+ if (entries.isEmpty()) throw validation("凭证至少需要一条借方和一条贷方分录");
+ BigDecimal debit = BigDecimal.ZERO;
+ BigDecimal credit = BigDecimal.ZERO;
+ boolean hasDebit = false;
+ boolean hasCredit = false;
+ for (EntryRow entry : entries) {
+ if (!"ACTIVE".equals(entry.accountStatus())) {
+ throw validation("科目 " + entry.accountCode() + " 已停用,不能审批或导出凭证");
+ }
+ String auxiliary = normalizedAuxiliary(entry.auxiliaryJson(), event);
+ if (entry.auxiliaryRequired() && !hasAuxiliary(auxiliary)) {
+ throw validation("科目 " + entry.accountCode() + " 缺少项目或往来单位等辅助核算信息");
+ }
+ if ("DEBIT".equals(entry.direction())) {
+ debit = debit.add(entry.amount());
+ hasDebit = true;
+ } else {
+ credit = credit.add(entry.amount());
+ hasCredit = true;
+ }
+ }
+ if (!hasDebit || !hasCredit) throw validation("凭证必须同时包含借方和贷方分录");
+ if (debit.compareTo(credit) != 0) throw validation("借方合计与贷方合计不平衡");
+ if (debit.compareTo(event.amount()) != 0) throw validation("凭证借贷金额必须与来源财务事件金额一致");
+ }
+
+ private List resolveEntries(List requests, EventRow event) {
+ Set lineNumbers = new HashSet<>();
+ List entries = new ArrayList<>();
+ for (VoucherEntryRequest request : requests) {
+ if (request.lineNo() == null || request.lineNo() < 1 || !lineNumbers.add(request.lineNo())) {
+ throw validation("分录行号必须为不重复的正整数");
+ }
+ AccountRow account = mapper.findAccount(publicId(request.accountId()));
+ if (account == null || !"ACTIVE".equals(account.status())) throw validation("分录科目不存在或未生效");
+ String auxiliary = normalizedAuxiliary(request.auxiliaryJson(), event);
+ entries.add(new ResolvedEntry(request.lineNo(), request.direction(), account, money(request.amount()),
+ request.summary().trim(), auxiliary));
+ }
+ entries.sort(java.util.Comparator.comparingInt(ResolvedEntry::lineNo));
+ return entries;
+ }
+
+ private Totals totals(List entries) {
+ BigDecimal debit = BigDecimal.ZERO;
+ BigDecimal credit = BigDecimal.ZERO;
+ boolean hasDebit = false;
+ boolean hasCredit = false;
+ for (ResolvedEntry entry : entries) {
+ if ("DEBIT".equals(entry.direction())) {
+ debit = debit.add(entry.amount());
+ hasDebit = true;
+ } else if ("CREDIT".equals(entry.direction())) {
+ credit = credit.add(entry.amount());
+ hasCredit = true;
+ } else {
+ throw validation("分录方向无效");
+ }
+ }
+ if (!hasDebit || !hasCredit) throw validation("凭证必须同时包含借方和贷方分录");
+ return new Totals(money(debit), money(credit));
+ }
+
+ private String normalizedAuxiliary(String value, EventRow event) {
+ String cleaned = blank(value);
+ if (cleaned == null) return null;
+ try {
+ JsonNode node = objectMapper.readTree(cleaned);
+ if (!node.isObject()) throw validation("辅助核算信息必须是 JSON 对象");
+ var fields = node.fields();
+ while (fields.hasNext()) {
+ Map.Entry field = fields.next();
+ String key = field.getKey();
+ if (!Set.of("projectId", "counterpartyId").contains(key)) {
+ throw validation("辅助核算只允许项目和往来单位维度");
+ }
+ JsonNode raw = field.getValue();
+ if (!raw.isTextual()) throw validation("辅助核算维度值必须是公开编号");
+ String valueId = publicId(raw.textValue());
+ String expected = "projectId".equals(key) ? event.projectPublicId() : event.counterpartyPublicId();
+ if (expected == null || !expected.equalsIgnoreCase(valueId)) {
+ throw validation("辅助核算维度必须与来源财务事件一致");
+ }
+ ((com.fasterxml.jackson.databind.node.ObjectNode) node).put(key, valueId);
+ }
+ return objectMapper.writeValueAsString(node);
+ } catch (JsonProcessingException exception) {
+ throw validation("辅助核算信息不是有效 JSON");
+ }
+ }
+
+ private boolean hasAuxiliary(String value) {
+ if (blank(value) == null) return false;
+ try {
+ JsonNode node = objectMapper.readTree(value);
+ return node.isObject() && node.size() > 0;
+ } catch (JsonProcessingException exception) {
+ return false;
+ }
+ }
+
+ private String auxiliaryJson(AccountRow account, EventRow event) {
+ if (!account.auxiliaryRequired()) return null;
+ Map dimensions = new LinkedHashMap<>();
+ if (event.projectPublicId() != null) dimensions.put("projectId", event.projectPublicId());
+ if (event.counterpartyPublicId() != null) dimensions.put("counterpartyId", event.counterpartyPublicId());
+ return dimensions.isEmpty() ? null : json(dimensions);
+ }
+
+ private RuleTemplate ruleTemplate(String requestedVersion) {
+ String version = blank(requestedVersion);
+ if (version == null) throw validation("凭证规则版本不能为空");
+ RuleParameterRow row = mapper.findAccountingRuleTemplate(version);
+ if (row == null) throw validation("凭证规则版本无效或未生效");
+ try {
+ JsonNode root = objectMapper.readTree(row.valueJson());
+ if (root == null || !root.isObject()) throw validation("凭证规则模板必须是 JSON 对象");
+ String declaredVersion = root.path("ruleVersion").asText(null);
+ if (!version.equals(declaredVersion)) throw validation("凭证规则版本标识不一致");
+ JsonNode eventTypes = root.path("eventTypes");
+ if (!eventTypes.isObject()) throw validation("凭证规则模板缺少 eventTypes");
+ Map rules = new LinkedHashMap<>();
+ for (String eventType : EVENT_TYPES) {
+ JsonNode item = eventTypes.path(eventType);
+ String debit = item.path("debitAccount").asText(null);
+ String credit = item.path("creditAccount").asText(null);
+ if (debit == null || credit == null || debit.isBlank() || credit.isBlank()) {
+ throw validation("凭证规则模板缺少 " + eventType + " 科目配置");
+ }
+ rules.put(eventType, new Rule(debit.trim(), credit.trim()));
+ }
+ return new RuleTemplate(version, objectMapper.writeValueAsString(root), rules);
+ } catch (JsonProcessingException exception) {
+ throw validation("凭证规则模板不是有效 JSON");
+ }
+ }
+
+ private AccountRow activeAccount(String code) {
+ Long id = mapper.activeAccountId(code);
+ if (id == null) throw validation("凭证规则引用的会计科目未生效:" + code);
+ return mapper.listAccounts().stream().filter(item -> item.id() == id).findFirst()
+ .orElseThrow(() -> validation("凭证规则引用的会计科目不存在:" + code));
+ }
+
+ private String eventSummary(EventRow event) {
+ String type = switch (event.eventType()) {
+ case "RECEIPT" -> "收款";
+ case "INVOICE" -> "开票";
+ case "PAYABLE" -> "应付";
+ case "PAYMENT" -> "付款";
+ case "PAYMENT_REFUND" -> "退汇冲销";
+ default -> event.eventType();
+ };
+ return type + "记账准备 - " + event.businessNo();
+ }
+
+ private List resolveEvidenceFiles(List fileIds, VoucherLock voucher,
+ String companyPublicId, String projectPublicId) {
+ if (fileIds == null || fileIds.isEmpty()) throw validation("必须上传人工记账结果证据");
+ FinancePrincipal principal = identityContext.requirePrincipal();
+ Set unique = new HashSet<>();
+ List files = new ArrayList<>();
+ for (String value : fileIds) {
+ String fileId = publicId(value);
+ if (!unique.add(fileId)) continue;
+ EvidenceFileRow file = mapper.findEvidenceFile(fileId, principal.publicId(), voucher.id(),
+ companyPublicId, projectPublicId);
+ if (file == null) throw notFound("结果证据文件不存在");
+ if (!file.uploadedByActor() && !file.attachedToVoucher() && !file.linkedToTarget()) {
+ throw new BusinessException(HttpStatus.FORBIDDEN, ErrorCode.DATA_SCOPE_DENIED,
+ "结果证据不属于当前账号或目标公司/项目");
+ }
+ if (!"AVAILABLE".equals(file.scanStatus())) {
+ throw new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.FILE_REJECTED,
+ "结果证据尚未通过文件检查");
+ }
+ files.add(file);
+ }
+ if (files.isEmpty()) throw validation("必须上传人工记账结果证据");
+ return files;
+ }
+
+ private VoucherExportView exportView(VoucherRow voucher, Actor actor, boolean rebuildMissingBatch) {
+ ExportBatchRow batch = mapper.findExportBatch(voucher.id());
+ List entries = mapper.listEntries(voucher.id());
+ byte[] bytes = csv(voucher, entries).getBytes(StandardCharsets.UTF_8);
+ String calculated = sha256(bytes);
+ if (voucher.exportSha256() == null || !calculated.equals(voucher.exportSha256())) {
+ throw new BusinessException(HttpStatus.CONFLICT, ErrorCode.INVALID_STATE_TRANSITION,
+ "当前凭证内容与已导出文件哈希不一致");
+ }
+ if (batch == null) {
+ if (!rebuildMissingBatch) throw notFound("记账导出批次不存在");
+ batch = rebuildExportBatch(voucher, actor, entries, bytes, calculated);
+ }
+ if (!calculated.equals(batch.sha256())) {
+ throw new BusinessException(HttpStatus.CONFLICT, ErrorCode.INVALID_STATE_TRANSITION,
+ "持久化导出批次与当前凭证哈希不一致");
+ }
+ return new VoucherExportView(voucherView(voucher), batch.batchNo(), batch.sha256(), batch.filePublicId(),
+ batch.rowCount());
+ }
+
+ private ExportBatchRow rebuildExportBatch(VoucherRow voucher, Actor actor, List entries,
+ byte[] bytes, String sha256) {
+ if (entries.isEmpty()) throw validation("已导出凭证没有可重建的分录");
+ String batchNo = voucher.exportBatchNo();
+ if (batchNo == null || batchNo.isBlank()) batchNo = "ACC-LEGACY-" + voucher.publicId();
+ FileView file = fileService.storeGenerated(voucher.businessNo() + "-" + batchNo + ".csv",
+ "text/csv;charset=UTF-8", "csv", bytes);
+ Map range = new LinkedHashMap<>();
+ range.put("voucherPublicIds", List.of(voucher.publicId()));
+ range.put("companyPublicId", voucher.companyPublicId());
+ range.put("projectPublicId", voucher.projectPublicId());
+ range.put("period", voucher.period());
+ try {
+ if (mapper.insertExportBatch(ulidGenerator.next(), batchNo, voucher.id(), voucher.ruleVersion(),
+ json(range), file.publicId(), sha256, entries.size(), actor.userId()) != 1) {
+ throw new IllegalStateException("Accounting export evidence was not rebuilt");
+ }
+ } catch (DataIntegrityViolationException exception) {
+ ExportBatchRow concurrent = mapper.findExportBatch(voucher.id());
+ if (concurrent == null || !sha256.equals(concurrent.sha256())) throw exception;
+ return concurrent;
+ }
+ ExportBatchRow rebuilt = mapper.findExportBatch(voucher.id());
+ if (rebuilt == null) throw new IllegalStateException("Accounting export evidence was not persisted");
+ auditService.recordScoped(voucher.companyPublicId(), voucher.projectPublicId(),
+ "ACCOUNTING_VOUCHER_EXPORT_EVIDENCE_REBUILD", "ACCOUNTING_VOUCHER", voucher.publicId(), "SUCCESS",
+ batchNo, null, Map.of("batchNo", batchNo, "fileId", file.publicId(), "sha256", sha256,
+ "rowCount", entries.size()));
+ return rebuilt;
+ }
+
+ private String csv(VoucherRow voucher, List entries) {
+ StringBuilder csv = new StringBuilder();
+ csv.append("凭证号,期间,业务日期,行号,方向,科目编码,科目名称,金额,摘要,辅助核算,公司,项目,来源事件\r\n");
+ for (EntryRow entry : entries) {
+ csv.append(csvCell(voucher.businessNo())).append(',')
+ .append(csvCell(voucher.period())).append(',')
+ .append(csvCell(voucher.businessDate().toString())).append(',')
+ .append(entry.lineNo()).append(',')
+ .append(csvCell(entry.direction())).append(',')
+ .append(csvCell(entry.accountCode())).append(',')
+ .append(csvCell(entry.accountName())).append(',')
+ .append(entry.amount().setScale(2, RoundingMode.UNNECESSARY).toPlainString()).append(',')
+ .append(csvCell(entry.summary())).append(',')
+ .append(csvCell(entry.auxiliaryJson())).append(',')
+ .append(csvCell(voucher.companyCode())).append(',')
+ .append(csvCell(voucher.projectCode())).append(',')
+ .append(csvCell(voucher.eventBusinessNo())).append("\r\n");
+ }
+ return csv.toString();
+ }
+
+ private String csvCell(String value) {
+ if (value == null) return "";
+ return '"' + value.replace("\"", "\"\"") + '"';
+ }
+
+ private String sha256(byte[] content) {
+ try {
+ byte[] digest = MessageDigest.getInstance("SHA-256").digest(content);
+ return java.util.HexFormat.of().formatHex(digest);
+ } catch (NoSuchAlgorithmException exception) {
+ throw new IllegalStateException("SHA-256 is not available", exception);
+ }
+ }
+
+ private VoucherDetailView detail(VoucherRow voucher) {
+ EventRow event = mapper.findEventById(voucher.eventId());
+ return new VoucherDetailView(voucherView(voucher), eventView(event),
+ mapper.listEntries(voucher.id()).stream().map(this::entryView).toList(),
+ mapper.listResultFiles(voucher.id()).stream().map(this::evidenceFileView).toList(),
+ mapper.listResultActions(voucher.id()).stream().map(this::resultActionView).toList());
+ }
+
+ private VoucherRow scopedVoucher(String publicId, String permission) {
+ authorizationService.requirePermission(permission);
+ VoucherRow row = mapper.findVoucher(publicId(publicId));
+ if (row == null) throw notFound("凭证不存在");
+ authorizationService.requireScope(permission, row.companyPublicId(), row.projectPublicId(), row.eventAmount());
+ return row;
+ }
+
+ private void requireDifferentVoucherReviewer(VoucherLock voucher, long reviewerUserId) {
+ if (voucher.createdBy() == reviewerUserId ||
+ (voucher.submittedBy() != null && voucher.submittedBy() == reviewerUserId)) {
+ throw sod("凭证制单人或提交人与凭证复核人不能是同一人");
+ }
+ }
+
+ private EventRow event(String publicId) {
+ EventRow row = mapper.findEvent(publicId(publicId));
+ if (row == null) throw notFound("财务事件不存在");
+ return row;
+ }
+
+ private VoucherLock lock(long id, long version) {
+ VoucherLock row = mapper.lockVoucher(id);
+ if (row == null) throw notFound("凭证不存在");
+ requireVersion(row.version(), version);
+ return row;
+ }
+
+ private AccountingEventView eventView(EventRow row) {
+ List actions = new ArrayList<>();
+ if ("PENDING".equals(row.status())
+ && authorizationService.hasScope("accounting:event:generate", row.companyPublicId(), row.projectPublicId(),
+ row.amount())) {
+ actions.add("GENERATE_DRAFT");
+ }
+ return new AccountingEventView(row.publicId(), row.businessNo(), row.eventType(), row.sourceType(),
+ row.sourcePublicId(), row.sourceBusinessNo(), row.sourceVersion(), row.eventSequence(),
+ row.sourceEventKey(),
+ reference(row.companyPublicId(), row.companyCode(), row.companyName()),
+ reference(row.projectPublicId(), row.projectCode(), row.projectName()),
+ reference(row.counterpartyPublicId(), row.counterpartyCode(), row.counterpartyName()), row.businessDate(),
+ row.period(), amount(row.amount()), row.currency(), row.status(), row.voucherPublicId(),
+ row.voucherStatus(), row.version(), row.createdAt(), actions);
+ }
+
+ private VoucherView voucherView(VoucherRow row) {
+ Actor actor = actor();
+ List actions = new ArrayList<>();
+ boolean editable = Set.of("DRAFT", "RETURNED").contains(row.status());
+ if (editable && authorizationService.hasScope("accounting:voucher:edit", row.companyPublicId(),
+ row.projectPublicId(), row.eventAmount())) {
+ actions.add("EDIT");
+ actions.add("SUBMIT");
+ }
+ if (editable && authorizationService.hasScope("accounting:voucher:void", row.companyPublicId(),
+ row.projectPublicId(), row.eventAmount())) actions.add("VOID");
+ if ("REVIEWING".equals(row.status()) && row.createdBy() != actor.userId()
+ && (row.submittedBy() == null || row.submittedBy() != actor.userId())
+ && authorizationService.hasScope("accounting:voucher:review", row.companyPublicId(),
+ row.projectPublicId(), row.eventAmount())) {
+ actions.add("APPROVE");
+ actions.add("RETURN");
+ }
+ if ("APPROVED".equals(row.status())
+ && authorizationService.hasScope("accounting:voucher:export",
+ row.companyPublicId(), row.projectPublicId(), row.eventAmount())) actions.add("EXPORT");
+ if ("EXPORTED".equals(row.status()) && authorizationService.hasScope("accounting:voucher:record-result",
+ row.companyPublicId(), row.projectPublicId(), row.eventAmount())) actions.add("RECORD_RESULT");
+ if ("EXPORTED".equals(row.status()) && row.exportFileId() == null
+ && authorizationService.hasScope("accounting:voucher:export",
+ row.companyPublicId(), row.projectPublicId(), row.eventAmount())) actions.add("EXPORT");
+ if ("RESULT_RECORDED".equals(row.status())
+ && (row.resultRecordedBy() == null || row.resultRecordedBy() != actor.userId())
+ && authorizationService.hasScope("accounting:voucher:verify-result", row.companyPublicId(),
+ row.projectPublicId(), row.eventAmount())) actions.add("VERIFY_RESULT");
+ if ("COMPLETED".equals(row.status())
+ && (row.resultRecordedBy() == null || row.resultRecordedBy() != actor.userId())
+ && authorizationService.hasScope("accounting:voucher:reverse-result", row.companyPublicId(),
+ row.projectPublicId(), row.eventAmount())) actions.add("REVERSE_RESULT");
+ if ("RESULT_REVERSED".equals(row.status())) {
+ ResultActionRow reversal = mapper.latestResultAction(row.id());
+ if (reversal != null && "REVERSE".equals(reversal.actionType())
+ && reversal.operatedBy() != actor.userId()
+ && authorizationService.hasScope("accounting:voucher:reopen-result", row.companyPublicId(),
+ row.projectPublicId(), row.eventAmount())) {
+ actions.add("REOPEN_RESULT");
+ }
+ }
+ String balance = row.debitTotal().compareTo(row.creditTotal()) == 0 ? "BALANCED" : "UNBALANCED";
+ return new VoucherView(row.publicId(), row.businessNo(), row.eventPublicId(), row.eventBusinessNo(),
+ reference(row.companyPublicId(), row.companyCode(), row.companyName()),
+ reference(row.projectPublicId(), row.projectCode(), row.projectName()), row.period(), row.businessDate(),
+ row.summary(), amount(row.debitTotal()), amount(row.creditTotal()), balance, row.status(),
+ row.submittedByName(), row.reviewedByName(), row.exportedAt(), row.exportBatchNo(), row.exportSha256(),
+ row.exportFileId(), row.ruleVersion(), row.externalVoucherNo(), row.resultAt(),
+ row.resultCycleNo(),
+ mapper.listResultFiles(row.id()).stream().map(ResultFileRow::publicId).toList(),
+ row.resultRecordedByName(), row.resultRecordedAt(), row.resultRecordRemark(),
+ row.resultVerifiedByName(), row.resultVerifiedAt(), row.resultVerifyOpinion(), row.voidReason(),
+ row.version(), row.createdAt(), row.updatedAt(), actions);
+ }
+
+ private VoucherEntryView entryView(EntryRow row) {
+ return new VoucherEntryView(row.publicId(), row.lineNo(), row.direction(),
+ reference(row.accountPublicId(), row.accountCode(), row.accountName()), amount(row.amount()),
+ row.summary(), row.auxiliaryJson());
+ }
+
+ private ResultEvidenceFileView evidenceFileView(ResultFileRow row) {
+ return new ResultEvidenceFileView(row.publicId(), row.originalName(), row.mediaType(), row.sizeBytes(),
+ row.sha256(), row.scanStatus());
+ }
+
+ private VoucherResultActionView resultActionView(ResultActionRow row) {
+ ResultSnapshot snapshot;
+ try {
+ snapshot = objectMapper.readValue(row.resultSnapshotJson(), ResultSnapshot.class);
+ } catch (JsonProcessingException exception) {
+ throw new IllegalStateException("Accounting result action snapshot cannot be read", exception);
+ }
+ return new VoucherResultActionView(row.publicId(), row.sequenceNo(), row.resultCycleNo(), row.actionType(),
+ row.reason(), snapshot.externalVoucherNo(), snapshot.resultAt(), snapshot.resultRecordedByName(),
+ snapshot.resultRecordedAt(), snapshot.resultRecordRemark(), snapshot.resultVerifiedByName(),
+ snapshot.resultVerifiedAt(), snapshot.resultVerifyOpinion(), snapshot.evidenceFiles(),
+ row.operatedByName(), row.operatedAt(), row.versionBefore(), row.versionAfter());
+ }
+
+ private AccountView accountView(AccountRow row) {
+ return new AccountView(row.publicId(), row.code(), row.name(), row.accountType(), row.auxiliaryRequired());
+ }
+
+ private ReferenceView reference(String publicId, String code, String name) {
+ return publicId == null ? null : new ReferenceView(publicId, code, name);
+ }
+
+ private Map snapshot(VoucherLock voucher, List entries) {
+ return Map.of("voucher", voucher, "entries", entries);
+ }
+
+ private Map snapshot(VoucherRow voucher, List entries) {
+ return Map.of("voucher", voucher, "entries", entries);
+ }
+
+ private ResultSnapshot resultSnapshot(VoucherRow voucher, List files) {
+ return new ResultSnapshot(voucher.externalVoucherNo(), voucher.resultAt(), voucher.resultRecordedByName(),
+ voucher.resultRecordedAt(), voucher.resultRecordRemark(), voucher.resultVerifiedByName(),
+ voucher.resultVerifiedAt(), voucher.resultVerifyOpinion(),
+ files.stream().map(this::evidenceFileView).toList());
+ }
+
+ private String json(Object value) {
+ try {
+ return objectMapper.writeValueAsString(value);
+ } catch (JsonProcessingException exception) {
+ throw new IllegalStateException("Accounting audit snapshot cannot be serialized", exception);
+ }
+ }
+
+ private Actor actor() {
+ FinancePrincipal principal = identityContext.requirePrincipal();
+ return new Actor(principal.userId(), identityContext.requireActiveRole());
+ }
+
+ private Page page(int page, int size) {
+ if (page < 1 || !Set.of(20, 50, 100).contains(size)) throw validation("分页参数无效");
+ long offset = (long) (page - 1) * size;
+ if (offset > Integer.MAX_VALUE) throw validation("分页参数超出范围");
+ return new Page(page, size, (int) offset);
+ }
+
+ private String eventOrderBy(String sort) {
+ return orderBy(sort, "businessDate,asc", Map.of(
+ "period", "event.period", "businessDate", "event.business_date",
+ "amount", "event.amount", "updatedAt", "event.created_at"
+ ));
+ }
+
+ private String voucherOrderBy(String sort) {
+ return orderBy(sort, "updatedAt,desc", Map.of(
+ "period", "voucher.period", "businessDate", "voucher.business_date",
+ "amount", "voucher.debit_total", "updatedAt", "voucher.updated_at"
+ ));
+ }
+
+ private String orderBy(String sort, String defaultSort, Map columns) {
+ String value = blank(sort) == null ? defaultSort : blank(sort);
+ String[] parts = value.split(",", -1);
+ if (parts.length != 2 || !columns.containsKey(parts[0])
+ || !("asc".equalsIgnoreCase(parts[1]) || "desc".equalsIgnoreCase(parts[1]))) {
+ throw validation("排序参数无效");
+ }
+ return columns.get(parts[0]) + " " + parts[1].toUpperCase(Locale.ROOT);
+ }
+
+ private String optionalPeriod(String value) {
+ String cleaned = blank(value);
+ if (cleaned != null && !PERIOD.matcher(cleaned).matches()) throw validation("会计期间格式无效");
+ return cleaned;
+ }
+
+ private String optionalId(String value) {
+ String cleaned = blank(value);
+ return cleaned == null ? null : publicId(cleaned);
+ }
+
+ private String optionalEventType(String value) {
+ String cleaned = blank(value);
+ if (cleaned == null) return null;
+ String normalized = cleaned.toUpperCase(Locale.ROOT);
+ if (!EVENT_TYPES.contains(normalized)) throw validation("财务事件类型无效");
+ return normalized;
+ }
+
+ private String optionalStatus(String value) {
+ String cleaned = blank(value);
+ if (cleaned == null) return null;
+ String normalized = cleaned.toUpperCase(Locale.ROOT);
+ if (!VOUCHER_STATUSES.contains(normalized)) throw validation("凭证状态无效");
+ return normalized;
+ }
+
+ private String publicId(String value) {
+ String cleaned = blank(value);
+ String normalized = cleaned == null ? null : cleaned.toUpperCase(Locale.ROOT);
+ if (normalized == null || !PUBLIC_ID.matcher(normalized).matches()) throw validation("公开编号格式无效");
+ return normalized;
+ }
+
+ private String clean(String value, int maxLength) {
+ String cleaned = blank(value);
+ if (cleaned != null && cleaned.length() > maxLength) throw validation("查询文本长度超出限制");
+ return cleaned;
+ }
+
+ private String blank(String value) {
+ if (value == null) return null;
+ String cleaned = value.trim();
+ return cleaned.isEmpty() ? null : cleaned;
+ }
+
+ private BigDecimal money(BigDecimal value) {
+ if (value == null || value.signum() <= 0 || value.scale() > 2) throw validation("金额必须大于零且最多两位小数");
+ return value.setScale(2, RoundingMode.UNNECESSARY);
+ }
+
+ private String amount(BigDecimal value) {
+ return value.setScale(2, RoundingMode.UNNECESSARY).toPlainString();
+ }
+
+ private void requirePeriodMatchesDate(String period, java.time.LocalDate date) {
+ if (!PERIOD.matcher(period).matches()) throw validation("会计期间格式无效");
+ if (!YearMonth.from(date).equals(YearMonth.parse(period))) throw validation("会计期间必须与业务日期一致");
+ }
+
+ private EventRow sourceEvent(long eventId) {
+ EventRow event = mapper.findEventById(eventId);
+ if (event == null) throw invalidState("凭证来源财务事件不存在");
+ return event;
+ }
+
+ private void requireSourcePeriod(EventRow event, String period, java.time.LocalDate date) {
+ requirePeriodMatchesDate(period, date);
+ if (!event.period().equals(period) || !event.businessDate().equals(date)) {
+ throw validation("凭证期间和业务日期必须与来源财务事件一致");
+ }
+ }
+
+ private void requireVersion(long actual, long expected) {
+ if (actual != expected) throw conflict();
+ }
+
+ private void requireState(String current, String... allowed) {
+ if (!Set.of(allowed).contains(current)) throw invalidState("当前凭证状态不允许执行该操作");
+ }
+
+ private BusinessException validation(String message) {
+ return new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.VALIDATION_FAILED, message);
+ }
+
+ private BusinessException notFound(String message) {
+ return new BusinessException(HttpStatus.NOT_FOUND, ErrorCode.RESOURCE_NOT_FOUND, message);
+ }
+
+ private BusinessException conflict() {
+ return new BusinessException(HttpStatus.CONFLICT, ErrorCode.VERSION_CONFLICT, "数据已被其他人更新,请刷新后重试");
+ }
+
+ private BusinessException invalidState(String message) {
+ return new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.INVALID_STATE_TRANSITION, message);
+ }
+
+ private BusinessException sod(String message) {
+ return new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.SOD_VIOLATION, message);
+ }
+
+ private BusinessException duplicate(String message) {
+ return new BusinessException(HttpStatus.CONFLICT, ErrorCode.DUPLICATE_RESOURCE, message);
+ }
+
+ private record Actor(long userId, String roleCode) {
+ }
+
+ private record Page(int page, int size, int offset) {
+ }
+
+ private record Rule(String debitAccount, String creditAccount) {
+ }
+
+ private record RuleTemplate(String version, String snapshotJson, Map rules) {
+ private Rule rule(String eventType) {
+ Rule result = rules.get(eventType);
+ if (result == null) throw new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY,
+ ErrorCode.VALIDATION_FAILED, "财务事件类型没有可用的凭证规则");
+ return result;
+ }
+ }
+
+ private record ResolvedEntry(int lineNo, String direction, AccountRow account, BigDecimal amount,
+ String summary, String auxiliaryJson) {
+ }
+
+ private record Totals(BigDecimal debit, BigDecimal credit) {
+ }
+
+ private record ResultSnapshot(
+ String externalVoucherNo,
+ LocalDate resultAt,
+ String resultRecordedByName,
+ java.time.LocalDateTime resultRecordedAt,
+ String resultRecordRemark,
+ String resultVerifiedByName,
+ java.time.LocalDateTime resultVerifiedAt,
+ String resultVerifyOpinion,
+ List evidenceFiles
+ ) {
+ }
+}
diff --git a/backend/src/main/java/com/kaidi/finance/accounting/infrastructure/AccountingMapper.java b/backend/src/main/java/com/kaidi/finance/accounting/infrastructure/AccountingMapper.java
new file mode 100644
index 0000000..41aed24
--- /dev/null
+++ b/backend/src/main/java/com/kaidi/finance/accounting/infrastructure/AccountingMapper.java
@@ -0,0 +1,757 @@
+package com.kaidi.finance.accounting.infrastructure;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.List;
+import org.apache.ibatis.annotations.Delete;
+import org.apache.ibatis.annotations.Insert;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+import org.apache.ibatis.annotations.Update;
+
+@org.apache.ibatis.annotations.Mapper
+public interface AccountingMapper {
+
+ String EVENT_SELECT = """
+ SELECT event.id, event.public_id, event.business_no, event.event_type, event.source_type,
+ event.source_public_id,
+ COALESCE(source_receipt.business_no, source_invoice.business_no,
+ source_payable.business_no, source_payment.business_no) AS source_business_no,
+ event.source_version, event.event_sequence,
+ event.source_event_key, event.company_id, company.public_id AS company_public_id,
+ company.business_no AS company_code, company.name AS company_name,
+ event.project_id, project.public_id AS project_public_id,
+ project.business_no AS project_code, project.name AS project_name,
+ event.counterparty_id, counterparty.public_id AS counterparty_public_id,
+ counterparty.business_no AS counterparty_code, counterparty.name AS counterparty_name,
+ event.business_date, event.period, event.amount, event.currency, event.status,
+ event.voucher_id, voucher.public_id AS voucher_public_id, voucher.status AS voucher_status,
+ event.version, event.created_at
+ FROM acc_event event
+ JOIN md_company company ON company.id = event.company_id
+ LEFT JOIN md_project project ON project.id = event.project_id
+ LEFT JOIN md_counterparty counterparty ON counterparty.id = event.counterparty_id
+ LEFT JOIN acc_voucher voucher ON voucher.id = event.voucher_id
+ LEFT JOIN fin_receipt source_receipt
+ ON event.source_type = 'RECEIPT' AND source_receipt.public_id = event.source_public_id
+ LEFT JOIN fin_invoice source_invoice
+ ON event.source_type = 'INVOICE' AND source_invoice.public_id = event.source_public_id
+ LEFT JOIN fin_payable source_payable
+ ON event.source_type = 'PAYABLE' AND source_payable.public_id = event.source_public_id
+ LEFT JOIN fin_payment_request source_payment
+ ON event.source_type IN ('PAYMENT', 'PAYMENT_REFUND')
+ AND source_payment.public_id = event.source_public_id
+ """ + " ";
+
+ String VOUCHER_SELECT = """
+ SELECT voucher.id, voucher.public_id, voucher.business_no, voucher.event_id,
+ event.public_id AS event_public_id, event.business_no AS event_business_no,
+ voucher.company_id, company.public_id AS company_public_id,
+ company.business_no AS company_code, company.name AS company_name,
+ voucher.project_id, project.public_id AS project_public_id,
+ project.business_no AS project_code, project.name AS project_name,
+ voucher.rule_version, CAST(voucher.rule_snapshot_json AS CHAR) AS rule_snapshot_json,
+ voucher.period, voucher.business_date, voucher.summary, event.amount AS event_amount,
+ voucher.debit_total, voucher.credit_total, voucher.status,
+ voucher.submitted_by, submitter.display_name AS submitted_by_name,
+ voucher.reviewed_by, reviewer.display_name AS reviewed_by_name,
+ voucher.exported_at, voucher.export_sha256, voucher.export_batch_no,
+ export_file.public_id AS export_file_id,
+ voucher.external_voucher_no, voucher.result_at, voucher.result_recorded_by,
+ recorder.display_name AS result_recorded_by_name, voucher.result_recorded_at,
+ voucher.result_record_remark,
+ voucher.result_verified_by, verifier.display_name AS result_verified_by_name,
+ voucher.result_verified_at, voucher.result_verify_opinion, voucher.result_cycle_no,
+ voucher.void_reason,
+ voucher.last_change_reason, voucher.created_by, voucher.updated_by,
+ voucher.version, voucher.created_at, voucher.updated_at
+ FROM acc_voucher voucher
+ JOIN acc_event event ON event.id = voucher.event_id
+ JOIN md_company company ON company.id = voucher.company_id
+ LEFT JOIN md_project project ON project.id = voucher.project_id
+ LEFT JOIN iam_user submitter ON submitter.id = voucher.submitted_by
+ LEFT JOIN iam_user reviewer ON reviewer.id = voucher.reviewed_by
+ LEFT JOIN iam_user recorder ON recorder.id = voucher.result_recorded_by
+ LEFT JOIN iam_user verifier ON verifier.id = voucher.result_verified_by
+ LEFT JOIN acc_export_batch export_batch ON export_batch.voucher_id = voucher.id
+ LEFT JOIN file_object export_file ON export_file.id = export_batch.file_id
+ """ + " ";
+
+ String EVENT_SCOPE = " " + """
+ EXISTS (
+ SELECT 1
+ FROM iam_scope scope
+ JOIN iam_role role ON role.id = scope.role_id
+ JOIN iam_permission permission ON permission.id = scope.permission_id
+ WHERE scope.user_id = #{userId}
+ AND role.code = #{roleCode}
+ AND permission.code = 'accounting:event:view'
+ AND scope.status = 'ACTIVE'
+ AND scope.valid_from <= UTC_TIMESTAMP(3)
+ AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3))
+ AND (
+ scope.scope_type = 'GLOBAL'
+ OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id)
+ OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id)
+ )
+ AND (scope.amount_limit IS NULL OR event.amount <= scope.amount_limit)
+ )
+ """ + " ";
+
+ String VOUCHER_SCOPE = " " + """
+ EXISTS (
+ SELECT 1
+ FROM iam_scope scope
+ JOIN iam_role role ON role.id = scope.role_id
+ JOIN iam_permission permission ON permission.id = scope.permission_id
+ WHERE scope.user_id = #{userId}
+ AND role.code = #{roleCode}
+ AND permission.code = 'accounting:voucher:view'
+ AND scope.status = 'ACTIVE'
+ AND scope.valid_from <= UTC_TIMESTAMP(3)
+ AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3))
+ AND (
+ scope.scope_type = 'GLOBAL'
+ OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id)
+ OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id)
+ )
+ AND (scope.amount_limit IS NULL OR event.amount <= scope.amount_limit)
+ )
+ """ + " ";
+
+ @Select("""
+ SELECT account.id, account.public_id, account.code, account.name,
+ account.account_type, account.auxiliary_required, account.status
+ FROM md_account account
+ WHERE account.status = 'ACTIVE'
+ ORDER BY account.code
+ """)
+ List listAccounts();
+
+ @Select("""
+
+ """)
+ List listEvents(@Param("userId") long userId,
+ @Param("roleCode") String roleCode,
+ @Param("status") String status,
+ @Param("period") String period,
+ @Param("projectId") String projectId,
+ @Param("eventType") String eventType,
+ @Param("keyword") String keyword,
+ @Param("orderBy") String orderBy,
+ @Param("limit") int limit,
+ @Param("offset") int offset);
+
+ @Select("""
+
+ """)
+ long countEvents(@Param("userId") long userId,
+ @Param("roleCode") String roleCode,
+ @Param("status") String status,
+ @Param("period") String period,
+ @Param("projectId") String projectId,
+ @Param("eventType") String eventType,
+ @Param("keyword") String keyword);
+
+ @Select("""
+
+ """)
+ List listVouchers(@Param("userId") long userId,
+ @Param("roleCode") String roleCode,
+ @Param("tab") String tab,
+ @Param("period") String period,
+ @Param("projectId") String projectId,
+ @Param("eventType") String eventType,
+ @Param("status") String status,
+ @Param("externalVoucherNo") String externalVoucherNo,
+ @Param("keyword") String keyword,
+ @Param("orderBy") String orderBy,
+ @Param("limit") int limit,
+ @Param("offset") int offset);
+
+ @Select("""
+
+ """)
+ long countVouchers(@Param("userId") long userId,
+ @Param("roleCode") String roleCode,
+ @Param("tab") String tab,
+ @Param("period") String period,
+ @Param("projectId") String projectId,
+ @Param("eventType") String eventType,
+ @Param("status") String status,
+ @Param("externalVoucherNo") String externalVoucherNo,
+ @Param("keyword") String keyword);
+
+ @Select(EVENT_SELECT + " WHERE event.public_id = #{publicId}")
+ EventRow findEvent(String publicId);
+
+ @Select(EVENT_SELECT + " WHERE event.id = #{id}")
+ EventRow findEventById(long id);
+
+ @Select(EVENT_SELECT + " WHERE event.id = #{id} FOR UPDATE")
+ EventRow lockEvent(long id);
+
+ @Select(VOUCHER_SELECT + " WHERE voucher.public_id = #{publicId}")
+ VoucherRow findVoucher(String publicId);
+
+ @Select(VOUCHER_SELECT + " WHERE voucher.id = #{id}")
+ VoucherRow findVoucherById(long id);
+
+ @Select("""
+ SELECT id, public_id, event_id, company_id, project_id, period, business_date,
+ summary, debit_total, credit_total, status, submitted_by, reviewed_by,
+ result_recorded_by, result_verified_by, result_cycle_no, version, created_by
+ FROM acc_voucher WHERE id = #{id} FOR UPDATE
+ """)
+ VoucherLock lockVoucher(long id);
+
+ @Select("""
+ SELECT entry.id, entry.public_id, entry.line_no, entry.direction, entry.account_id,
+ account.public_id AS account_public_id, account.code AS account_code,
+ account.name AS account_name, account.auxiliary_required,
+ account.status AS account_status,
+ entry.amount, entry.summary, CAST(entry.auxiliary_json AS CHAR) AS auxiliary_json
+ FROM acc_voucher_entry entry
+ JOIN md_account account ON account.id = entry.account_id
+ WHERE entry.voucher_id = #{voucherId}
+ ORDER BY entry.line_no
+ """)
+ List listEntries(long voucherId);
+
+ @Select("""
+ SELECT account.id, account.public_id, account.code, account.name,
+ account.account_type, account.auxiliary_required, account.status
+ FROM md_account account WHERE account.public_id = #{publicId}
+ """)
+ AccountRow findAccount(String publicId);
+
+ @Select("SELECT id FROM md_account WHERE code = #{code} AND status = 'ACTIVE'")
+ Long activeAccountId(String code);
+
+ @Select("""
+ SELECT id, public_id, version_no, CAST(value_json AS CHAR) AS value_json, status
+ FROM sys_parameter_version
+ WHERE parameter_group = 'ACCOUNTING_RULE_TEMPLATE'
+ AND status = 'ACTIVE' AND effective_at IS NOT NULL
+ AND effective_at <= UTC_TIMESTAMP(3)
+ AND JSON_UNQUOTE(JSON_EXTRACT(value_json, '$.ruleVersion')) = #{ruleVersion}
+ ORDER BY version_no DESC
+ LIMIT 1
+ """)
+ RuleParameterRow findAccountingRuleTemplate(String ruleVersion);
+
+ @Insert("""
+ INSERT INTO acc_voucher (
+ public_id, business_no, event_id, company_id, project_id, rule_version, rule_snapshot_json,
+ period, business_date,
+ summary, debit_total, credit_total, status, created_by, updated_by
+ ) VALUES (
+ #{publicId}, #{businessNo}, #{eventId}, #{companyId}, #{projectId}, #{ruleVersion},
+ CAST(#{ruleSnapshotJson} AS JSON), #{period}, #{businessDate},
+ #{summary}, #{amount}, #{amount}, 'DRAFT', #{actorId}, #{actorId}
+ )
+ """)
+ int insertVoucher(@Param("publicId") String publicId,
+ @Param("businessNo") String businessNo,
+ @Param("eventId") long eventId,
+ @Param("companyId") long companyId,
+ @Param("projectId") Long projectId,
+ @Param("ruleVersion") String ruleVersion,
+ @Param("ruleSnapshotJson") String ruleSnapshotJson,
+ @Param("period") String period,
+ @Param("businessDate") LocalDate businessDate,
+ @Param("summary") String summary,
+ @Param("amount") BigDecimal amount,
+ @Param("actorId") long actorId);
+
+ @Select("SELECT id FROM acc_voucher WHERE public_id = #{publicId}")
+ Long voucherId(String publicId);
+
+ @Insert("""
+ INSERT INTO acc_voucher_entry
+ (public_id, voucher_id, line_no, direction, account_id, amount, summary, auxiliary_json)
+ VALUES
+ (#{publicId}, #{voucherId}, #{lineNo}, #{direction}, #{accountId}, #{amount}, #{summary},
+ CASE WHEN #{auxiliaryJson} IS NULL THEN NULL ELSE CAST(#{auxiliaryJson} AS JSON) END)
+ """)
+ int insertEntry(@Param("publicId") String publicId,
+ @Param("voucherId") long voucherId,
+ @Param("lineNo") int lineNo,
+ @Param("direction") String direction,
+ @Param("accountId") long accountId,
+ @Param("amount") BigDecimal amount,
+ @Param("summary") String summary,
+ @Param("auxiliaryJson") String auxiliaryJson);
+
+ @Update("""
+ UPDATE acc_event
+ SET status = 'DRAFTED', voucher_id = #{voucherId}, version = version + 1
+ WHERE id = #{eventId} AND version = #{version} AND status = 'PENDING'
+ """)
+ int markEventDrafted(@Param("eventId") long eventId,
+ @Param("voucherId") long voucherId,
+ @Param("version") long version);
+
+ @Update("""
+ UPDATE acc_voucher
+ SET period = #{period}, business_date = #{businessDate}, summary = #{summary},
+ debit_total = #{debitTotal}, credit_total = #{creditTotal},
+ last_change_reason = #{changeReason}, updated_by = #{actorId}, version = version + 1
+ WHERE id = #{id} AND version = #{version} AND status IN ('DRAFT', 'RETURNED')
+ """)
+ int updateVoucher(@Param("id") long id,
+ @Param("version") long version,
+ @Param("period") String period,
+ @Param("businessDate") LocalDate businessDate,
+ @Param("summary") String summary,
+ @Param("debitTotal") BigDecimal debitTotal,
+ @Param("creditTotal") BigDecimal creditTotal,
+ @Param("changeReason") String changeReason,
+ @Param("actorId") long actorId);
+
+ @Delete("DELETE FROM acc_voucher_entry WHERE voucher_id = #{voucherId}")
+ int deleteEntries(long voucherId);
+
+ @Insert("""
+ INSERT INTO acc_voucher_change
+ (public_id, voucher_id, reason, before_json, after_json, changed_by)
+ VALUES
+ (#{publicId}, #{voucherId}, #{reason}, CAST(#{beforeJson} AS JSON), CAST(#{afterJson} AS JSON), #{actorId})
+ """)
+ int insertChange(@Param("publicId") String publicId,
+ @Param("voucherId") long voucherId,
+ @Param("reason") String reason,
+ @Param("beforeJson") String beforeJson,
+ @Param("afterJson") String afterJson,
+ @Param("actorId") long actorId);
+
+ @Update("""
+ UPDATE acc_voucher
+ SET status = 'REVIEWING', submitted_by = #{actorId}, reviewed_by = NULL,
+ updated_by = #{actorId}, version = version + 1
+ WHERE id = #{id} AND version = #{version} AND status IN ('DRAFT', 'RETURNED')
+ """)
+ int submitVoucher(@Param("id") long id, @Param("version") long version, @Param("actorId") long actorId);
+
+ @Update("""
+ UPDATE acc_voucher
+ SET status = 'RETURNED', reviewed_by = #{actorId}, last_change_reason = #{opinion},
+ updated_by = #{actorId}, version = version + 1
+ WHERE id = #{id} AND version = #{version} AND status = 'REVIEWING'
+ """)
+ int returnVoucher(@Param("id") long id,
+ @Param("version") long version,
+ @Param("opinion") String opinion,
+ @Param("actorId") long actorId);
+
+ @Update("""
+ UPDATE acc_voucher
+ SET status = 'APPROVED', reviewed_by = #{actorId}, updated_by = #{actorId}, version = version + 1
+ WHERE id = #{id} AND version = #{version} AND status = 'REVIEWING'
+ """)
+ int approveVoucher(@Param("id") long id, @Param("version") long version, @Param("actorId") long actorId);
+
+ @Update("""
+ UPDATE acc_voucher
+ SET status = 'EXPORTED', exported_at = UTC_TIMESTAMP(3), export_sha256 = #{sha256},
+ export_batch_no = #{batchNo}, updated_by = #{actorId}, version = version + 1
+ WHERE id = #{id} AND version = #{version} AND status = 'APPROVED'
+ """)
+ int markExported(@Param("id") long id,
+ @Param("version") long version,
+ @Param("sha256") String sha256,
+ @Param("batchNo") String batchNo,
+ @Param("actorId") long actorId);
+
+ @Update("""
+ UPDATE acc_voucher
+ SET status = 'RESULT_RECORDED', external_voucher_no = #{externalVoucherNo},
+ result_at = #{resultAt}, result_record_remark = #{remark}, result_recorded_by = #{actorId},
+ result_recorded_at = UTC_TIMESTAMP(3), result_verified_by = NULL,
+ result_verified_at = NULL, result_verify_opinion = NULL,
+ updated_by = #{actorId}, version = version + 1
+ WHERE id = #{id} AND version = #{version} AND status = 'EXPORTED'
+ """)
+ int recordResult(@Param("id") long id,
+ @Param("version") long version,
+ @Param("externalVoucherNo") String externalVoucherNo,
+ @Param("resultAt") LocalDate resultAt,
+ @Param("remark") String remark,
+ @Param("actorId") long actorId);
+
+ @Update("""
+ UPDATE acc_voucher
+ SET status = 'COMPLETED', result_verified_by = #{actorId},
+ result_verified_at = UTC_TIMESTAMP(3), result_verify_opinion = #{opinion},
+ updated_by = #{actorId}, version = version + 1
+ WHERE id = #{id} AND version = #{version} AND status = 'RESULT_RECORDED'
+ """)
+ int verifyResult(@Param("id") long id,
+ @Param("version") long version,
+ @Param("opinion") String opinion,
+ @Param("actorId") long actorId);
+
+ @Update("""
+ UPDATE acc_voucher
+ SET status = 'RESULT_REVERSED', updated_by = #{actorId}, version = version + 1
+ WHERE id = #{id} AND version = #{version} AND status = 'COMPLETED'
+ """)
+ int reverseResult(@Param("id") long id,
+ @Param("version") long version,
+ @Param("actorId") long actorId);
+
+ @Update("""
+ UPDATE acc_event
+ SET status = 'DRAFTED', version = version + 1
+ WHERE id = #{eventId} AND voucher_id = #{voucherId} AND status = 'COMPLETED'
+ """)
+ int reopenCompletedEvent(@Param("eventId") long eventId, @Param("voucherId") long voucherId);
+
+ @Update("""
+ UPDATE acc_voucher
+ SET status = 'EXPORTED', external_voucher_no = NULL, result_at = NULL,
+ result_recorded_by = NULL, result_recorded_at = NULL, result_record_remark = NULL,
+ result_verified_by = NULL, result_verified_at = NULL, result_verify_opinion = NULL,
+ result_cycle_no = result_cycle_no + 1,
+ updated_by = #{actorId}, version = version + 1
+ WHERE id = #{id} AND version = #{version} AND status = 'RESULT_REVERSED'
+ """)
+ int reopenResult(@Param("id") long id,
+ @Param("version") long version,
+ @Param("actorId") long actorId);
+
+ @Update("""
+ UPDATE acc_voucher
+ SET status = 'VOID', void_reason = #{reason}, updated_by = #{actorId}, version = version + 1
+ WHERE id = #{id} AND version = #{version} AND status IN ('DRAFT', 'RETURNED')
+ """)
+ int voidVoucher(@Param("id") long id,
+ @Param("version") long version,
+ @Param("reason") String reason,
+ @Param("actorId") long actorId);
+
+ @Update("""
+ UPDATE acc_event SET status = #{status}, version = version + 1
+ WHERE id = #{eventId} AND status = 'DRAFTED'
+ """)
+ int completeEvent(@Param("eventId") long eventId, @Param("status") String status);
+
+ @Select("""
+ SELECT COUNT(*)
+ FROM acc_voucher_result_action
+ WHERE company_id = #{companyId} AND period = #{period}
+ AND historical_external_voucher_no = #{externalVoucherNo}
+ """)
+ int historicalExternalVoucherNoExists(@Param("companyId") long companyId,
+ @Param("period") String period,
+ @Param("externalVoucherNo") String externalVoucherNo);
+
+ @Select("""
+ SELECT COALESCE(MAX(sequence_no), 0) + 1
+ FROM acc_voucher_result_action
+ WHERE voucher_id = #{voucherId}
+ """)
+ int nextResultActionSequence(long voucherId);
+
+ @Insert("""
+ INSERT INTO acc_voucher_result_action (
+ public_id, voucher_id, company_id, period, sequence_no, result_cycle_no, action_type,
+ reason, historical_external_voucher_no, result_snapshot_json, operated_by,
+ version_before, version_after
+ ) VALUES (
+ #{publicId}, #{voucherId}, #{companyId}, #{period}, #{sequenceNo}, #{resultCycleNo}, #{actionType},
+ #{reason}, #{historicalExternalVoucherNo}, CAST(#{resultSnapshotJson} AS JSON), #{actorId},
+ #{versionBefore}, #{versionAfter}
+ )
+ """)
+ int insertResultAction(@Param("publicId") String publicId,
+ @Param("voucherId") long voucherId,
+ @Param("companyId") long companyId,
+ @Param("period") String period,
+ @Param("sequenceNo") int sequenceNo,
+ @Param("resultCycleNo") int resultCycleNo,
+ @Param("actionType") String actionType,
+ @Param("reason") String reason,
+ @Param("historicalExternalVoucherNo") String historicalExternalVoucherNo,
+ @Param("resultSnapshotJson") String resultSnapshotJson,
+ @Param("actorId") long actorId,
+ @Param("versionBefore") long versionBefore,
+ @Param("versionAfter") long versionAfter);
+
+ @Select("""
+ SELECT action.id, action.public_id, action.voucher_id, action.sequence_no,
+ action.result_cycle_no, action.action_type, action.reason,
+ CAST(action.result_snapshot_json AS CHAR) AS result_snapshot_json,
+ action.operated_by, operator.display_name AS operated_by_name,
+ action.operated_at, action.version_before, action.version_after
+ FROM acc_voucher_result_action action
+ JOIN iam_user operator ON operator.id = action.operated_by
+ WHERE action.voucher_id = #{voucherId}
+ ORDER BY action.sequence_no DESC
+ """)
+ List listResultActions(long voucherId);
+
+ @Select("""
+ SELECT action.id, action.public_id, action.voucher_id, action.sequence_no,
+ action.result_cycle_no, action.action_type, action.reason,
+ CAST(action.result_snapshot_json AS CHAR) AS result_snapshot_json,
+ action.operated_by, operator.display_name AS operated_by_name,
+ action.operated_at, action.version_before, action.version_after
+ FROM acc_voucher_result_action action
+ JOIN iam_user operator ON operator.id = action.operated_by
+ WHERE action.voucher_id = #{voucherId}
+ ORDER BY action.sequence_no DESC
+ LIMIT 1
+ """)
+ ResultActionRow latestResultAction(long voucherId);
+
+ @Update("""
+ UPDATE acc_event
+ SET status = 'PENDING', voucher_id = NULL, version = version + 1
+ WHERE id = #{eventId} AND status = 'DRAFTED' AND voucher_id = #{voucherId}
+ """)
+ int releaseVoidedEvent(@Param("eventId") long eventId, @Param("voucherId") long voucherId);
+
+ @Select("""
+ SELECT batch.id, batch.public_id, batch.batch_no, batch.voucher_id, batch.rule_version,
+ CAST(batch.range_json AS CHAR) AS range_json, file.public_id AS file_public_id,
+ batch.sha256, batch.row_count, batch.exported_by, batch.exported_at
+ FROM acc_export_batch batch
+ JOIN file_object file ON file.id = batch.file_id
+ WHERE batch.voucher_id = #{voucherId}
+ """)
+ ExportBatchRow findExportBatch(long voucherId);
+
+ @Insert("""
+ INSERT INTO acc_export_batch
+ (public_id, batch_no, voucher_id, rule_version, range_json, file_id, sha256, row_count,
+ exported_by)
+ SELECT #{publicId}, #{batchNo}, #{voucherId}, #{ruleVersion}, CAST(#{rangeJson} AS JSON), file.id,
+ #{sha256}, #{rowCount}, #{actorId}
+ FROM file_object file
+ WHERE file.public_id = #{filePublicId} AND file.scan_status = 'AVAILABLE'
+ """)
+ int insertExportBatch(@Param("publicId") String publicId,
+ @Param("batchNo") String batchNo,
+ @Param("voucherId") long voucherId,
+ @Param("ruleVersion") String ruleVersion,
+ @Param("rangeJson") String rangeJson,
+ @Param("filePublicId") String filePublicId,
+ @Param("sha256") String sha256,
+ @Param("rowCount") int rowCount,
+ @Param("actorId") long actorId);
+
+ @Select("""
+ SELECT file.id, file.public_id, file.original_name, file.scan_status,
+ (file.uploaded_by = #{actorPublicId}) AS uploaded_by_actor,
+ EXISTS (
+ SELECT 1
+ FROM acc_voucher_result_file result_file
+ WHERE result_file.voucher_id = #{voucherId} AND result_file.file_id = file.id
+ ) AS attached_to_voucher,
+ EXISTS (
+ SELECT 1
+ FROM file_link link
+ WHERE link.file_id = file.id AND link.active = TRUE
+ AND link.archive_status IN ('ACTIVE', 'ARCHIVED', 'FROZEN')
+ AND (link.company_public_id = #{companyPublicId}
+ OR link.project_public_id = #{projectPublicId})
+ ) AS linked_to_target
+ FROM file_object file
+ WHERE file.public_id = #{publicId}
+ """)
+ EvidenceFileRow findEvidenceFile(@Param("publicId") String publicId,
+ @Param("actorPublicId") String actorPublicId,
+ @Param("voucherId") long voucherId,
+ @Param("companyPublicId") String companyPublicId,
+ @Param("projectPublicId") String projectPublicId);
+
+ @Insert("""
+ INSERT INTO acc_voucher_result_file
+ (public_id, voucher_id, result_cycle_no, file_id, created_by)
+ VALUES (#{publicId}, #{voucherId}, #{resultCycleNo}, #{fileId}, #{actorId})
+ """)
+ int insertResultFile(@Param("publicId") String publicId,
+ @Param("voucherId") long voucherId,
+ @Param("resultCycleNo") int resultCycleNo,
+ @Param("fileId") long fileId,
+ @Param("actorId") long actorId);
+
+ @Select("""
+ SELECT file.public_id, file.original_name, file.media_type, file.size_bytes,
+ file.sha256, file.scan_status
+ FROM acc_voucher_result_file result_file
+ JOIN acc_voucher voucher ON voucher.id = result_file.voucher_id
+ JOIN file_object file ON file.id = result_file.file_id
+ WHERE result_file.voucher_id = #{voucherId}
+ AND result_file.result_cycle_no = voucher.result_cycle_no
+ ORDER BY result_file.created_at, result_file.public_id
+ """)
+ List listResultFiles(long voucherId);
+
+ @Select("""
+ SELECT file.public_id, file.original_name, file.media_type, file.size_bytes,
+ file.sha256, file.scan_status
+ FROM acc_voucher_result_file result_file
+ JOIN file_object file ON file.id = result_file.file_id
+ WHERE result_file.voucher_id = #{voucherId} AND file.public_id = #{filePublicId}
+ ORDER BY result_file.result_cycle_no DESC
+ LIMIT 1
+ """)
+ ResultFileRow findResultFile(@Param("voucherId") long voucherId,
+ @Param("filePublicId") String filePublicId);
+
+ record AccountRow(long id, String publicId, String code, String name, String accountType,
+ boolean auxiliaryRequired, String status) {
+ }
+
+ record RuleParameterRow(long id, String publicId, int versionNo, String valueJson, String status) {
+ }
+
+ record EventRow(long id, String publicId, String businessNo, String eventType, String sourceType,
+ String sourcePublicId, String sourceBusinessNo, String sourceVersion, int eventSequence,
+ String sourceEventKey,
+ long companyId, String companyPublicId, String companyCode,
+ String companyName, Long projectId, String projectPublicId, String projectCode,
+ String projectName, Long counterpartyId, String counterpartyPublicId,
+ String counterpartyCode, String counterpartyName, LocalDate businessDate,
+ String period, BigDecimal amount, String currency, String status, Long voucherId,
+ String voucherPublicId, String voucherStatus, long version, LocalDateTime createdAt) {
+ }
+
+ record VoucherRow(long id, String publicId, String businessNo, long eventId,
+ String eventPublicId, String eventBusinessNo, long companyId,
+ String companyPublicId, String companyCode, String companyName, Long projectId,
+ String projectPublicId, String projectCode, String projectName, String ruleVersion,
+ String ruleSnapshotJson, String period,
+ LocalDate businessDate, String summary, BigDecimal eventAmount, BigDecimal debitTotal,
+ BigDecimal creditTotal,
+ String status, Long submittedBy, String submittedByName, Long reviewedBy,
+ String reviewedByName, LocalDateTime exportedAt, String exportSha256,
+ String exportBatchNo, String exportFileId, String externalVoucherNo, LocalDate resultAt,
+ Long resultRecordedBy,
+ String resultRecordedByName, LocalDateTime resultRecordedAt, String resultRecordRemark,
+ Long resultVerifiedBy, String resultVerifiedByName, LocalDateTime resultVerifiedAt,
+ String resultVerifyOpinion, int resultCycleNo,
+ String voidReason, String lastChangeReason, long createdBy, long updatedBy,
+ long version, LocalDateTime createdAt, LocalDateTime updatedAt) {
+ }
+
+ record VoucherLock(long id, String publicId, long eventId, long companyId, Long projectId,
+ String period, LocalDate businessDate, String summary, BigDecimal debitTotal,
+ BigDecimal creditTotal, String status, Long submittedBy, Long reviewedBy,
+ Long resultRecordedBy, Long resultVerifiedBy, int resultCycleNo,
+ long version, long createdBy) {
+ }
+
+ record EntryRow(long id, String publicId, int lineNo, String direction, long accountId,
+ String accountPublicId, String accountCode, String accountName,
+ boolean auxiliaryRequired, String accountStatus, BigDecimal amount,
+ String summary, String auxiliaryJson) {
+ }
+
+ record ExportBatchRow(long id, String publicId, String batchNo, long voucherId, String ruleVersion,
+ String rangeJson, String filePublicId, String sha256, int rowCount,
+ long exportedBy, LocalDateTime exportedAt) {
+ }
+
+ record EvidenceFileRow(long id, String publicId, String originalName, String scanStatus,
+ boolean uploadedByActor, boolean attachedToVoucher, boolean linkedToTarget) {
+ }
+
+ record ResultFileRow(String publicId, String originalName, String mediaType, long sizeBytes,
+ String sha256, String scanStatus) {
+ }
+
+ record ResultActionRow(long id, String publicId, long voucherId, int sequenceNo,
+ int resultCycleNo, String actionType, String reason,
+ String resultSnapshotJson, long operatedBy, String operatedByName,
+ LocalDateTime operatedAt, long versionBefore, long versionAfter) {
+ }
+}
diff --git a/backend/src/main/java/com/kaidi/finance/archive/api/ArchiveContracts.java b/backend/src/main/java/com/kaidi/finance/archive/api/ArchiveContracts.java
new file mode 100644
index 0000000..85f3c7d
--- /dev/null
+++ b/backend/src/main/java/com/kaidi/finance/archive/api/ArchiveContracts.java
@@ -0,0 +1,96 @@
+package com.kaidi.finance.archive.api;
+
+import jakarta.validation.constraints.FutureOrPresent;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Pattern;
+import jakarta.validation.constraints.Size;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.List;
+
+public final class ArchiveContracts {
+
+ private ArchiveContracts() {
+ }
+
+ public record PackageCreateRequest(
+ @NotBlank @Size(max = 26) String projectId,
+ @Min(1) Integer retentionYears,
+ @Size(max = 500) String physicalLocation
+ ) {
+ }
+
+ public record PackageRevisionRequest(
+ @NotNull Long version,
+ @NotBlank @Size(min = 2, max = 1000) String reason
+ ) {
+ }
+
+ public record PackageCommandRequest(
+ @NotNull Long version,
+ @Size(max = 1000) String opinion,
+ @Size(max = 50) List<@Pattern(regexp = "[0-9A-HJKMNP-TV-Z]{26}") String> returnItemIds
+ ) {
+ }
+
+ public record FreezeRequest(
+ @NotNull Long version,
+ @NotBlank @Size(max = 1000) String reason
+ ) {
+ }
+
+ public record NotApplicableRequest(
+ @NotNull Long version,
+ @NotBlank @Size(min = 2, max = 500) String reason
+ ) {
+ }
+
+ public record NotApplicableReviewRequest(
+ @NotNull Long version,
+ @NotNull Boolean approved,
+ @NotBlank @Size(min = 2, max = 1000) String opinion
+ ) {
+ }
+
+ public record FileLinkMetadata(
+ @NotBlank @Size(max = 26) String projectId,
+ @Size(max = 26) String packageId,
+ @Size(max = 26) String packageItemId,
+ @Size(max = 26) String contractId,
+ @Size(max = 26) String counterpartyId,
+ @Size(max = 32) String formType,
+ @NotBlank @Size(max = 64) String fileType,
+ @Size(max = 64) String originalType,
+ @Size(max = 26) String replacesFileId,
+ @Size(max = 500) String replacementReason,
+ @Size(max = 500) String notApplicableReason
+ ) {
+ }
+
+ public record BorrowCreateRequest(
+ @NotBlank @Size(max = 26) String fileId,
+ @NotBlank @Size(min = 2, max = 500) String purpose,
+ @NotNull @FutureOrPresent LocalDateTime dueAt
+ ) {
+ }
+
+ public record BorrowCommandRequest(
+ @NotNull Long version,
+ @Size(min = 2, max = 1000) String opinion,
+ @Size(max = 500) String returnCondition
+ ) {
+ }
+
+ public record ReportExportRequest(
+ @Size(max = 26) String companyId,
+ @Size(max = 26) String projectId,
+ @Size(max = 100) String keyword,
+ @Size(max = 32) String status,
+ LocalDate dateFrom,
+ LocalDate dateTo,
+ @Size(max = 20) String[] columns
+ ) {
+ }
+}
diff --git a/backend/src/main/java/com/kaidi/finance/archive/api/ArchiveController.java b/backend/src/main/java/com/kaidi/finance/archive/api/ArchiveController.java
new file mode 100644
index 0000000..8045ff0
--- /dev/null
+++ b/backend/src/main/java/com/kaidi/finance/archive/api/ArchiveController.java
@@ -0,0 +1,354 @@
+package com.kaidi.finance.archive.api;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.kaidi.finance.archive.api.ArchiveContracts.BorrowCommandRequest;
+import com.kaidi.finance.archive.api.ArchiveContracts.BorrowCreateRequest;
+import com.kaidi.finance.archive.api.ArchiveContracts.FileLinkMetadata;
+import com.kaidi.finance.archive.api.ArchiveContracts.FreezeRequest;
+import com.kaidi.finance.archive.api.ArchiveContracts.NotApplicableRequest;
+import com.kaidi.finance.archive.api.ArchiveContracts.NotApplicableReviewRequest;
+import com.kaidi.finance.archive.api.ArchiveContracts.PackageCommandRequest;
+import com.kaidi.finance.archive.api.ArchiveContracts.PackageCreateRequest;
+import com.kaidi.finance.archive.api.ArchiveContracts.PackageRevisionRequest;
+import com.kaidi.finance.archive.api.ArchiveViews.ArchiveFileDetailView;
+import com.kaidi.finance.archive.api.ArchiveViews.ArchiveFileView;
+import com.kaidi.finance.archive.api.ArchiveViews.BorrowView;
+import com.kaidi.finance.archive.api.ArchiveViews.PackageView;
+import com.kaidi.finance.archive.application.ArchiveApplicationService;
+import com.kaidi.finance.shared.api.ApiResponse;
+import com.kaidi.finance.shared.api.BusinessException;
+import com.kaidi.finance.shared.api.ErrorCode;
+import com.kaidi.finance.shared.api.PageResult;
+import com.kaidi.finance.shared.idempotency.IdempotencyApplicationService;
+import io.swagger.v3.oas.annotations.Operation;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.validation.Valid;
+import java.nio.charset.StandardCharsets;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+import org.springframework.core.io.Resource;
+import org.springframework.core.io.ByteArrayResource;
+import org.springframework.http.ContentDisposition;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RequestPart;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.multipart.MultipartFile;
+import org.springframework.web.util.UriUtils;
+
+@RestController
+@RequestMapping({"/api/v1/archive", "/api/v1/archives"})
+public class ArchiveController {
+
+ private final ArchiveApplicationService service;
+ private final IdempotencyApplicationService idempotencyService;
+
+ public ArchiveController(ArchiveApplicationService service,
+ IdempotencyApplicationService idempotencyService) {
+ this.service = service;
+ this.idempotencyService = idempotencyService;
+ }
+
+ @GetMapping("/packages")
+ @Operation(operationId = "listArchivePackages", summary = "查询项目档案包")
+ @PreAuthorize("@authorizationService.hasPermission('archive:package:view')")
+ public ApiResponse> packages(
+ @RequestParam(required = false) String view,
+ @RequestParam(required = false) String companyId,
+ @RequestParam(required = false) String projectId,
+ @RequestParam(required = false) String status,
+ @RequestParam(required = false) String completeness,
+ @RequestParam(required = false) Boolean frozen,
+ @RequestParam(required = false) String keyword,
+ @RequestParam(defaultValue = "updatedAt,desc") String sort,
+ @RequestParam(defaultValue = "1") int page,
+ @RequestParam(defaultValue = "20") int size) {
+ PageResult result = service.listPackages(view, companyId, projectId, status, completeness,
+ frozen, keyword, sort, page, size);
+ return ApiResponse.ok(result.items(), result.meta());
+ }
+
+ @GetMapping("/packages/{publicId}")
+ @Operation(operationId = "getArchivePackage", summary = "查询档案包详情")
+ @PreAuthorize("@authorizationService.hasPermission('archive:package:view')")
+ public ApiResponse packageDetail(@PathVariable String publicId) {
+ return ApiResponse.ok(service.getPackage(publicId));
+ }
+
+ @PostMapping("/packages")
+ @Operation(operationId = "createArchivePackage", summary = "生成档案包")
+ @PreAuthorize("@authorizationService.hasPermission('archive:package:create')")
+ public ApiResponse createPackage(@Valid @RequestBody PackageCreateRequest request,
+ @RequestHeader(value = "Idempotency-Key", required = false) String key,
+ HttpServletRequest httpRequest) {
+ return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { },
+ () -> service.createPackage(request)));
+ }
+
+ @PostMapping("/packages/{publicId}/check")
+ @Operation(operationId = "checkArchivePackage", summary = "检查档案包完整性")
+ @PreAuthorize("@authorizationService.hasPermission('archive:package:submit')")
+ public ApiResponse checkPackage(@PathVariable String publicId,
+ @Valid @RequestBody PackageCommandRequest request,
+ @RequestHeader(value = "Idempotency-Key", required = false) String key,
+ HttpServletRequest httpRequest) {
+ return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { },
+ () -> service.checkPackage(publicId, request)));
+ }
+
+ @PostMapping("/packages/{publicId}/submit")
+ @Operation(operationId = "submitArchivePackage", summary = "提交档案包审核")
+ @PreAuthorize("@authorizationService.hasPermission('archive:package:submit')")
+ public ApiResponse submitPackage(@PathVariable String publicId,
+ @Valid @RequestBody PackageCommandRequest request,
+ @RequestHeader(value = "Idempotency-Key", required = false) String key,
+ HttpServletRequest httpRequest) {
+ return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { },
+ () -> service.submitPackage(publicId, request)));
+ }
+
+ @PostMapping("/packages/{publicId}/return")
+ @Operation(operationId = "returnArchivePackage", summary = "退回档案包")
+ @PreAuthorize("@authorizationService.hasPermission('archive:package:review')")
+ public ApiResponse returnPackage(@PathVariable String publicId,
+ @Valid @RequestBody PackageCommandRequest request,
+ @RequestHeader(value = "Idempotency-Key", required = false) String key,
+ HttpServletRequest httpRequest) {
+ return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { },
+ () -> service.returnPackage(publicId, request)));
+ }
+
+ @PostMapping("/packages/{publicId}/revise")
+ @Operation(operationId = "reviseArchivePackage", summary = "生成档案包补件版本")
+ @PreAuthorize("@authorizationService.hasPermission('archive:package:submit')")
+ public ApiResponse revisePackage(@PathVariable String publicId,
+ @Valid @RequestBody PackageRevisionRequest request,
+ @RequestHeader(value = "Idempotency-Key", required = false) String key,
+ HttpServletRequest httpRequest) {
+ return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { },
+ () -> service.revisePackage(publicId, request)));
+ }
+
+ @PostMapping("/packages/{publicId}/archive")
+ @Operation(operationId = "archivePackage", summary = "确认归档")
+ @PreAuthorize("@authorizationService.hasPermission('archive:package:archive')")
+ public ApiResponse archivePackage(@PathVariable String publicId,
+ @Valid @RequestBody PackageCommandRequest request,
+ @RequestHeader(value = "Idempotency-Key", required = false) String key,
+ HttpServletRequest httpRequest) {
+ return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { },
+ () -> service.archivePackage(publicId, request)));
+ }
+
+ @PostMapping("/packages/{publicId}/freeze")
+ @Operation(operationId = "freezeArchivePackage", summary = "冻结档案包")
+ @PreAuthorize("@authorizationService.hasPermission('archive:package:freeze')")
+ public ApiResponse freezePackage(@PathVariable String publicId,
+ @Valid @RequestBody FreezeRequest request,
+ @RequestHeader(value = "Idempotency-Key", required = false) String key,
+ HttpServletRequest httpRequest) {
+ return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { },
+ () -> service.freezePackage(publicId, request)));
+ }
+
+ @PostMapping("/packages/{publicId}/unfreeze")
+ @Operation(operationId = "unfreezeArchivePackage", summary = "解冻档案包")
+ @PreAuthorize("@authorizationService.hasPermission('archive:package:unfreeze')")
+ public ApiResponse unfreezePackage(@PathVariable String publicId,
+ @Valid @RequestBody PackageCommandRequest request,
+ @RequestHeader(value = "Idempotency-Key", required = false) String key,
+ HttpServletRequest httpRequest) {
+ return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { },
+ () -> service.unfreezePackage(publicId, request)));
+ }
+
+ @PostMapping("/packages/{packageId}/items/{itemId}/not-applicable")
+ @Operation(operationId = "markArchiveItemNotApplicable", summary = "申请档案项不涉及")
+ @PreAuthorize("@authorizationService.hasPermission('archive:package:submit')")
+ public ApiResponse notApplicable(@PathVariable String packageId, @PathVariable String itemId,
+ @Valid @RequestBody NotApplicableRequest request,
+ @RequestHeader(value = "Idempotency-Key", required = false) String key,
+ HttpServletRequest httpRequest) {
+ return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { },
+ () -> service.markNotApplicable(packageId, itemId, request)));
+ }
+
+ @PostMapping("/packages/{packageId}/items/{itemId}/not-applicable/review")
+ @Operation(operationId = "reviewArchiveItemNotApplicable", summary = "复核档案项不涉及")
+ @PreAuthorize("@authorizationService.hasPermission('archive:package:na-review')")
+ public ApiResponse reviewNotApplicable(
+ @PathVariable String packageId, @PathVariable String itemId,
+ @Valid @RequestBody NotApplicableReviewRequest request,
+ @RequestHeader(value = "Idempotency-Key", required = false) String key,
+ HttpServletRequest httpRequest) {
+ return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { },
+ () -> service.reviewNotApplicable(packageId, itemId, request)));
+ }
+
+ @PostMapping(value = "/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+ @Operation(operationId = "uploadArchiveFile", summary = "上传档案文件")
+ @PreAuthorize("@authorizationService.hasPermission('archive:file:upload')")
+ public ApiResponse uploadFile(@Valid @RequestPart("metadata") FileLinkMetadata metadata,
+ @RequestPart("file") MultipartFile file,
+ @RequestHeader(value = "Idempotency-Key", required = false) String key,
+ HttpServletRequest httpRequest) {
+ ArchiveFileView result = idempotencyService.executeMultipart(key, httpRequest.getMethod(),
+ httpRequest.getRequestURI(), metadata, file, new TypeReference() { },
+ view -> "QUARANTINED".equals(view.scanStatus()) ? 503 : 200,
+ () -> service.uploadFile(metadata, file));
+ if ("QUARANTINED".equals(result.scanStatus())) {
+ throw new BusinessException(HttpStatus.SERVICE_UNAVAILABLE, ErrorCode.FILE_SCANNER_UNAVAILABLE,
+ "文件安全检查暂不可用,文件已隔离,请稍后重试");
+ }
+ return ApiResponse.ok(result);
+ }
+
+ @GetMapping("/files")
+ @Operation(operationId = "listArchiveFiles", summary = "查询档案文件")
+ @PreAuthorize("@authorizationService.hasPermission('archive:file:view')")
+ public ApiResponse> files(
+ @RequestParam(required = false) String companyId,
+ @RequestParam(required = false) String projectId,
+ @RequestParam(required = false) String contractId,
+ @RequestParam(required = false) String counterpartyId,
+ @RequestParam(required = false) String formType,
+ @RequestParam(required = false) String fileName,
+ @RequestParam(required = false) String uploadedBy,
+ @RequestParam(required = false) LocalDate uploadedDateFrom,
+ @RequestParam(required = false) LocalDate uploadedDateTo,
+ @RequestParam(required = false) String originalType,
+ @RequestParam(required = false) String archiveStatus,
+ @RequestParam(required = false) String scanStatus,
+ @RequestParam(defaultValue = "uploadedAt,desc") String sort,
+ @RequestParam(defaultValue = "1") int page,
+ @RequestParam(defaultValue = "20") int size) {
+ PageResult result = service.listFiles(companyId, projectId, contractId, counterpartyId,
+ formType, fileName, uploadedBy, uploadedDateFrom, uploadedDateTo, originalType, archiveStatus,
+ scanStatus, sort, page, size);
+ return ApiResponse.ok(result.items(), result.meta());
+ }
+
+ @GetMapping("/files/{publicId}")
+ @Operation(operationId = "getArchiveFile", summary = "查询档案文件详情")
+ @PreAuthorize("@authorizationService.hasPermission('archive:file:view')")
+ public ApiResponse fileDetail(@PathVariable String publicId) {
+ return ApiResponse.ok(service.getFile(publicId));
+ }
+
+ @PostMapping("/files/{publicId}/rescan")
+ @Operation(operationId = "rescanArchiveFile", summary = "重新扫描业务范围内的隔离档案")
+ @PreAuthorize("@authorizationService.hasPermission('archive:file:upload')")
+ public ApiResponse rescanFile(
+ @PathVariable String publicId,
+ @RequestHeader(value = "Idempotency-Key", required = false) String key,
+ HttpServletRequest httpRequest) {
+ ArchiveFileView result = idempotencyService.execute(key, httpRequest.getMethod(),
+ httpRequest.getRequestURI(), Map.of("filePublicId", publicId),
+ new TypeReference() { },
+ view -> "QUARANTINED".equals(view.scanStatus()) ? 503
+ : "REJECTED".equals(view.scanStatus()) ? 422 : 200,
+ () -> service.rescanFile(publicId));
+ if ("QUARANTINED".equals(result.scanStatus())) {
+ throw new BusinessException(HttpStatus.SERVICE_UNAVAILABLE, ErrorCode.FILE_SCANNER_UNAVAILABLE,
+ "文件安全检查暂不可用,文件仍处于隔离状态");
+ }
+ if ("REJECTED".equals(result.scanStatus())) {
+ throw new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.FILE_REJECTED,
+ "文件未通过安全检查,已拒绝接收");
+ }
+ return ApiResponse.ok(result);
+ }
+
+ @GetMapping("/files/{publicId}/content")
+ @Operation(operationId = "getArchiveFileContent", summary = "读取档案文件内容")
+ @PreAuthorize("#preview ? @authorizationService.hasPermission('archive:file:preview') : "
+ + "@authorizationService.hasPermission('archive:file:download')")
+ public ResponseEntity fileContent(@PathVariable String publicId,
+ @RequestParam(defaultValue = "false") boolean preview) {
+ ArchiveApplicationService.ControlledDownload controlled = service.downloadFile(publicId, preview);
+ var content = controlled.content();
+ ContentDisposition disposition = (controlled.preview() ? ContentDisposition.inline() : ContentDisposition.attachment())
+ .filename(content.fileName(), StandardCharsets.UTF_8).build();
+ return ResponseEntity.ok()
+ .contentType(MediaType.parseMediaType(content.mediaType()))
+ .contentLength(content.bytes().length)
+ .header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString())
+ .header("X-Archive-Watermark", UriUtils.encode(controlled.watermark(), StandardCharsets.UTF_8))
+ .header("X-Archive-Watermark-Encoding", "uri-component")
+ .header("X-File-SHA256", content.sourceSha256())
+ .header("X-Derived-File-SHA256", content.derivedSha256())
+ .header("X-Content-Type-Options", "nosniff")
+ .body(new ByteArrayResource(content.bytes()));
+ }
+
+ @GetMapping("/borrows")
+ @Operation(operationId = "listBorrows", summary = "查询档案借阅")
+ @PreAuthorize("@authorizationService.hasPermission('archive:file:view')")
+ public ApiResponse> borrows(@RequestParam(required = false) String status,
+ @RequestParam(required = false) String keyword,
+ @RequestParam(defaultValue = "1") int page,
+ @RequestParam(defaultValue = "20") int size) {
+ PageResult result = service.listBorrows(status, keyword, page, size);
+ return ApiResponse.ok(result.items(), result.meta());
+ }
+
+ @PostMapping("/borrows")
+ @Operation(operationId = "createBorrow", summary = "申请档案借阅")
+ @PreAuthorize("@authorizationService.hasPermission('archive:borrow:apply')")
+ public ApiResponse createBorrow(@Valid @RequestBody BorrowCreateRequest request,
+ @RequestHeader(value = "Idempotency-Key", required = false) String key,
+ HttpServletRequest httpRequest) {
+ return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { },
+ () -> service.createBorrow(request)));
+ }
+
+ @PostMapping("/borrows/{publicId}/approve")
+ @Operation(operationId = "approveBorrow", summary = "批准档案借阅")
+ @PreAuthorize("@authorizationService.hasPermission('archive:borrow:approve')")
+ public ApiResponse approveBorrow(@PathVariable String publicId,
+ @Valid @RequestBody BorrowCommandRequest request,
+ @RequestHeader(value = "Idempotency-Key", required = false) String key,
+ HttpServletRequest httpRequest) {
+ return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { },
+ () -> service.approveBorrow(publicId, request)));
+ }
+
+ @PostMapping("/borrows/{publicId}/reject")
+ @Operation(operationId = "rejectBorrow", summary = "驳回档案借阅")
+ @PreAuthorize("@authorizationService.hasPermission('archive:borrow:reject')")
+ public ApiResponse rejectBorrow(@PathVariable String publicId,
+ @Valid @RequestBody BorrowCommandRequest request,
+ @RequestHeader(value = "Idempotency-Key", required = false) String key,
+ HttpServletRequest httpRequest) {
+ return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { },
+ () -> service.rejectBorrow(publicId, request)));
+ }
+
+ @PostMapping("/borrows/{publicId}/return")
+ @Operation(operationId = "returnBorrow", summary = "登记档案归还")
+ @PreAuthorize("@authorizationService.hasPermission('archive:borrow:return')")
+ public ApiResponse returnBorrow(@PathVariable String publicId,
+ @Valid @RequestBody BorrowCommandRequest request,
+ @RequestHeader(value = "Idempotency-Key", required = false) String key,
+ HttpServletRequest httpRequest) {
+ return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { },
+ () -> service.returnBorrow(publicId, request)));
+ }
+
+ private T command(String key, HttpServletRequest request, Object body, TypeReference type,
+ java.util.function.Supplier action) {
+ return idempotencyService.execute(key, request.getMethod(), request.getRequestURI(), body, type, action);
+ }
+}
diff --git a/backend/src/main/java/com/kaidi/finance/archive/api/ArchiveViews.java b/backend/src/main/java/com/kaidi/finance/archive/api/ArchiveViews.java
new file mode 100644
index 0000000..6e53b75
--- /dev/null
+++ b/backend/src/main/java/com/kaidi/finance/archive/api/ArchiveViews.java
@@ -0,0 +1,206 @@
+package com.kaidi.finance.archive.api;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+
+public final class ArchiveViews {
+
+ private ArchiveViews() {
+ }
+
+ public record ReferenceView(String publicId, String businessNo, String name) {
+ }
+
+ public record PackageItemView(
+ String publicId,
+ String itemType,
+ String itemName,
+ String objectType,
+ String objectPublicId,
+ String filePublicId,
+ String fileName,
+ String fileSha256,
+ boolean required,
+ String status,
+ String missingReason,
+ String returnReason,
+ String notApplicableReason,
+ String notApplicableRequestedByName,
+ LocalDateTime notApplicableRequestedAt,
+ String notApplicableReviewedByName,
+ LocalDateTime notApplicableReviewedAt,
+ String notApplicableReviewOpinion,
+ List allowedActions
+ ) {
+ }
+
+ public record PackageActionView(
+ int sequenceNo,
+ String actionCode,
+ String fromStatus,
+ String toStatus,
+ String opinion,
+ String actorName,
+ LocalDateTime occurredAt,
+ String manifestSha256
+ ) {
+ }
+
+ public record PackageObjectSnapshotView(
+ String publicId,
+ String itemType,
+ String objectType,
+ String objectPublicId,
+ String objectBusinessNo,
+ String objectName,
+ String objectStatus,
+ Long objectVersion,
+ String objectFormType,
+ String sourceVersionPublicId,
+ String snapshotSha256,
+ LocalDateTime capturedAt
+ ) {
+ }
+
+ public record PackageView(
+ String publicId,
+ String businessNo,
+ ReferenceView company,
+ ReferenceView project,
+ int packageVersion,
+ String rootPackagePublicId,
+ String supersedesPackagePublicId,
+ String revisionReason,
+ String ruleVersion,
+ String completeness,
+ int missingCount,
+ int retentionYears,
+ LocalDate retentionExpiresOn,
+ String retentionStatus,
+ String physicalLocation,
+ boolean frozen,
+ String status,
+ String manifestSha256,
+ String submittedByName,
+ String reviewedByName,
+ LocalDateTime submittedAt,
+ LocalDateTime archivedAt,
+ LocalDateTime updatedAt,
+ long version,
+ List allowedActions,
+ List items,
+ List objectSnapshots,
+ List actions
+ ) {
+ }
+
+ public record ArchiveFileView(
+ String publicId,
+ String displayName,
+ String mediaType,
+ String originalType,
+ long sizeBytes,
+ String sha256,
+ String scanStatus,
+ String companyPublicId,
+ String companyName,
+ String projectPublicId,
+ String projectBusinessNo,
+ String projectName,
+ String contractPublicId,
+ String counterpartyPublicId,
+ String formType,
+ String documentType,
+ String seriesPublicId,
+ int versionNo,
+ String archiveStatus,
+ String borrowStatus,
+ String packagePublicId,
+ String packageStatus,
+ String uploadedBy,
+ LocalDateTime uploadedAt,
+ boolean sensitive,
+ List allowedActions
+ ) {
+ }
+
+ public record ArchiveFileDetailView(
+ ArchiveFileView file,
+ List versions,
+ List borrows
+ ) {
+ }
+
+ public record BorrowView(
+ String publicId,
+ String businessNo,
+ String filePublicId,
+ String fileName,
+ String projectPublicId,
+ String projectName,
+ String purpose,
+ LocalDateTime dueAt,
+ String status,
+ String applicantName,
+ String approvedByName,
+ LocalDateTime approvedAt,
+ String reviewOpinion,
+ String returnCondition,
+ String returnedByName,
+ LocalDateTime returnedAt,
+ long version,
+ List allowedActions
+ ) {
+ }
+
+ public record ArchiveContentView(
+ String filePublicId,
+ String displayName,
+ String mediaType,
+ long sizeBytes,
+ String sha256,
+ String watermark,
+ boolean preview
+ ) {
+ }
+
+ public record ReportRow(
+ String rowId,
+ Map values
+ ) {
+ }
+
+ public record ReportView(
+ String reportCode,
+ String definitionVersion,
+ String filterHash,
+ List columns,
+ List rows,
+ Map summary,
+ long totalElements,
+ int page,
+ int size,
+ int totalPages,
+ List allowedActions
+ ) {
+ }
+
+ public record ReportDrilldownView(
+ String reportCode,
+ String rowId,
+ List