From a3203f95da09dccd116273c6a07e0ba4ffde768d Mon Sep 17 00:00:00 2001 From: leefer Date: Sat, 1 Aug 2026 15:59:28 +0800 Subject: [PATCH] feat: add reviewed OCR and AI document workflow --- .gitignore | 2 + config/local.providers.example.toml | 40 ++ docs/ARCHITECTURE.md | 12 + docs/LOCAL_PROVIDER_CONFIGURATION.md | 23 + docs/SMART_DOCUMENTS.md | 42 ++ src-tauri/Cargo.lock | 300 +++++++++++- src-tauri/Cargo.toml | 3 + src-tauri/src/documents.rs | 671 +++++++++++++++++++++++++++ src-tauri/src/lib.rs | 100 +++- src-tauri/src/providers.rs | 333 +++++++++++++ src/App.tsx | 218 ++++++++- src/styles.css | 70 +++ 12 files changed, 1808 insertions(+), 6 deletions(-) create mode 100644 config/local.providers.example.toml create mode 100644 docs/LOCAL_PROVIDER_CONFIGURATION.md create mode 100644 docs/SMART_DOCUMENTS.md create mode 100644 src-tauri/src/documents.rs create mode 100644 src-tauri/src/providers.rs diff --git a/.gitignore b/.gitignore index a337791..9ce322d 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,5 @@ vite.config.d.ts .env .env.* !.env.example +config/local.providers.toml +tupian/ diff --git a/config/local.providers.example.toml b/config/local.providers.example.toml new file mode 100644 index 0000000..1e15b1a --- /dev/null +++ b/config/local.providers.example.toml @@ -0,0 +1,40 @@ +# 小白记账:本地第三方服务配置模板 +# +# 使用方法: +# 1. 复制本文件并命名为 local.providers.toml(项目已配置为不提交该文件)。 +# 2. 只在复制出的文件中填写真实密钥,不要修改本示例文件。 +# 3. enabled 保持 false,填写完整并接入测试功能后再改为 true。 +# +# 注意:当前文件结构仅用于本地开发测试。正式的客户端/服务端版本中, +# 百度 OCR 与 LLM 密钥必须存放在服务端,不能随安装包分发,也不能由客户端读取。 + +[baidu_ocr] +enabled = false + +# 推荐使用 api_key_secret,由程序换取和刷新短期 access token。 +# 如果你拿到的只有现成 token,可改为 access_token。 +auth_mode = "api_key_secret" +api_key = "" +secret_key = "" +access_token = "" + +# 先保留默认值;接入时会根据你的百度 OCR 产品权限确认具体接口。 +service = "mixed_invoice" +request_timeout_seconds = 30 + +[llm] +enabled = false + +# 例如兼容 OpenAI API 的服务地址;不要在结尾填写具体模型名。 +base_url = "" +api_key = "" +model = "" + +# 当前计划首先支持 openai_compatible。若服务商协议不同,接入时再增加适配器。 +protocol = "openai_compatible" +request_timeout_seconds = 60 + +# 只控制本地调试日志,任何情况下都不会记录密钥、票据全文或完整模型输入。 +[diagnostics] +log_level = "info" +redact_sensitive_data = true diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b776c23..33a05bb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -63,3 +63,15 @@ - 桌面底层桥接:保持少量 Rust 代码,不在其中堆积业务规则 当前仓库首先实现可运行的界面与纯领域逻辑;本地数据库、Tauri 容器和云端服务按阶段接入,不用模拟实现冒充生产能力。 + +## OCR 与 LLM 迁移边界 + +当前纯本地测试版通过 `providers` 适配层访问百度 OCR 和 OpenAI 兼容 LLM。`documents` 模块只依赖统一的识别与建议函数,不读取供应商密钥,也不拼装 HTTP 请求。这样迁移到服务端时,可将 `providers` 的实现移动到服务端,客户端改为调用小白记账版本化 API,票据复核页面与字段校验规则无需重写。 + +客户端与服务端分离后遵守以下边界: + +- 供应商密钥只存在服务端密钥管理系统或受保护环境变量中。 +- 客户端只持有用户登录后的短期会话凭据,不持有 OCR 或 LLM 密钥。 +- 服务端按经营主体做鉴权、限流、调用审计和数据隔离。 +- OCR 与 LLM 的响应都先经过服务端结构校验;客户端仍保留金额、日期和枚举白名单等确定性校验。 +- AI 只生成建议,不能直接写入账簿、修改税额或代替人工确认。 diff --git a/docs/LOCAL_PROVIDER_CONFIGURATION.md b/docs/LOCAL_PROVIDER_CONFIGURATION.md new file mode 100644 index 0000000..c160081 --- /dev/null +++ b/docs/LOCAL_PROVIDER_CONFIGURATION.md @@ -0,0 +1,23 @@ +# 本地 OCR 与 LLM 配置 + +## 文件用途 + +- `config/local.providers.example.toml` 是字段说明模板,可以提交到 Git,不含真实密钥。 +- `config/local.providers.toml` 是本机实际配置,已被 `.gitignore` 排除,禁止提交或发送。 +- 当前配置只服务于纯本地开发测试;票据中心会在本地读取配置并调用服务,正式客户端不会打包这份文件。 + +## 填写规则 + +百度 OCR 优先填写 `api_key` 和 `secret_key`,并保持 `auth_mode = "api_key_secret"`。程序接入后将自行获取短期 access token,不长期保存换取到的 token。只有在服务商明确仅提供现成 token 时,才填写 `access_token` 并把认证方式改为 `access_token`。 + +LLM 需要填写 `base_url`、`api_key` 和 `model`。如果接口不是 OpenAI 兼容协议,先不要启用,接入时需要按服务商官方协议增加独立适配器。 + +首次填写时两个 `enabled` 都保持 `false`,逐项确认地址和密钥后再启用。不要在聊天、截图、报错信息、文档或 Git 提交中展示真实密钥。 + +应用只向百度 OCR 发送当前主动选择的图片;发票原图、OCR 原始响应、归一化字段、AI 原始响应和人工复核记录均写入本地 SQLCipher 加密账本。LLM 只接收归一化后的必要票据信息,不接收本地数据库或其他票据。 + +## 后续迁移原则 + +客户端/服务端版本中,百度 OCR 与 LLM 的供应商密钥只保存在服务端的密钥管理系统或受保护环境变量中。Windows 客户端不再保存这些密钥,只连接小白记账服务端,并使用用户登录后取得的短期会话凭据。 + +迁移时将保留统一的 OCR、LLM 适配器接口,因此业务功能不需要因密钥位置变化而重写;主要变化是把当前本地调用实现移动到服务端 API 后面。 diff --git a/docs/SMART_DOCUMENTS.md b/docs/SMART_DOCUMENTS.md new file mode 100644 index 0000000..dee5250 --- /dev/null +++ b/docs/SMART_DOCUMENTS.md @@ -0,0 +1,42 @@ +# 智能票据闭环 + +## 当前能力 + +票据中心已经形成“主动上传 → 加密存档 → OCR 识别 → 字段核对 → AI 建议 → 人工确认”的本地闭环。发票与收付款流水是两类不同证据,因此人工确认票据不会自动生成收支流水,避免把“取得发票”误当成“已经付款”。后续应通过流水匹配或单独的凭证流程完成入账。 + +当前开放增值税发票图片,单张不超过 2.5 MB,支持 PNG、JPEG、BMP 和 WebP。文件按内容哈希去重,重复选择同一图片不会重复建档。 + +## 状态与数据 + +- `documents`:保存加密原图、内容哈希、文件类型和处理状态。 +- `ocr_runs`:追加保存每次识别的供应商、耗时、状态,以及服务可解析时的原始响应。 +- `document_extractions`:保存经过归一化的可编辑字段,金额统一使用整数分。 +- `ai_runs`:追加保存模型调用状态、服务可解析时的原始响应、校验后的建议和置信度。 +- `document_reviews`:追加保存每次人工确认时的字段快照与时间。 + +原始 OCR 结果不会因人工修改而被覆盖。失败调用同样留下审计状态,但日志和界面不显示供应商密钥。 + +## 安全阀 + +- 文件签名与大小在发出网络请求前校验。 +- OCR 只发送用户当前主动选择的图片。 +- LLM 只接收识别后的必要字段,不读取整本账簿。 +- LLM 返回必须是结构化 JSON,并通过方向、分类白名单、金额一致性、日期和置信度校验。 +- 任何解析或校验失败都只显示失败,不产生记账结果。 +- AI 建议与人工确认是两个独立动作,AI 永远不能自动入账。 + +## 服务端迁移 + +正式客户端发布前,把 `providers` 中的供应商 HTTP 调用移至服务端。客户端保留上传、复核和状态展示,通过小白记账 API 获取识别结果与建议。服务端负责供应商密钥、租户隔离、限流、重试和调用成本审计。 + +## 回归检查 + +每次修改票据功能至少验证: + +1. 不支持或超限文件在本地被拒绝。 +2. 同一文件不会重复建档。 +3. OCR 金额满足“不含税金额 + 税额 = 价税合计”的允许误差。 +4. LLM 非 JSON、非法分类或金额不一致时不会产生建议。 +5. 人工修改后保存的是新复核快照,OCR 原始结果仍可追溯。 +6. 人工确认不会改变收支流水与税务计算结果。 +7. 本机真实配置、测试票据和密钥不会进入 Git 或安装包。 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 38b4971..f097014 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -321,6 +321,23 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core", +] + [[package]] name = "chrono" version = "0.4.45" @@ -402,6 +419,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -859,6 +885,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -1039,8 +1066,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1062,8 +1091,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "rand_core", + "wasm-bindgen", ] [[package]] @@ -1331,6 +1363,22 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -1743,6 +1791,12 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "markup5ever" version = "0.38.0" @@ -2363,6 +2417,62 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.19", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.19", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.47" @@ -2384,6 +2494,32 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -2459,6 +2595,46 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + [[package]] name = "reqwest" version = "0.13.4" @@ -2493,6 +2669,20 @@ dependencies = [ "web-sys", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rusqlite" version = "0.32.1" @@ -2522,6 +2712,41 @@ dependencies = [ "semver", ] +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -2724,6 +2949,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_with" version = "3.21.0" @@ -2794,7 +3031,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -2922,6 +3159,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.7" @@ -3085,7 +3328,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest", + "reqwest 0.13.4", "serde", "serde_json", "serde_repr", @@ -3386,6 +3629,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.19" @@ -3676,6 +3929,12 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -3869,6 +4128,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "web_atoms" version = "0.2.5" @@ -3925,6 +4194,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -4155,6 +4433,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -4427,14 +4714,17 @@ dependencies = [ name = "xiaobai-bookkeeping" version = "0.9.0" dependencies = [ + "base64 0.22.1", "csv", "getrandom 0.2.17", + "reqwest 0.12.28", "rusqlite", "serde", "serde_json", "sha2", "tauri", "tauri-build", + "toml 0.8.2", "windows-sys 0.59.0", ] @@ -4502,6 +4792,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zerotrie" version = "0.2.4" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4f95ef8..8bab8ee 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -13,13 +13,16 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] +base64 = "0.22" csv = "1.3" getrandom = "0.2" +reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } rusqlite = { version = "0.32", features = ["backup", "bundled-sqlcipher-vendored-openssl"] } serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" tauri = { version = "2", features = [] } +toml = "0.8" [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Security_Cryptography"] } diff --git a/src-tauri/src/documents.rs b/src-tauri/src/documents.rs new file mode 100644 index 0000000..6b4de64 --- /dev/null +++ b/src-tauri/src/documents.rs @@ -0,0 +1,671 @@ +use crate::providers::{self, ProviderFailure}; +use crate::{open_database, parse_amount_to_cents, sha256_hex}; +use rusqlite::{params, Connection, OptionalExtension}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::BTreeMap; +use tauri::AppHandle; + +const MAX_IMAGE_BYTES: usize = 2_500_000; +const EXTRACTION_SCHEMA_VERSION: &str = "invoice-extraction-v1"; +const PROMPT_VERSION: &str = "bookkeeping-suggestion-v1"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct InvoiceLineItem { + name: String, + amount_in_cents: Option, + tax_rate: String, + tax_in_cents: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct InvoiceExtraction { + invoice_type: String, + invoice_number: String, + invoice_date: String, + purchaser_name: String, + purchaser_tax_id: String, + seller_name: String, + seller_tax_id: String, + total_amount_in_cents: Option, + total_tax_in_cents: Option, + total_with_tax_in_cents: Option, + amount_in_words: String, + drawer: String, + remarks: String, + confidence: f64, + line_items: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct BookkeepingSuggestion { + direction: String, + category: String, + counterparty: String, + occurred_on: String, + amount_in_cents: i64, + business_purpose: String, + confidence: f64, + reason: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct StoredDocument { + id: String, + file_name: String, + mime_type: String, + size_bytes: i64, + status: String, + last_error: Option, + created_at: String, + updated_at: String, + extraction: Option, + suggestion: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct RecognizeDocumentInput { + file_name: String, + mime_type: String, + bytes: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ConfirmDocumentInput { + document_id: String, + invoice_number: String, + invoice_date: String, + purchaser_name: String, + purchaser_tax_id: String, + seller_name: String, + seller_tax_id: String, + total_amount_in_cents: Option, + total_tax_in_cents: Option, + total_with_tax_in_cents: Option, +} + +fn one_word(result: &Value, name: &str) -> String { + result + .get(name) + .and_then(Value::as_array) + .and_then(|items| items.first()) + .and_then(|item| item.get("word")) + .and_then(Value::as_str) + .unwrap_or_default() + .trim() + .to_string() +} + +fn row_words(result: &Value, name: &str) -> BTreeMap { + result + .get(name) + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .enumerate() + .filter_map(|(index, item)| { + let word = item.get("word")?.as_str()?.trim(); + if word.is_empty() { + return None; + } + let row = item + .get("row") + .and_then(|value| value.as_str().map(str::to_string).or_else(|| value.as_i64().map(|number| number.to_string()))) + .unwrap_or_else(|| (index + 1).to_string()); + Some((row, word.to_string())) + }) + .collect() + }) + .unwrap_or_default() +} + +fn optional_cents(value: &str) -> Option { + if value.trim().is_empty() { + None + } else { + parse_amount_to_cents(value).ok() + } +} + +fn normalized_date(value: &str) -> String { + let normalized = value + .trim() + .replace('年', "-") + .replace('月', "-") + .replace('日', "") + .replace('/', "-") + .replace('.', "-"); + let parts: Vec<_> = normalized.split('-').collect(); + if parts.len() == 3 { + if let (Ok(year), Ok(month), Ok(day)) = ( + parts[0].parse::(), + parts[1].parse::(), + parts[2].parse::(), + ) { + if (2000..=2100).contains(&year) && (1..=12).contains(&month) && (1..=31).contains(&day) { + return format!("{year:04}-{month:02}-{day:02}"); + } + } + } + value.trim().to_string() +} + +fn build_line_items(result: &Value) -> Vec { + let names = row_words(result, "CommodityName"); + let amounts = row_words(result, "CommodityAmount"); + let rates = row_words(result, "CommodityTaxRate"); + let taxes = row_words(result, "CommodityTax"); + let mut rows = BTreeMap::::new(); + for row in names.keys().chain(amounts.keys()).chain(rates.keys()).chain(taxes.keys()) { + rows.entry(row.clone()).or_insert_with(|| InvoiceLineItem { + name: String::new(), + amount_in_cents: None, + tax_rate: String::new(), + tax_in_cents: None, + }); + } + for (row, item) in &mut rows { + item.name = names.get(row).cloned().unwrap_or_default(); + item.amount_in_cents = amounts.get(row).and_then(|value| optional_cents(value)); + item.tax_rate = rates.get(row).cloned().unwrap_or_default(); + item.tax_in_cents = taxes.get(row).and_then(|value| optional_cents(value)); + } + rows.into_values().collect() +} + +fn parse_baidu_invoice(response: &Value) -> Result { + let invoice = response + .get("words_result") + .and_then(Value::as_array) + .and_then(|items| items.first()) + .ok_or_else(|| "没有识别到可用票据".to_string())?; + let invoice_type_code = invoice.get("type").and_then(Value::as_str).unwrap_or_default(); + if invoice_type_code != "vat_invoice" { + return Err(format!("当前版本只开放增值税发票字段核对,识别类型为 {invoice_type_code}")); + } + let result = invoice.get("result").ok_or_else(|| "票据结果缺少结构化字段".to_string())?; + let type_name = one_word(result, "InvoiceType"); + let title = one_word(result, "InvoiceTypeOrg"); + Ok(InvoiceExtraction { + invoice_type: if type_name.is_empty() { title } else { type_name }, + invoice_number: one_word(result, "InvoiceNum"), + invoice_date: normalized_date(&one_word(result, "InvoiceDate")), + purchaser_name: one_word(result, "PurchaserName"), + purchaser_tax_id: one_word(result, "PurchaserRegisterNum"), + seller_name: one_word(result, "SellerName"), + seller_tax_id: one_word(result, "SellerRegisterNum"), + total_amount_in_cents: optional_cents(&one_word(result, "TotalAmount")), + total_tax_in_cents: optional_cents(&one_word(result, "TotalTax")), + total_with_tax_in_cents: optional_cents(&one_word(result, "AmountInFiguers")), + amount_in_words: one_word(result, "AmountInWords"), + drawer: one_word(result, "NoteDrawer"), + remarks: one_word(result, "Remarks"), + confidence: invoice.get("probability").and_then(Value::as_f64).unwrap_or(0.0).clamp(0.0, 1.0), + line_items: build_line_items(result), + }) +} + +fn valid_image_signature(mime_type: &str, bytes: &[u8]) -> bool { + match mime_type { + "image/png" => bytes.starts_with(b"\x89PNG\r\n\x1a\n"), + "image/jpeg" => bytes.starts_with(&[0xff, 0xd8, 0xff]), + "image/bmp" => bytes.starts_with(b"BM"), + "image/webp" => bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WEBP", + _ => false, + } +} + +fn validate_upload(input: &RecognizeDocumentInput) -> Result<(), String> { + let name_length = input.file_name.trim().chars().count(); + if !(1..=180).contains(&name_length) { + return Err("文件名长度不正确".to_string()); + } + if input.bytes.is_empty() || input.bytes.len() > MAX_IMAGE_BYTES { + return Err("图片应小于 2.5 MB".to_string()); + } + if !valid_image_signature(&input.mime_type, &input.bytes) { + return Err("图片格式与文件内容不一致,当前支持 PNG、JPG、BMP 和 WebP".to_string()); + } + Ok(()) +} + +fn parse_json Deserialize<'de>>(value: Option) -> Option { + value.and_then(|text| serde_json::from_str(&text).ok()) +} + +fn document_from_connection(connection: &Connection, id: &str) -> Result { + connection + .query_row( + " + SELECT d.id, d.file_name, d.mime_type, d.size_bytes, d.status, d.last_error, + d.created_at, d.updated_at, + (SELECT reviewed_json FROM document_reviews WHERE document_id = d.id ORDER BY id DESC LIMIT 1), + (SELECT normalized_json FROM document_extractions WHERE document_id = d.id ORDER BY id DESC LIMIT 1), + (SELECT normalized_json FROM ai_runs WHERE document_id = d.id AND status = 'succeeded' ORDER BY id DESC LIMIT 1) + FROM documents d WHERE d.id = ?1 + ", + [id], + |row| { + let reviewed: Option = row.get(8)?; + let extracted: Option = row.get(9)?; + let suggestion: Option = row.get(10)?; + Ok(StoredDocument { + id: row.get(0)?, + file_name: row.get(1)?, + mime_type: row.get(2)?, + size_bytes: row.get(3)?, + status: row.get(4)?, + last_error: row.get(5)?, + created_at: row.get(6)?, + updated_at: row.get(7)?, + extraction: parse_json(reviewed.or(extracted)), + suggestion: parse_json(suggestion), + }) + }, + ) + .map_err(|error| format!("读取票据失败:{error}")) +} + +#[tauri::command] +pub(crate) fn list_documents(app: AppHandle) -> Result, String> { + let connection = open_database(&app)?; + let mut statement = connection + .prepare("SELECT id FROM documents ORDER BY created_at DESC, id DESC") + .map_err(|error| format!("读取票据列表失败:{error}"))?; + let ids = statement + .query_map([], |row| row.get::<_, String>(0)) + .map_err(|error| format!("读取票据列表失败:{error}"))? + .collect::, _>>() + .map_err(|error| format!("读取票据列表失败:{error}"))?; + ids.iter().map(|id| document_from_connection(&connection, id)).collect() +} + +fn mark_ocr_failure(app: &AppHandle, document_id: &str, run_id: i64, failure: &ProviderFailure) { + if let Ok(connection) = open_database(app) { + let _ = connection.execute( + "UPDATE ocr_runs SET status = 'failed', error_code = ?1, completed_at = CURRENT_TIMESTAMP WHERE id = ?2", + params![failure.code, run_id], + ); + let _ = connection.execute( + "UPDATE documents SET status = 'failed', last_error = ?1, updated_at = CURRENT_TIMESTAMP WHERE id = ?2", + params![failure.message, document_id], + ); + } +} + +#[tauri::command] +pub(crate) async fn recognize_document( + app: AppHandle, + input: RecognizeDocumentInput, +) -> Result { + validate_upload(&input)?; + let content_hash = sha256_hex(&input.bytes); + let document_id = format!("DOC-{}", &content_hash[..20]); + let connection = open_database(&app)?; + connection + .execute( + " + INSERT OR IGNORE INTO documents( + id, content_hash, file_name, mime_type, size_bytes, encrypted_content, status + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'stored') + ", + params![&document_id, &content_hash, input.file_name.trim(), &input.mime_type, input.bytes.len() as i64, &input.bytes], + ) + .map_err(|error| format!("保存加密票据失败:{error}"))?; + let existing = document_from_connection(&connection, &document_id)?; + if existing.extraction.is_some() && matches!(existing.status.as_str(), "needs_review" | "confirmed") { + return Ok(existing); + } + connection + .execute( + "UPDATE documents SET status = 'processing', last_error = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?1", + [&document_id], + ) + .map_err(|error| format!("更新票据状态失败:{error}"))?; + connection + .execute( + "INSERT INTO ocr_runs(document_id, provider, operation, request_version, status) VALUES (?1, 'baidu', 'multiple_invoice', '2026-06', 'running')", + [&document_id], + ) + .map_err(|error| format!("创建 OCR 审计记录失败:{error}"))?; + let run_id = connection.last_insert_rowid(); + drop(connection); + + let config = providers::load_config()?; + let mime_type = input.mime_type; + let bytes = input.bytes; + let task = tauri::async_runtime::spawn_blocking(move || { + providers::recognize_financial_document(&config, &mime_type, &bytes) + }); + let response = match task.await { + Ok(Ok(response)) => response, + Ok(Err(failure)) => { + mark_ocr_failure(&app, &document_id, run_id, &failure); + return Err(failure.message); + } + Err(_) => { + let failure = ProviderFailure { code: "ocr_worker".to_string(), message: "OCR 任务意外中止,请重试".to_string() }; + mark_ocr_failure(&app, &document_id, run_id, &failure); + return Err(failure.message); + } + }; + let extraction = match parse_baidu_invoice(&response.raw) { + Ok(value) => value, + Err(message) => { + let failure = ProviderFailure { code: "unsupported_extraction".to_string(), message }; + mark_ocr_failure(&app, &document_id, run_id, &failure); + return Err(failure.message); + } + }; + let raw_json = serde_json::to_string(&response.raw).map_err(|_| "无法保存 OCR 原始响应".to_string())?; + let normalized_json = serde_json::to_string(&extraction).map_err(|_| "无法保存 OCR 结构化结果".to_string())?; + let mut connection = open_database(&app)?; + let transaction = connection.transaction().map_err(|error| format!("无法提交 OCR 结果:{error}"))?; + transaction.execute( + "UPDATE ocr_runs SET status = 'succeeded', raw_response_json = ?1, completed_at = CURRENT_TIMESTAMP WHERE id = ?2", + params![raw_json, run_id], + ).map_err(|error| format!("保存 OCR 审计记录失败:{error}"))?; + transaction.execute( + " + INSERT INTO document_extractions( + document_id, ocr_run_id, schema_version, invoice_type, invoice_number, invoice_date, + purchaser_name, purchaser_tax_id, seller_name, seller_tax_id, total_amount_in_cents, + total_tax_in_cents, total_with_tax_in_cents, confidence, normalized_json + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15) + ", + params![ + document_id, run_id, EXTRACTION_SCHEMA_VERSION, extraction.invoice_type, + extraction.invoice_number, extraction.invoice_date, extraction.purchaser_name, + extraction.purchaser_tax_id, extraction.seller_name, extraction.seller_tax_id, + extraction.total_amount_in_cents, extraction.total_tax_in_cents, + extraction.total_with_tax_in_cents, extraction.confidence, normalized_json, + ], + ).map_err(|error| format!("保存 OCR 结构化结果失败:{error}"))?; + transaction.execute( + "UPDATE documents SET status = 'needs_review', last_error = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?1", + [&document_id], + ).map_err(|error| format!("更新票据状态失败:{error}"))?; + transaction.commit().map_err(|error| format!("提交 OCR 结果失败:{error}"))?; + document_from_connection(&connection, &document_id) +} + +fn valid_iso_date(value: &str) -> bool { + let bytes = value.as_bytes(); + if bytes.len() != 10 + || bytes[4] != b'-' + || bytes[7] != b'-' + || bytes + .iter() + .enumerate() + .any(|(index, value)| index != 4 && index != 7 && !value.is_ascii_digit()) + { + return false; + } + let year = value[0..4].parse::().ok(); + let month = value[5..7].parse::().ok(); + let day = value[8..10].parse::().ok(); + matches!((year, month, day), (Some(2000..=2100), Some(1..=12), Some(1..=31))) +} + +fn validate_suggestion(value: Value, extraction: &InvoiceExtraction) -> Result { + let suggestion: BookkeepingSuggestion = serde_json::from_value(value) + .map_err(|_| "LLM 建议缺少必要字段或字段类型错误".to_string())?; + let categories = [ + "销售收入", "进货成本", "经营房租", "水电燃气", "办公支出", "交通差旅", + "业务招待", "平台服务费", "税费支出", "其他经营支出", + ]; + if !matches!(suggestion.direction.as_str(), "income" | "expense") { + return Err("LLM 建议的收支方向无效".to_string()); + } + if !categories.contains(&suggestion.category.as_str()) { + return Err("LLM 建议的分类不在允许范围内".to_string()); + } + if !valid_iso_date(&suggestion.occurred_on) { + return Err("LLM 建议的业务日期格式无效".to_string()); + } + if extraction.total_with_tax_in_cents != Some(suggestion.amount_in_cents) { + return Err("LLM 建议金额与票据价税合计不一致".to_string()); + } + if suggestion.counterparty.trim().is_empty() || suggestion.counterparty.chars().count() > 100 { + return Err("LLM 建议的交易对方无效".to_string()); + } + if suggestion.business_purpose.chars().count() > 40 || suggestion.reason.chars().count() > 80 { + return Err("LLM 建议的说明过长".to_string()); + } + if !(0.0..=1.0).contains(&suggestion.confidence) { + return Err("LLM 建议置信度无效".to_string()); + } + Ok(suggestion) +} + +fn suggestion_input(extraction: &InvoiceExtraction) -> Value { + json!({ + "invoiceType": extraction.invoice_type, + "invoiceDate": extraction.invoice_date, + "purchaserName": extraction.purchaser_name, + "sellerName": extraction.seller_name, + "totalWithTaxInCents": extraction.total_with_tax_in_cents, + "items": extraction.line_items.iter().map(|item| &item.name).collect::>() + }) +} + +fn mark_ai_failure(app: &AppHandle, run_id: i64, failure: &ProviderFailure) { + if let Ok(connection) = open_database(app) { + let _ = connection.execute( + "UPDATE ai_runs SET status = 'failed', error_code = ?1, completed_at = CURRENT_TIMESTAMP WHERE id = ?2", + params![failure.code, run_id], + ); + } +} + +fn mark_ai_validation_failure(app: &AppHandle, run_id: i64, raw: &Value, failure: &ProviderFailure) { + if let Ok(connection) = open_database(app) { + let raw_json = serde_json::to_string(raw).ok(); + let _ = connection.execute( + "UPDATE ai_runs SET status = 'failed', response_json = ?1, error_code = ?2, completed_at = CURRENT_TIMESTAMP WHERE id = ?3", + params![raw_json, failure.code, run_id], + ); + } +} + +#[tauri::command] +pub(crate) async fn generate_document_suggestion( + app: AppHandle, + document_id: String, +) -> Result { + let connection = open_database(&app)?; + let extraction_json: Option = connection.query_row( + "SELECT normalized_json FROM document_extractions WHERE document_id = ?1 ORDER BY id DESC LIMIT 1", + [&document_id], + |row| row.get(0), + ).optional().map_err(|error| format!("读取票据识别结果失败:{error}"))?; + let extraction: InvoiceExtraction = parse_json(extraction_json).ok_or_else(|| "票据尚未完成 OCR,不能生成记账建议".to_string())?; + let input = suggestion_input(&extraction); + let input_json = serde_json::to_string(&input).map_err(|_| "无法准备 LLM 输入".to_string())?; + let input_hash = sha256_hex(input_json.as_bytes()); + let config = providers::load_config()?; + connection.execute( + "INSERT INTO ai_runs(document_id, provider, model, prompt_version, input_hash, status) VALUES (?1, 'openai_compatible', ?2, ?3, ?4, 'running')", + params![document_id, config.llm.model, PROMPT_VERSION, input_hash], + ).map_err(|error| format!("创建 LLM 审计记录失败:{error}"))?; + let run_id = connection.last_insert_rowid(); + drop(connection); + let task = tauri::async_runtime::spawn_blocking(move || providers::request_bookkeeping_suggestion(&config, &input)); + let response = match task.await { + Ok(Ok(response)) => response, + Ok(Err(failure)) => { + mark_ai_failure(&app, run_id, &failure); + return Err(failure.message); + } + Err(_) => { + let failure = ProviderFailure { code: "llm_worker".to_string(), message: "LLM 任务意外中止,请重试".to_string() }; + mark_ai_failure(&app, run_id, &failure); + return Err(failure.message); + } + }; + let suggestion = match validate_suggestion(response.content.clone(), &extraction) { + Ok(value) => value, + Err(message) => { + let failure = ProviderFailure { code: "llm_validation".to_string(), message }; + mark_ai_validation_failure(&app, run_id, &response.raw, &failure); + return Err(failure.message); + } + }; + let raw_json = serde_json::to_string(&response.raw).map_err(|_| "无法保存 LLM 原始响应".to_string())?; + let normalized_json = serde_json::to_string(&suggestion).map_err(|_| "无法保存记账建议".to_string())?; + let connection = open_database(&app)?; + connection.execute( + "UPDATE ai_runs SET status = 'succeeded', model = ?1, response_json = ?2, normalized_json = ?3, completed_at = CURRENT_TIMESTAMP WHERE id = ?4", + params![response.model, raw_json, normalized_json, run_id], + ).map_err(|error| format!("保存 LLM 建议失败:{error}"))?; + document_from_connection(&connection, &document_id) +} + +fn validate_review(input: &ConfirmDocumentInput, original: &InvoiceExtraction) -> Result { + let invoice_number = input.invoice_number.trim().to_string(); + let invoice_date = input.invoice_date.trim().to_string(); + let seller_name = input.seller_name.trim().to_string(); + if invoice_number.is_empty() || invoice_number.chars().count() > 40 { + return Err("请核对并填写有效的发票号码".to_string()); + } + if !valid_iso_date(&invoice_date) { + return Err("开票日期应为 YYYY-MM-DD".to_string()); + } + if seller_name.is_empty() || seller_name.chars().count() > 100 { + return Err("请核对并填写销售方名称".to_string()); + } + for amount in [input.total_amount_in_cents, input.total_tax_in_cents, input.total_with_tax_in_cents].into_iter().flatten() { + if amount < 0 { + return Err("票据合计金额不能为负数".to_string()); + } + } + if let (Some(amount), Some(tax), Some(total)) = ( + input.total_amount_in_cents, + input.total_tax_in_cents, + input.total_with_tax_in_cents, + ) { + if (amount + tax - total).abs() > 1 { + return Err("不含税金额与税额之和不等于价税合计".to_string()); + } + } + if input.total_with_tax_in_cents.is_none() { + return Err("请核对价税合计".to_string()); + } + let mut reviewed = original.clone(); + reviewed.invoice_number = invoice_number; + reviewed.invoice_date = invoice_date; + reviewed.purchaser_name = input.purchaser_name.trim().to_string(); + reviewed.purchaser_tax_id = input.purchaser_tax_id.trim().to_string(); + reviewed.seller_name = seller_name; + reviewed.seller_tax_id = input.seller_tax_id.trim().to_string(); + reviewed.total_amount_in_cents = input.total_amount_in_cents; + reviewed.total_tax_in_cents = input.total_tax_in_cents; + reviewed.total_with_tax_in_cents = input.total_with_tax_in_cents; + reviewed.confidence = 1.0; + Ok(reviewed) +} + +#[tauri::command] +pub(crate) fn confirm_document( + app: AppHandle, + input: ConfirmDocumentInput, +) -> Result { + let mut connection = open_database(&app)?; + let extraction_json: Option = connection.query_row( + "SELECT normalized_json FROM document_extractions WHERE document_id = ?1 ORDER BY id DESC LIMIT 1", + [&input.document_id], + |row| row.get(0), + ).optional().map_err(|error| format!("读取票据识别结果失败:{error}"))?; + let original: InvoiceExtraction = parse_json(extraction_json).ok_or_else(|| "票据尚未完成 OCR,不能确认".to_string())?; + let reviewed = validate_review(&input, &original)?; + let reviewed_json = serde_json::to_string(&reviewed).map_err(|_| "无法保存人工核对结果".to_string())?; + let transaction = connection.transaction().map_err(|error| format!("无法开始票据确认:{error}"))?; + transaction.execute( + "INSERT INTO document_reviews(document_id, reviewed_json, actor) VALUES (?1, ?2, 'user')", + params![input.document_id, reviewed_json], + ).map_err(|error| format!("保存人工核对记录失败:{error}"))?; + transaction.execute( + "UPDATE documents SET status = 'confirmed', last_error = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?1", + [&input.document_id], + ).map_err(|error| format!("更新票据状态失败:{error}"))?; + transaction.commit().map_err(|error| format!("提交票据确认失败:{error}"))?; + document_from_connection(&connection, &input.document_id) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_baidu_vat_invoice_into_integer_cents() { + let value = json!({ + "words_result_num": 1, + "words_result": [{ + "type": "vat_invoice", + "probability": 0.959, + "result": { + "InvoiceType": [{"word": "电子发票(普通发票)"}], + "InvoiceNum": [{"word": "26427000000291271373"}], + "InvoiceDate": [{"word": "2026年03月16日"}], + "SellerName": [{"word": "武汉测试贸易有限公司"}], + "TotalAmount": [{"word": "56.48"}], + "TotalTax": [{"word": "7.33"}], + "AmountInFiguers": [{"word": "63.81"}], + "CommodityName": [{"row": "1", "word": "清洁用品"}], + "CommodityAmount": [{"row": "1", "word": "56.48"}], + "CommodityTaxRate": [{"row": "1", "word": "13%"}], + "CommodityTax": [{"row": "1", "word": "7.33"}] + } + }] + }); + let parsed = parse_baidu_invoice(&value).unwrap(); + assert_eq!(parsed.invoice_date, "2026-03-16"); + assert_eq!(parsed.total_with_tax_in_cents, Some(6_381)); + assert_eq!(parsed.line_items[0].tax_in_cents, Some(733)); + } + + #[test] + fn rejects_llm_amounts_that_differ_from_invoice() { + let extraction = InvoiceExtraction { + invoice_type: "电子普通发票".to_string(), + invoice_number: "1".to_string(), + invoice_date: "2026-03-16".to_string(), + purchaser_name: String::new(), + purchaser_tax_id: String::new(), + seller_name: "测试商户".to_string(), + seller_tax_id: String::new(), + total_amount_in_cents: Some(5_648), + total_tax_in_cents: Some(733), + total_with_tax_in_cents: Some(6_381), + amount_in_words: String::new(), + drawer: String::new(), + remarks: String::new(), + confidence: 0.95, + line_items: vec![], + }; + let candidate = json!({ + "direction": "expense", + "category": "进货成本", + "counterparty": "测试商户", + "occurredOn": "2026-03-16", + "amountInCents": 6380, + "businessPurpose": "购买耗材", + "confidence": 0.8, + "reason": "票据内容显示为经营耗材" + }); + assert!(validate_suggestion(candidate, &extraction).is_err()); + } + + #[test] + fn validates_file_signature_instead_of_only_trusting_mime_type() { + assert!(valid_image_signature("image/png", b"\x89PNG\r\n\x1a\nrest")); + assert!(!valid_image_signature("image/png", b"not an image")); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d94a994..48f622c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,3 +1,6 @@ +mod documents; +mod providers; + use rusqlite::{params, Connection, OptionalExtension}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -329,6 +332,94 @@ fn migrate(connection: &Connection) -> Result<(), String> { ON transaction_corrections(transaction_id, id DESC); INSERT OR IGNORE INTO schema_migrations(version) VALUES (4); + + CREATE TABLE IF NOT EXISTS documents ( + id TEXT PRIMARY KEY, + content_hash TEXT NOT NULL UNIQUE, + file_name TEXT NOT NULL, + mime_type TEXT NOT NULL, + size_bytes INTEGER NOT NULL CHECK (size_bytes > 0), + encrypted_content BLOB NOT NULL, + status TEXT NOT NULL CHECK (status IN ('stored', 'processing', 'needs_review', 'confirmed', 'failed')), + last_error TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS ocr_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + document_id TEXT NOT NULL, + provider TEXT NOT NULL, + operation TEXT NOT NULL, + request_version TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('running', 'succeeded', 'failed')), + raw_response_json TEXT, + error_code TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + completed_at TEXT, + FOREIGN KEY (document_id) REFERENCES documents(id) + ); + + CREATE INDEX IF NOT EXISTS idx_ocr_runs_document + ON ocr_runs(document_id, id DESC); + + CREATE TABLE IF NOT EXISTS document_extractions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + document_id TEXT NOT NULL, + ocr_run_id INTEGER NOT NULL, + schema_version TEXT NOT NULL, + invoice_type TEXT NOT NULL, + invoice_number TEXT NOT NULL, + invoice_date TEXT NOT NULL, + purchaser_name TEXT NOT NULL, + purchaser_tax_id TEXT NOT NULL, + seller_name TEXT NOT NULL, + seller_tax_id TEXT NOT NULL, + total_amount_in_cents INTEGER, + total_tax_in_cents INTEGER, + total_with_tax_in_cents INTEGER, + confidence REAL NOT NULL CHECK (confidence >= 0 AND confidence <= 1), + normalized_json TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (document_id) REFERENCES documents(id), + FOREIGN KEY (ocr_run_id) REFERENCES ocr_runs(id) + ); + + CREATE INDEX IF NOT EXISTS idx_document_extractions_latest + ON document_extractions(document_id, id DESC); + + CREATE TABLE IF NOT EXISTS ai_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + document_id TEXT NOT NULL, + provider TEXT NOT NULL, + model TEXT NOT NULL, + prompt_version TEXT NOT NULL, + input_hash TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('running', 'succeeded', 'failed')), + response_json TEXT, + normalized_json TEXT, + error_code TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + completed_at TEXT, + FOREIGN KEY (document_id) REFERENCES documents(id) + ); + + CREATE INDEX IF NOT EXISTS idx_ai_runs_document + ON ai_runs(document_id, id DESC); + + CREATE TABLE IF NOT EXISTS document_reviews ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + document_id TEXT NOT NULL, + reviewed_json TEXT NOT NULL, + actor TEXT NOT NULL DEFAULT 'user', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (document_id) REFERENCES documents(id) + ); + + CREATE INDEX IF NOT EXISTS idx_document_reviews_latest + ON document_reviews(document_id, id DESC); + + INSERT OR IGNORE INTO schema_migrations(version) VALUES (5); ", ) .map_err(|error| format!("本地账本升级失败:{error}"))?; @@ -1296,7 +1387,12 @@ pub fn run() { restore_local_backup, create_manual_transaction, correct_transaction, - export_ledger_csv + export_ledger_csv, + providers::get_provider_status, + documents::list_documents, + documents::recognize_document, + documents::generate_document_suggestion, + documents::confirm_document ]) .run(tauri::generate_context!()) .expect("failed to start Xiaobai Bookkeeping"); @@ -1564,7 +1660,7 @@ mod tests { let migration_count: i64 = connection .query_row("SELECT COUNT(*) FROM schema_migrations", [], |row| row.get(0)) .unwrap(); - assert_eq!(migration_count, 4); + assert_eq!(migration_count, 5); let listed = list_backups_in_directory(&directory, None).unwrap(); assert!(!listed[0].is_valid); let _ = fs::remove_dir_all(directory); diff --git a/src-tauri/src/providers.rs b/src-tauri/src/providers.rs new file mode 100644 index 0000000..defd891 --- /dev/null +++ b/src-tauri/src/providers.rs @@ -0,0 +1,333 @@ +use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; +use reqwest::blocking::Client; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +const BAIDU_TOKEN_URL: &str = "https://aip.baidubce.com/oauth/2.0/token"; +const BAIDU_MULTIPLE_INVOICE_URL: &str = + "https://aip.baidubce.com/rest/2.0/ocr/v1/multiple_invoice"; + +#[derive(Deserialize)] +pub(crate) struct ProviderConfig { + pub(crate) baidu_ocr: BaiduOcrConfig, + pub(crate) llm: LlmConfig, +} + +#[derive(Deserialize)] +pub(crate) struct BaiduOcrConfig { + pub(crate) enabled: bool, + pub(crate) auth_mode: String, + pub(crate) api_key: String, + pub(crate) secret_key: String, + pub(crate) access_token: String, + pub(crate) service: String, + pub(crate) request_timeout_seconds: u64, +} + +#[derive(Deserialize)] +pub(crate) struct LlmConfig { + pub(crate) enabled: bool, + pub(crate) base_url: String, + pub(crate) api_key: String, + pub(crate) model: String, + pub(crate) protocol: String, + pub(crate) request_timeout_seconds: u64, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProviderStatus { + ocr_configured: bool, + ocr_enabled: bool, + ocr_provider: &'static str, + llm_configured: bool, + llm_enabled: bool, + llm_provider: &'static str, + llm_model: String, + local_development_only: bool, +} + +#[derive(Debug)] +pub(crate) struct ProviderFailure { + pub(crate) code: String, + pub(crate) message: String, +} + +impl ProviderFailure { + fn new(code: impl Into, message: impl Into) -> Self { + Self { code: code.into(), message: message.into() } + } +} + +pub(crate) struct OcrResponse { + pub(crate) raw: Value, +} + +pub(crate) struct LlmResponse { + pub(crate) raw: Value, + pub(crate) content: Value, + pub(crate) model: String, +} + +fn find_config_from(start: &Path) -> Option { + start + .ancestors() + .map(|directory| directory.join("config").join("local.providers.toml")) + .find(|candidate| candidate.is_file()) +} + +fn config_path() -> Result { + if let Some(path) = env::var_os("XIAOBAI_PROVIDER_CONFIG") { + let path = PathBuf::from(path); + if path.is_file() { + return Ok(path); + } + return Err("XIAOBAI_PROVIDER_CONFIG 指向的配置文件不存在".to_string()); + } + if let Ok(current) = env::current_dir() { + if let Some(path) = find_config_from(¤t) { + return Ok(path); + } + } + if let Ok(executable) = env::current_exe() { + if let Some(parent) = executable.parent() { + if let Some(path) = find_config_from(parent) { + return Ok(path); + } + } + } + Err("找不到本地服务配置 config/local.providers.toml".to_string()) +} + +pub(crate) fn load_config() -> Result { + let path = config_path()?; + let text = fs::read_to_string(path).map_err(|_| "无法读取本地服务配置".to_string())?; + toml::from_str(&text).map_err(|error| format!("本地服务配置格式不正确:{error}")) +} + +fn configured_ocr(config: &BaiduOcrConfig) -> bool { + match config.auth_mode.as_str() { + "api_key_secret" => !config.api_key.trim().is_empty() && !config.secret_key.trim().is_empty(), + "access_token" => !config.access_token.trim().is_empty(), + _ => false, + } +} + +fn configured_llm(config: &LlmConfig) -> bool { + !config.base_url.trim().is_empty() + && !config.api_key.trim().is_empty() + && !config.model.trim().is_empty() + && config.protocol == "openai_compatible" +} + +#[tauri::command] +pub(crate) fn get_provider_status() -> Result { + let config = load_config()?; + Ok(ProviderStatus { + ocr_configured: configured_ocr(&config.baidu_ocr), + ocr_enabled: config.baidu_ocr.enabled, + ocr_provider: "百度智能财务票据识别", + llm_configured: configured_llm(&config.llm), + llm_enabled: config.llm.enabled, + llm_provider: "兼容 LLM 服务", + llm_model: config.llm.model, + local_development_only: true, + }) +} + +fn timeout(seconds: u64) -> Duration { + Duration::from_secs(seconds.clamp(5, 120)) +} + +fn client(seconds: u64) -> Result { + Client::builder() + .connect_timeout(Duration::from_secs(10)) + .timeout(timeout(seconds)) + .user_agent("XiaobaiBookkeeping-LocalDevelopment/0.10") + .build() + .map_err(|_| ProviderFailure::new("client_init", "无法初始化安全网络连接")) +} + +fn baidu_access_token(config: &BaiduOcrConfig) -> Result { + if config.auth_mode == "access_token" { + if config.access_token.trim().is_empty() { + return Err(ProviderFailure::new("missing_token", "百度 OCR access token 未填写")); + } + return Ok(config.access_token.trim().to_string()); + } + if config.auth_mode != "api_key_secret" { + return Err(ProviderFailure::new("unsupported_auth", "百度 OCR 认证方式不受支持")); + } + if config.api_key.trim().is_empty() || config.secret_key.trim().is_empty() { + return Err(ProviderFailure::new("missing_credentials", "百度 OCR API Key 或 Secret Key 未填写")); + } + let response = client(config.request_timeout_seconds)? + .post(BAIDU_TOKEN_URL) + .form(&[ + ("grant_type", "client_credentials"), + ("client_id", config.api_key.trim()), + ("client_secret", config.secret_key.trim()), + ]) + .send() + .map_err(|_| ProviderFailure::new("baidu_auth_network", "无法连接百度 OCR 鉴权服务"))?; + let status = response.status(); + let value: Value = response + .json() + .map_err(|_| ProviderFailure::new("baidu_auth_response", "百度 OCR 鉴权响应无法解析"))?; + if !status.is_success() { + return Err(ProviderFailure::new("baidu_auth_http", format!("百度 OCR 鉴权失败,HTTP {}", status.as_u16()))); + } + value + .get("access_token") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .ok_or_else(|| { + let code = value.get("error").and_then(Value::as_str).unwrap_or("missing_access_token"); + ProviderFailure::new(code, "百度 OCR 凭据无效或无权获取 access token") + }) +} + +pub(crate) fn recognize_financial_document( + config: &ProviderConfig, + mime_type: &str, + bytes: &[u8], +) -> Result { + if !config.baidu_ocr.enabled { + return Err(ProviderFailure::new("ocr_disabled", "百度 OCR 已配置但尚未启用")); + } + if config.baidu_ocr.service != "mixed_invoice" { + return Err(ProviderFailure::new("unsupported_service", "当前只支持智能财务票据识别")); + } + if !matches!(mime_type, "image/png" | "image/jpeg" | "image/bmp" | "image/webp") { + return Err(ProviderFailure::new("unsupported_file", "当前只支持 PNG、JPG、BMP 或 WebP 图片")); + } + let token = baidu_access_token(&config.baidu_ocr)?; + let image = BASE64.encode(bytes); + let response = client(config.baidu_ocr.request_timeout_seconds)? + .post(BAIDU_MULTIPLE_INVOICE_URL) + .query(&[("access_token", token)]) + .form(&[("image", image)]) + .send() + .map_err(|_| ProviderFailure::new("baidu_ocr_network", "百度 OCR 请求失败,请检查网络后重试"))?; + let status = response.status(); + let value: Value = response + .json() + .map_err(|_| ProviderFailure::new("baidu_ocr_response", "百度 OCR 响应无法解析"))?; + if !status.is_success() { + return Err(ProviderFailure::new("baidu_ocr_http", format!("百度 OCR 返回 HTTP {}", status.as_u16()))); + } + if let Some(code) = value.get("error_code") { + return Err(ProviderFailure::new( + format!("baidu_{}", code.as_i64().unwrap_or_default()), + "百度 OCR 未能完成识别,请确认接口权限、额度和图片格式", + )); + } + Ok(OcrResponse { raw: value }) +} + +fn chat_endpoint(base_url: &str) -> String { + let base = base_url.trim().trim_end_matches('/'); + if base.ends_with("/chat/completions") { + base.to_string() + } else { + format!("{base}/chat/completions") + } +} + +pub(crate) fn request_bookkeeping_suggestion( + config: &ProviderConfig, + input: &Value, +) -> Result { + if !config.llm.enabled { + return Err(ProviderFailure::new("llm_disabled", "LLM 已配置但尚未启用")); + } + if !configured_llm(&config.llm) { + return Err(ProviderFailure::new("llm_incomplete", "LLM 配置不完整或协议不受支持")); + } + let allowed_categories = [ + "销售收入", "进货成本", "经营房租", "水电燃气", "办公支出", "交通差旅", + "业务招待", "平台服务费", "税费支出", "其他经营支出", + ]; + let system = format!( + "你是小白记账的记账建议模块。只根据给定票据字段生成建议,不计算税务,不编造缺失事实。只能输出一个 JSON 对象,不要输出 Markdown、解释或思考过程。JSON 结构必须精确为:{{\"direction\":\"expense\",\"category\":\"进货成本\",\"counterparty\":\"交易对方\",\"occurredOn\":\"2026-01-01\",\"amountInCents\":100,\"businessPurpose\":\"经营用途\",\"confidence\":0.8,\"reason\":\"判断依据\"}}。category 只能是:{}。direction 只能是 income 或 expense。amountInCents 必须原样使用输入的价税合计分值。confidence 必须为 0 到 1。businessPurpose 不超过 40 个汉字,reason 不超过 80 个汉字。", + allowed_categories.join("、") + ); + let body = json!({ + "model": config.llm.model, + "temperature": 0, + "max_tokens": 900, + "messages": [ + {"role": "system", "content": system}, + {"role": "user", "content": serde_json::to_string(input).unwrap_or_else(|_| "{}".to_string())} + ] + }); + let response = client(config.llm.request_timeout_seconds)? + .post(chat_endpoint(&config.llm.base_url)) + .bearer_auth(config.llm.api_key.trim()) + .json(&body) + .send() + .map_err(|_| ProviderFailure::new("llm_network", "无法连接 LLM 服务"))?; + let status = response.status(); + let raw: Value = response + .json() + .map_err(|_| ProviderFailure::new("llm_response", "LLM 响应无法解析"))?; + if !status.is_success() { + return Err(ProviderFailure::new("llm_http", format!("LLM 服务返回 HTTP {}", status.as_u16()))); + } + let text = raw + .pointer("/choices/0/message/content") + .and_then(Value::as_str) + .ok_or_else(|| ProviderFailure::new("llm_format", "LLM 没有返回兼容的消息内容"))?; + let cleaned = extract_json_object(text) + .ok_or_else(|| ProviderFailure::new("llm_invalid_json", "LLM 未返回有效的 JSON 建议"))?; + let content: Value = serde_json::from_str(cleaned) + .map_err(|_| ProviderFailure::new("llm_invalid_json", "LLM 未返回有效的 JSON 建议"))?; + Ok(LlmResponse { raw, content, model: config.llm.model.clone() }) +} + +fn extract_json_object(text: &str) -> Option<&str> { + let trimmed = text.trim(); + if trimmed.starts_with('{') && trimmed.ends_with('}') { + return Some(trimmed); + } + let start = trimmed.find('{')?; + let end = trimmed.rfind('}')?; + (end > start).then_some(&trimmed[start..=end]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn chat_endpoint_accepts_base_and_complete_urls() { + assert_eq!(chat_endpoint("https://example.test/v1"), "https://example.test/v1/chat/completions"); + assert_eq!(chat_endpoint("https://example.test/v1/chat/completions"), "https://example.test/v1/chat/completions"); + } + + #[test] + fn provider_configuration_checks_auth_modes_without_exposing_values() { + let config = BaiduOcrConfig { + enabled: false, + auth_mode: "api_key_secret".to_string(), + api_key: "key".to_string(), + secret_key: "secret".to_string(), + access_token: String::new(), + service: "mixed_invoice".to_string(), + request_timeout_seconds: 30, + }; + assert!(configured_ocr(&config)); + } + + #[test] + fn extracts_json_from_fences_and_reasoning_prefixes() { + assert_eq!(extract_json_object("```json\n{\"ok\":true}\n```"), Some("{\"ok\":true}")); + assert_eq!(extract_json_object("internal\n{\"ok\":true}"), Some("{\"ok\":true}")); + assert_eq!(extract_json_object("没有对象"), None); + } +} diff --git a/src/App.tsx b/src/App.tsx index 50d22a0..d4b7223 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -108,6 +108,66 @@ interface ExportInfo { checksum: string; } +interface ProviderStatus { + ocrConfigured: boolean; + ocrEnabled: boolean; + ocrProvider: string; + llmConfigured: boolean; + llmEnabled: boolean; + llmProvider: string; + llmModel: string; + localDevelopmentOnly: boolean; +} + +interface InvoiceLineItem { + name: string; + amountInCents: number | null; + taxRate: string; + taxInCents: number | null; +} + +interface InvoiceExtraction { + invoiceType: string; + invoiceNumber: string; + invoiceDate: string; + purchaserName: string; + purchaserTaxId: string; + sellerName: string; + sellerTaxId: string; + totalAmountInCents: number | null; + totalTaxInCents: number | null; + totalWithTaxInCents: number | null; + amountInWords: string; + drawer: string; + remarks: string; + confidence: number; + lineItems: InvoiceLineItem[]; +} + +interface BookkeepingSuggestion { + direction: "income" | "expense"; + category: string; + counterparty: string; + occurredOn: string; + amountInCents: number; + businessPurpose: string; + confidence: number; + reason: string; +} + +interface StoredDocument { + id: string; + fileName: string; + mimeType: string; + sizeBytes: number; + status: "stored" | "processing" | "needs_review" | "confirmed" | "failed"; + lastError: string | null; + createdAt: string; + updatedAt: string; + extraction: InvoiceExtraction | null; + suggestion: BookkeepingSuggestion | null; +} + function entityTypeLabel(entityType: BusinessProfile["entityType"]) { return entityType === "sole_proprietor" ? "个体工商户" : "小微企业"; } @@ -485,9 +545,163 @@ function ConfirmPage({ pending, isLocal, reviewingId, onConfirm, onNotify }: { ; } +function amountInput(cents: number | null) { + return cents === null ? "" : (cents / 100).toFixed(2); +} + +function inputCents(value: string) { + const trimmed = value.trim(); + if (!/^\d+(\.\d{1,2})?$/.test(trimmed)) return null; + const [whole, decimal = ""] = trimmed.split("."); + return Number(whole) * 100 + Number(decimal.padEnd(2, "0")); +} + +function documentStatus(document: StoredDocument) { + if (document.status === "confirmed") return { label: "已核对", className: "done" }; + if (document.status === "needs_review") return { label: "待核对", className: "waiting" }; + if (document.status === "processing") return { label: "识别中", className: "waiting" }; + if (document.status === "failed") return { label: "需重试", className: "error" }; + return { label: "已归档", className: "personal" }; +} + +function DocumentReview({ document, llmReady, busy, onConfirm, onSuggest }: { + document: StoredDocument; + llmReady: boolean; + busy: "ocr" | "llm" | "confirm" | null; + onConfirm: (input: Record) => Promise; + onSuggest: () => Promise; +}) { + const extraction = document.extraction; + const [invoiceNumber, setInvoiceNumber] = useState(extraction?.invoiceNumber ?? ""); + const [invoiceDate, setInvoiceDate] = useState(extraction?.invoiceDate ?? ""); + const [purchaserName, setPurchaserName] = useState(extraction?.purchaserName ?? ""); + const [purchaserTaxId, setPurchaserTaxId] = useState(extraction?.purchaserTaxId ?? ""); + const [sellerName, setSellerName] = useState(extraction?.sellerName ?? ""); + const [sellerTaxId, setSellerTaxId] = useState(extraction?.sellerTaxId ?? ""); + const [totalAmount, setTotalAmount] = useState(amountInput(extraction?.totalAmountInCents ?? null)); + const [totalTax, setTotalTax] = useState(amountInput(extraction?.totalTaxInCents ?? null)); + const [totalWithTax, setTotalWithTax] = useState(amountInput(extraction?.totalWithTaxInCents ?? null)); + + if (!extraction) { + return

{document.status === "failed" ? "这张票据暂未识别成功" : "正在准备识别结果"}

{document.lastError ?? "完成后会在这里显示可核对字段。"}

; + } + + const confirm = async (event: FormEvent) => { + event.preventDefault(); + const amounts = [totalAmount, totalTax, totalWithTax].map((value) => value.trim() ? inputCents(value) : null); + if (amounts.some((value, index) => [totalAmount, totalTax, totalWithTax][index].trim() && value === null)) return; + await onConfirm({ + documentId: document.id, + invoiceNumber, + invoiceDate, + purchaserName, + purchaserTaxId, + sellerName, + sellerTaxId, + totalAmountInCents: amounts[0], + totalTaxInCents: amounts[1], + totalWithTaxInCents: amounts[2], + }); + }; + + return
+
{extraction.invoiceType || "增值税发票"}

{document.fileName}

OCR 分类置信度 {(extraction.confidence * 100).toFixed(1)}%,请以票面原件为准。

{documentStatus(document).label}
+
+
+ + + + + + + + + +
+ {extraction.lineItems.length ?
商品与服务明细{extraction.lineItems.length} 行
{extraction.lineItems.map((item, index) =>
{item.name || "未识别名称"}{item.taxRate || "税率未知"}{item.amountInCents === null ? "金额未知" : formatCurrency(item.amountInCents)}
)}
: null} + {document.suggestion ?
AI 记账建议{document.suggestion.direction === "income" ? "经营收入" : "经营支出"},{document.suggestion.category}

{document.suggestion.businessPurpose}。{document.suggestion.reason}

{Math.round(document.suggestion.confidence * 100)}% 把握
: null} +
AI 建议不会直接入账
+
+
; +} + function DocumentsPage({ onNotify }: { onNotify: (message: string) => void }) { - return
-
后续功能

这里将保存原始票据与识别结果

票据文件、识别字段和人工修订会分开保存;在功能通过完整验收前不会开放入口。

+ const fileInput = useRef(null); + const [providerStatus, setProviderStatus] = useState(null); + const [documents, setDocuments] = useState([]); + const [selectedId, setSelectedId] = useState(null); + const [busy, setBusy] = useState<"ocr" | "llm" | "confirm" | null>(null); + const [loading, setLoading] = useState(true); + const selected = documents.find((item) => item.id === selectedId) ?? documents[0] ?? null; + const ocrReady = Boolean(providerStatus?.ocrConfigured && providerStatus.ocrEnabled); + const llmReady = Boolean(providerStatus?.llmConfigured && providerStatus.llmEnabled); + + const loadDocuments = async () => { + const stored = await invoke("list_documents"); + setDocuments(stored); + setSelectedId((current) => current && stored.some((item) => item.id === current) ? current : stored[0]?.id ?? null); + return stored; + }; + + useEffect(() => { + if (!isTauriRuntime()) { setLoading(false); return; } + Promise.all([invoke("get_provider_status"), loadDocuments()]) + .then(([status]) => setProviderStatus(status)) + .catch((error) => onNotify(`票据中心初始化失败:${String(error)}`)) + .finally(() => setLoading(false)); + }, []); + + const chooseFile = () => { + if (!isTauriRuntime()) { onNotify("票据识别请在 Windows 客户端中使用"); return; } + if (!ocrReady) { onNotify("请先在本地配置中启用百度 OCR"); return; } + fileInput.current?.click(); + }; + + const recognize = async (event: ChangeEvent) => { + const file = event.target.files?.[0]; + event.target.value = ""; + if (!file) return; + if (file.size > 2_500_000) { onNotify("图片不能超过 2.5 MB"); return; } + const mimeType = file.type || (file.name.toLowerCase().endsWith(".png") ? "image/png" : "image/jpeg"); + setBusy("ocr"); + try { + const stored = await invoke("recognize_document", { input: { fileName: file.name, mimeType, bytes: Array.from(new Uint8Array(await file.arrayBuffer())) } }); + await loadDocuments(); + setSelectedId(stored.id); + onNotify(stored.status === "confirmed" ? "这张票据已经归档并核对" : "票据识别完成,请核对字段"); + } catch (error) { + await loadDocuments().catch(() => undefined); + onNotify(`票据识别失败:${String(error)}`); + } finally { setBusy(null); } + }; + + const generateSuggestion = async () => { + if (!selected) return; + setBusy("llm"); + try { + await invoke("generate_document_suggestion", { documentId: selected.id }); + await loadDocuments(); + onNotify("记账建议已生成,仍需人工确认"); + } catch (error) { onNotify(`生成建议失败:${String(error)}`); } + finally { setBusy(null); } + }; + + const confirm = async (input: Record) => { + setBusy("confirm"); + try { + await invoke("confirm_document", { input }); + await loadDocuments(); + onNotify("人工核对结果已保存,原始 OCR 结果仍保留"); + } catch (error) { onNotify(`保存核对结果失败:${String(error)}`); throw error; } + finally { setBusy(null); } + }; + + const pendingCount = documents.filter((item) => item.status === "needs_review").length; + const confirmedCount = documents.filter((item) => item.status === "confirmed").length; + return
} /> +
{ocrReady ? `${providerStatus?.ocrProvider}已就绪` : "OCR 当前未启用"}

{ocrReady ? "本次上传会调用云端识别;原图同时写入本地 SQLCipher 加密账本。" : loading ? "正在检查本地开发配置。" : "请把 local.providers.toml 中的百度 OCR enabled 改为 true。"}

{providerStatus?.localDevelopmentOnly ? "仅本地开发" : "服务端模式"}
+
加密归档{documents.length}按内容哈希自动去重
等待核对{pendingCount}不会自动写入账簿
已人工确认{confirmedCount}修改记录单独保存
+ {loading ?

正在读取加密票据

请稍候,系统不会把票据内容写入日志。

: documents.length === 0 ?
本地加密归档

上传第一张经营票据

支持 PNG、JPG、BMP 和 WebP,单张不超过 2.5 MB。点击上传即表示同意将当前图片发送给百度 OCR。

:

票据记录

最近上传优先

{documents.length} 张
{documents.map((document) => { const status = documentStatus(document); return ; })}
{selected ? : null}
}
; } diff --git a/src/styles.css b/src/styles.css index 6a11a3f..52d124d 100644 --- a/src/styles.css +++ b/src/styles.css @@ -246,6 +246,7 @@ th:nth-child(1) { width: 21%; } th:nth-child(2) { width: 25%; } th:nth-child(3) .status.done { color: var(--accent-700); background: var(--accent-050); } .status.waiting { color: var(--warning); background: var(--warning-bg); } .status.personal { color: #5b6472; background: #eef0f3; } +.status.error { color: #9a3f32; background: #fff0ed; } .right-rail { display: grid; gap: 16px; } .action-panel-title { padding: 17px 17px 11px; display: flex; align-items: center; gap: 11px; } @@ -291,6 +292,74 @@ th:nth-child(1) { width: 21%; } th:nth-child(2) { width: 25%; } th:nth-child(3) .feature-preview .safe-pill { margin-top: 5px; } .feature-preview p { max-width: 520px; line-height: 1.7; } .feature-preview .secondary-button { margin-top: 10px; } +.document-loading { min-height: 330px; } +.provider-strip { + min-height: 66px; + margin-bottom: 14px; + padding: 13px 17px; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + border: 1px solid; + border-radius: var(--radius-surface); +} +.provider-strip.ready { color: #315e56; background: var(--accent-050); border-color: #cce2dc; } +.provider-strip.blocked { color: #755600; background: var(--warning-bg); border-color: #ead99e; } +.provider-strip strong, .provider-strip p { display: block; margin: 0; } +.provider-strip strong { color: #283b38; font-size: 11.5px; } +.provider-strip p { margin-top: 4px; color: #5e706c; font-size: 10px; line-height: 1.45; } +.provider-strip > span { padding: 4px 8px; color: inherit; background: rgba(255, 255, 255, .68); border-radius: 6px; font-size: 9px; font-weight: 650; white-space: nowrap; } +.documents-workspace { min-height: 520px; display: grid; grid-template-columns: 278px minmax(0, 1fr); gap: 14px; align-items: stretch; } +.document-list-panel, .document-detail { overflow: hidden; background: #fff; border: 1px solid var(--border); border-radius: var(--radius-surface); } +.document-list-heading { min-height: 64px; padding: 14px 16px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid #e8ecee; } +.document-list-heading h2, .document-list-heading p { margin: 0; } +.document-list-heading h2 { font-size: 13px; } +.document-list-heading p { margin-top: 4px; color: var(--muted); font-size: 9.5px; } +.document-list-heading > span { color: var(--muted); font-size: 9.5px; } +.document-list { padding: 5px; display: grid; gap: 2px; } +.document-list > button { width: 100%; min-height: 60px; padding: 8px 9px; display: grid; grid-template-columns: 34px minmax(0, 1fr) auto; align-items: center; gap: 9px; text-align: left; color: var(--text); background: transparent; border: 1px solid transparent; border-radius: 8px; cursor: pointer; } +.document-list > button:hover { background: #f6f8f9; } +.document-list > button.active { background: var(--accent-050); border-color: #d2e6e0; } +.document-list-icon { width: 32px; height: 34px; display: grid; place-items: center; color: var(--accent-700); background: #e7f2ef; border-radius: 7px; } +.document-list button > span:nth-child(2) { min-width: 0; } +.document-list strong, .document-list small { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } +.document-list strong { font-size: 10.5px; } +.document-list small { margin-top: 5px; color: var(--muted); font-size: 9px; } +.document-list .status { white-space: nowrap; } +.document-detail { min-width: 0; } +.document-detail-empty { min-height: 100%; padding: 40px; display: grid; place-content: center; justify-items: center; text-align: center; color: var(--accent-700); } +.document-detail-empty h2 { margin: 13px 0 6px; color: var(--text); font-size: 15px; } +.document-detail-empty p { max-width: 420px; margin: 0; color: var(--muted); font-size: 10.5px; line-height: 1.6; } +.document-detail-content { min-width: 0; } +.document-detail-heading { min-height: 84px; padding: 16px 19px; display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; border-bottom: 1px solid #e8ecee; } +.document-detail-heading > div { min-width: 0; } +.document-detail-heading span:first-child { color: var(--accent-700); font-size: 9.5px; font-weight: 650; } +.document-detail-heading h2 { margin: 5px 0 0; overflow: hidden; color: var(--text); font-size: 14px; white-space: nowrap; text-overflow: ellipsis; } +.document-detail-heading p { margin: 5px 0 0; color: var(--muted); font-size: 9.5px; } +.invoice-review-form { padding-bottom: 18px; } +.invoice-fields { padding: 17px 19px 4px; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 13px; } +.invoice-fields label > span { display: block; margin-bottom: 6px; color: #344149; font-size: 9.5px; font-weight: 650; } +.invoice-fields input { width: 100%; height: 35px; padding: 0 9px; color: var(--text); background: #fff; border: 1px solid #cbd3d8; border-radius: var(--radius-control); outline: none; font-size: 10.5px; } +.invoice-fields input:focus { border-color: var(--accent-600); box-shadow: 0 0 0 2px rgba(38, 124, 112, .13); } +.invoice-fields .field-wide { grid-column: span 2; } +.invoice-items { margin: 14px 19px 0; overflow: hidden; border: 1px solid #e2e6e9; border-radius: 9px; } +.invoice-items-heading { min-height: 39px; padding: 0 12px; display: flex; align-items: center; justify-content: space-between; background: #f7f9fa; } +.invoice-items-heading strong { font-size: 10px; } +.invoice-items-heading span { color: var(--muted); font-size: 9px; } +.invoice-item { min-height: 39px; padding: 0 12px; display: grid; grid-template-columns: minmax(0, 1fr) 58px 82px; align-items: center; gap: 10px; border-top: 1px solid #edf0f2; } +.invoice-item > span { overflow: hidden; font-size: 9.5px; white-space: nowrap; text-overflow: ellipsis; } +.invoice-item small { color: var(--muted); font-size: 9px; } +.invoice-item b { text-align: right; font-size: 9.5px; font-variant-numeric: tabular-nums; } +.suggestion-box { margin: 14px 19px 0; padding: 13px 14px; display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 16px; color: #355a54; background: var(--accent-050); border: 1px solid #cce2dc; border-radius: 9px; } +.suggestion-box span, .suggestion-box strong { display: block; } +.suggestion-box span { font-size: 9px; } +.suggestion-box strong { margin-top: 4px; color: #24413c; font-size: 11px; } +.suggestion-box p { margin: 5px 0 0; color: #58716b; font-size: 9.5px; line-height: 1.5; } +.suggestion-box > b { font-size: 10px; white-space: nowrap; } +.document-review-actions { margin: 16px 19px 0; padding-top: 15px; display: flex; align-items: center; gap: 8px; border-top: 1px solid #e6eaec; } +.document-review-actions > span { margin-left: auto; color: var(--muted); font-size: 9px; } +.document-review-actions button:disabled, .page-actions button:disabled, .feature-preview button:disabled { cursor: not-allowed; opacity: .58; } .confirm-card { padding: 21px; display: grid; grid-template-columns: 38px 1fr; gap: 15px; background: #fff; border: 1px solid var(--border); border-radius: var(--radius-surface); } .confirm-index { width: 34px; height: 34px; display: grid; place-items: center; border-radius: 8px; color: var(--accent-700); background: var(--accent-050); font-size: 11px; font-weight: 700; } .source-line { color: var(--muted); font-size: 10px; } @@ -438,4 +507,5 @@ th:nth-child(1) { width: 21%; } th:nth-child(2) { width: 25%; } th:nth-child(3) .profile-safety .icon { grid-row: 1 / span 2; } .transaction-form-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .document-grid { grid-template-columns: repeat(2, 1fr); } + .documents-workspace { grid-template-columns: 250px minmax(0, 1fr); } }