feat: link reviewed invoices to ledger transactions
This commit is contained in:
@@ -75,3 +75,9 @@
|
||||
- 服务端按经营主体做鉴权、限流、调用审计和数据隔离。
|
||||
- OCR 与 LLM 的响应都先经过服务端结构校验;客户端仍保留金额、日期和枚举白名单等确定性校验。
|
||||
- AI 只生成建议,不能直接写入账簿、修改税额或代替人工确认。
|
||||
|
||||
## 票据、流水与凭证
|
||||
|
||||
票据证明交易内容,流水证明资金实际收付,凭证负责把两类证据关联起来。三者不互相冒充:确认发票不会创建流水,导入流水也不会自动认定某张发票属于它。
|
||||
|
||||
当前一对一匹配由版本化确定性规则完成。金额和收支方向必须完全一致,日期必须在允许窗口内,交易对方名称只影响排序。用户确认后,系统保存匹配证据快照并按需追加流水分类修订;撤销通过新的审计事件和反向修订完成,不删除原记录,也不覆盖撤销前已经发生的后续人工修改。
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
- 界面与交互:`src/App.tsx`,视觉令牌与布局:`src/styles.css`。
|
||||
- 金额汇总规则:`src/domain/transactions.ts`,修改时先补对应测试。
|
||||
- 本地数据库、命令、迁移、备份和导出:`src-tauri/src/lib.rs`。
|
||||
- 票据与流水候选匹配、凭证和撤销审计:`src-tauri/src/vouchers.rs`。
|
||||
- 产品边界和架构决定:`docs/`,发布前同步更新版本说明。
|
||||
|
||||
## 不破坏旧数据的规则
|
||||
@@ -19,6 +20,8 @@
|
||||
|
||||
运行 `scripts/verify.ps1`,确认前端测试、生产构建和 Rust 测试通过;随后人工检查建档、记一笔、CSV 重复导入、确认、修改、搜索、账簿导出、备份和恢复。安装升级测试不得删除应用数据目录。
|
||||
|
||||
发布客户端必须使用 `npm run desktop:build`,不要用 `cargo build` 代替正式打包流程,否则最新 `dist` 可能没有嵌入可执行文件。SQLCipher 的 vendored OpenSSL 在 Windows 中文路径下构建失败时,可临时用 `subst` 把项目根目录映射到纯英文盘符后构建;映射只服务于编译,不得写入程序配置或用户数据路径。
|
||||
|
||||
## 故障定位顺序
|
||||
|
||||
先记录用户看到的中文错误和触发步骤,再判断属于界面、命令、数据库迁移还是文件系统。修复后必须增加能复现问题的自动测试。不要让用户手工编辑数据库或密钥文件。
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 当前能力
|
||||
|
||||
票据中心已经形成“主动上传 → 加密存档 → OCR 识别 → 字段核对 → AI 建议 → 人工确认”的本地闭环。发票与收付款流水是两类不同证据,因此人工确认票据不会自动生成收支流水,避免把“取得发票”误当成“已经付款”。后续应通过流水匹配或单独的凭证流程完成入账。
|
||||
票据中心已经形成“主动上传 → 加密存档 → OCR 识别 → 字段核对 → AI 建议 → 人工确认 → 匹配流水 → 生成凭证”的本地闭环。发票与收付款流水是两类不同证据,因此人工确认票据不会新建收支流水,避免把“取得发票”误当成“已经付款”。只有匹配到已有真实流水并经人工确认后,系统才生成凭证关联并追加分类修订。
|
||||
|
||||
当前开放增值税发票图片,单张不超过 2.5 MB,支持 PNG、JPEG、BMP 和 WebP。文件按内容哈希去重,重复选择同一图片不会重复建档。
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
- `document_extractions`:保存经过归一化的可编辑字段,金额统一使用整数分。
|
||||
- `ai_runs`:追加保存模型调用状态、服务可解析时的原始响应、校验后的建议和置信度。
|
||||
- `document_reviews`:追加保存每次人工确认时的字段快照与时间。
|
||||
- `vouchers`:保存票据、流水、入账分类、经营用途、匹配规则版本和确认前快照。
|
||||
- `voucher_events`:追加保存凭证确认与撤销事件,不覆盖历史状态。
|
||||
|
||||
原始 OCR 结果不会因人工修改而被覆盖。失败调用同样留下审计状态,但日志和界面不显示供应商密钥。
|
||||
|
||||
@@ -24,6 +26,9 @@
|
||||
- LLM 返回必须是结构化 JSON,并通过方向、分类白名单、金额一致性、日期和置信度校验。
|
||||
- 任何解析或校验失败都只显示失败,不产生记账结果。
|
||||
- AI 建议与人工确认是两个独立动作,AI 永远不能自动入账。
|
||||
- 流水候选使用确定性规则,不调用 LLM。金额和收支方向必须完全一致,日期相差不超过 45 天,交易对方相似度只参与排序和风险提示。
|
||||
- 同一张票据或同一笔流水同时只能存在一张有效凭证。
|
||||
- 撤销凭证通过追加反向事件和分类修订完成。如果用户在凭证确认后又手工修改过流水,撤销不会覆盖这次更新。
|
||||
|
||||
## 服务端迁移
|
||||
|
||||
@@ -40,3 +45,5 @@
|
||||
5. 人工修改后保存的是新复核快照,OCR 原始结果仍可追溯。
|
||||
6. 人工确认不会改变收支流水与税务计算结果。
|
||||
7. 本机真实配置、测试票据和密钥不会进入 Git 或安装包。
|
||||
8. 同额、同方向且日期相近的流水会成为候选,不同金额或相反方向不会出现。
|
||||
9. 确认凭证不会新建第二笔流水,撤销后原始票据、流水和凭证历史仍可追溯。
|
||||
|
||||
+48
-2
@@ -1,5 +1,6 @@
|
||||
mod documents;
|
||||
mod providers;
|
||||
mod vouchers;
|
||||
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -420,6 +421,47 @@ fn migrate(connection: &Connection) -> Result<(), String> {
|
||||
ON document_reviews(document_id, id DESC);
|
||||
|
||||
INSERT OR IGNORE INTO schema_migrations(version) VALUES (5);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS vouchers (
|
||||
id TEXT PRIMARY KEY,
|
||||
document_id TEXT NOT NULL,
|
||||
transaction_id TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
business_purpose TEXT NOT NULL,
|
||||
match_score REAL NOT NULL CHECK (match_score >= 0 AND match_score <= 1),
|
||||
match_rule_version TEXT NOT NULL,
|
||||
evidence_json TEXT NOT NULL,
|
||||
prior_review_decision TEXT,
|
||||
prior_category TEXT NOT NULL,
|
||||
applied_review_id INTEGER,
|
||||
applied_correction_id INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (document_id) REFERENCES documents(id),
|
||||
FOREIGN KEY (transaction_id) REFERENCES transactions(id),
|
||||
FOREIGN KEY (applied_review_id) REFERENCES transaction_reviews(id),
|
||||
FOREIGN KEY (applied_correction_id) REFERENCES transaction_corrections(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_vouchers_document
|
||||
ON vouchers(document_id, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_vouchers_transaction
|
||||
ON vouchers(transaction_id, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS voucher_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
voucher_id TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL CHECK (event_type IN ('confirmed', 'reversed')),
|
||||
reason TEXT NOT NULL,
|
||||
actor TEXT NOT NULL DEFAULT 'user',
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (voucher_id) REFERENCES vouchers(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_voucher_events_latest
|
||||
ON voucher_events(voucher_id, id DESC);
|
||||
|
||||
INSERT OR IGNORE INTO schema_migrations(version) VALUES (6);
|
||||
",
|
||||
)
|
||||
.map_err(|error| format!("本地账本升级失败:{error}"))?;
|
||||
@@ -1392,7 +1434,11 @@ pub fn run() {
|
||||
documents::list_documents,
|
||||
documents::recognize_document,
|
||||
documents::generate_document_suggestion,
|
||||
documents::confirm_document
|
||||
documents::confirm_document,
|
||||
vouchers::list_voucher_candidates,
|
||||
vouchers::list_vouchers,
|
||||
vouchers::confirm_voucher_match,
|
||||
vouchers::reverse_voucher
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("failed to start Xiaobai Bookkeeping");
|
||||
@@ -1660,7 +1706,7 @@ mod tests {
|
||||
let migration_count: i64 = connection
|
||||
.query_row("SELECT COUNT(*) FROM schema_migrations", [], |row| row.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(migration_count, 5);
|
||||
assert_eq!(migration_count, 6);
|
||||
let listed = list_backups_in_directory(&directory, None).unwrap();
|
||||
assert!(!listed[0].is_valid);
|
||||
let _ = fs::remove_dir_all(directory);
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
use crate::{open_database, sha256_hex};
|
||||
use rusqlite::{params, Connection, OptionalExtension, TransactionBehavior};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tauri::AppHandle;
|
||||
|
||||
const MATCH_RULE_VERSION: &str = "invoice-bank-match-v1";
|
||||
const CATEGORIES: [&str; 10] = [
|
||||
"销售收入", "进货成本", "经营房租", "水电燃气", "办公支出",
|
||||
"交通差旅", "业务招待", "平台服务费", "税费支出", "其他经营支出",
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct EffectiveTransaction {
|
||||
id: String,
|
||||
occurred_on: String,
|
||||
counterparty: String,
|
||||
category: String,
|
||||
kind: String,
|
||||
amount_in_cents: i64,
|
||||
review_decision: Option<String>,
|
||||
latest_review_id: Option<i64>,
|
||||
latest_correction_id: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct DocumentEvidence {
|
||||
document_id: String,
|
||||
file_name: String,
|
||||
invoice_number: String,
|
||||
invoice_date: String,
|
||||
seller_name: String,
|
||||
amount_in_cents: i64,
|
||||
direction: String,
|
||||
recommended_category: String,
|
||||
business_purpose: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct VoucherCandidate {
|
||||
document_id: String,
|
||||
transaction_id: String,
|
||||
occurred_on: String,
|
||||
counterparty: String,
|
||||
category: String,
|
||||
kind: String,
|
||||
amount_in_cents: i64,
|
||||
review_decision: Option<String>,
|
||||
score: f64,
|
||||
date_distance_days: i64,
|
||||
counterparty_similarity: f64,
|
||||
recommended_category: String,
|
||||
business_purpose: String,
|
||||
explanation: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct StoredVoucher {
|
||||
id: String,
|
||||
document_id: String,
|
||||
transaction_id: String,
|
||||
status: String,
|
||||
category: String,
|
||||
business_purpose: String,
|
||||
match_score: f64,
|
||||
file_name: String,
|
||||
invoice_number: String,
|
||||
seller_name: String,
|
||||
invoice_date: String,
|
||||
invoice_amount_in_cents: i64,
|
||||
transaction_occurred_on: String,
|
||||
transaction_counterparty: String,
|
||||
transaction_amount_in_cents: i64,
|
||||
created_at: String,
|
||||
latest_event_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ConfirmVoucherInput {
|
||||
document_id: String,
|
||||
transaction_id: String,
|
||||
category: String,
|
||||
business_purpose: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ReverseVoucherInput {
|
||||
voucher_id: String,
|
||||
reason: String,
|
||||
}
|
||||
|
||||
fn text(value: &Value, field: &str) -> String {
|
||||
value.get(field).and_then(Value::as_str).unwrap_or_default().trim().to_string()
|
||||
}
|
||||
|
||||
fn document_evidence(connection: &Connection, document_id: &str) -> Result<DocumentEvidence, String> {
|
||||
let row: Option<(String, String, Option<String>, Option<String>)> = connection.query_row(
|
||||
"SELECT d.file_name, d.status,
|
||||
COALESCE(
|
||||
(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",
|
||||
[document_id],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
|
||||
).optional().map_err(|error| format!("读取票据匹配依据失败:{error}"))?;
|
||||
let (file_name, status, extraction_json, suggestion_json) = row.ok_or_else(|| "找不到需要匹配的票据".to_string())?;
|
||||
if status != "confirmed" {
|
||||
return Err("请先完成人工票据核对,再匹配流水".to_string());
|
||||
}
|
||||
let extraction: Value = serde_json::from_str(
|
||||
extraction_json.as_deref().ok_or_else(|| "票据缺少已核对字段".to_string())?,
|
||||
).map_err(|_| "票据核对字段无法读取".to_string())?;
|
||||
let amount_in_cents = extraction.get("totalWithTaxInCents").and_then(Value::as_i64)
|
||||
.filter(|value| *value > 0).ok_or_else(|| "票据价税合计无效,不能匹配流水".to_string())?;
|
||||
let suggestion = suggestion_json.as_deref().and_then(|value| serde_json::from_str::<Value>(value).ok());
|
||||
let direction = suggestion.as_ref().map(|value| text(value, "direction"))
|
||||
.filter(|value| matches!(value.as_str(), "income" | "expense"))
|
||||
.unwrap_or_else(|| "expense".to_string());
|
||||
let recommended_category = suggestion.as_ref().map(|value| text(value, "category"))
|
||||
.filter(|value| CATEGORIES.contains(&value.as_str()))
|
||||
.unwrap_or_else(|| if direction == "income" { "销售收入" } else { "其他经营支出" }.to_string());
|
||||
let business_purpose = suggestion.as_ref().map(|value| text(value, "businessPurpose"))
|
||||
.filter(|value| !value.is_empty()).unwrap_or_else(|| "经营票据支出".to_string());
|
||||
Ok(DocumentEvidence {
|
||||
document_id: document_id.to_string(), file_name,
|
||||
invoice_number: text(&extraction, "invoiceNumber"),
|
||||
invoice_date: text(&extraction, "invoiceDate"),
|
||||
seller_name: text(&extraction, "sellerName"), amount_in_cents,
|
||||
direction, recommended_category, business_purpose,
|
||||
})
|
||||
}
|
||||
|
||||
fn effective_transactions(connection: &Connection) -> Result<Vec<EffectiveTransaction>, String> {
|
||||
let mut statement = connection.prepare(
|
||||
"SELECT t.id, COALESCE(c.occurred_on, t.occurred_on),
|
||||
COALESCE(c.counterparty, t.counterparty), COALESCE(c.category, t.category),
|
||||
COALESCE(c.kind, t.kind), COALESCE(c.amount_in_cents, t.amount_in_cents),
|
||||
r.decision, r.id, c.id
|
||||
FROM transactions t
|
||||
LEFT JOIN transaction_reviews r ON r.id = (
|
||||
SELECT id FROM transaction_reviews WHERE transaction_id = t.id ORDER BY id DESC LIMIT 1
|
||||
)
|
||||
LEFT JOIN transaction_corrections c ON c.id = (
|
||||
SELECT id FROM transaction_corrections WHERE transaction_id = t.id ORDER BY id DESC LIMIT 1
|
||||
)
|
||||
ORDER BY COALESCE(c.occurred_on, t.occurred_on) DESC, t.created_at DESC"
|
||||
).map_err(|error| format!("读取可匹配流水失败:{error}"))?;
|
||||
let rows = statement.query_map([], |row| Ok(EffectiveTransaction {
|
||||
id: row.get(0)?, occurred_on: row.get(1)?, counterparty: row.get(2)?,
|
||||
category: row.get(3)?, kind: row.get(4)?, amount_in_cents: row.get(5)?,
|
||||
review_decision: row.get(6)?, latest_review_id: row.get(7)?, latest_correction_id: row.get(8)?,
|
||||
})).map_err(|error| format!("读取可匹配流水失败:{error}"))?;
|
||||
rows.collect::<Result<Vec<_>, _>>().map_err(|error| format!("读取可匹配流水失败:{error}"))
|
||||
}
|
||||
|
||||
fn active_voucher_exists(connection: &Connection, document_id: &str, transaction_id: Option<&str>) -> Result<bool, String> {
|
||||
connection.query_row(
|
||||
"SELECT EXISTS(
|
||||
SELECT 1 FROM vouchers v
|
||||
WHERE (v.document_id = ?1 OR (?2 IS NOT NULL AND v.transaction_id = ?2))
|
||||
AND (SELECT event_type FROM voucher_events WHERE voucher_id = v.id ORDER BY id DESC LIMIT 1) = 'confirmed'
|
||||
)",
|
||||
params![document_id, transaction_id], |row| row.get(0),
|
||||
).map_err(|error| format!("检查凭证关联失败:{error}"))
|
||||
}
|
||||
|
||||
fn date_serial(value: &str) -> Option<i64> {
|
||||
let mut parts = value.get(..10)?.split('-');
|
||||
let mut year = parts.next()?.parse::<i64>().ok()?;
|
||||
let month = parts.next()?.parse::<i64>().ok()?;
|
||||
let day = parts.next()?.parse::<i64>().ok()?;
|
||||
if parts.next().is_some() || !(1..=12).contains(&month) || !(1..=31).contains(&day) { return None; }
|
||||
year -= i64::from(month <= 2);
|
||||
let era = if year >= 0 { year } else { year - 399 } / 400;
|
||||
let year_of_era = year - era * 400;
|
||||
let shifted_month = month + if month > 2 { -3 } else { 9 };
|
||||
let day_of_year = (153 * shifted_month + 2) / 5 + day - 1;
|
||||
let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
|
||||
Some(era * 146_097 + day_of_era)
|
||||
}
|
||||
|
||||
fn normalized_name(value: &str) -> String {
|
||||
let mut normalized = value.chars().filter(|value| value.is_alphanumeric()).collect::<String>().to_lowercase();
|
||||
for suffix in ["有限责任公司", "股份有限公司", "有限公司", "公司"] {
|
||||
normalized = normalized.replace(suffix, "");
|
||||
}
|
||||
normalized
|
||||
}
|
||||
|
||||
fn longest_common_run(left: &str, right: &str) -> usize {
|
||||
let right: Vec<char> = right.chars().collect();
|
||||
let mut previous = vec![0usize; right.len() + 1];
|
||||
let mut best = 0usize;
|
||||
for left_char in left.chars() {
|
||||
let mut current = vec![0usize; right.len() + 1];
|
||||
for (index, right_char) in right.iter().enumerate() {
|
||||
if left_char == *right_char {
|
||||
current[index + 1] = previous[index] + 1;
|
||||
best = best.max(current[index + 1]);
|
||||
}
|
||||
}
|
||||
previous = current;
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
fn counterparty_similarity(left: &str, right: &str) -> f64 {
|
||||
let left = normalized_name(left);
|
||||
let right = normalized_name(right);
|
||||
if left.is_empty() || right.is_empty() { return 0.0; }
|
||||
if left == right { return 1.0; }
|
||||
if (left.contains(&right) || right.contains(&left)) && left.chars().count().min(right.chars().count()) >= 3 { return 0.9; }
|
||||
match longest_common_run(&left, &right) { value if value >= 4 => 0.75, 3 => 0.55, 2 => 0.3, _ => 0.0 }
|
||||
}
|
||||
|
||||
fn candidate_for(document: &DocumentEvidence, transaction: &EffectiveTransaction) -> Option<VoucherCandidate> {
|
||||
if document.amount_in_cents != transaction.amount_in_cents || document.direction != transaction.kind { return None; }
|
||||
let date_distance_days = (date_serial(&document.invoice_date)? - date_serial(&transaction.occurred_on)?).abs();
|
||||
if date_distance_days > 45 { return None; }
|
||||
let date_score = match date_distance_days { 0 => 0.20, 1..=3 => 0.17, 4..=7 => 0.14, 8..=15 => 0.10, 16..=30 => 0.06, _ => 0.03 };
|
||||
let similarity = counterparty_similarity(&document.seller_name, &transaction.counterparty);
|
||||
let score = (0.65 + date_score + similarity * 0.15_f64).min(1.0_f64);
|
||||
let explanation = if similarity >= 0.75 {
|
||||
format!("金额完全一致,日期相差 {date_distance_days} 天,交易对方名称高度相近")
|
||||
} else if similarity >= 0.3 {
|
||||
format!("金额完全一致,日期相差 {date_distance_days} 天,交易对方名称部分相近")
|
||||
} else {
|
||||
format!("金额完全一致,日期相差 {date_distance_days} 天,请重点核对交易对方")
|
||||
};
|
||||
Some(VoucherCandidate {
|
||||
document_id: document.document_id.clone(), transaction_id: transaction.id.clone(),
|
||||
occurred_on: transaction.occurred_on.clone(), counterparty: transaction.counterparty.clone(),
|
||||
category: transaction.category.clone(), kind: transaction.kind.clone(),
|
||||
amount_in_cents: transaction.amount_in_cents, review_decision: transaction.review_decision.clone(),
|
||||
score, date_distance_days, counterparty_similarity: similarity,
|
||||
recommended_category: document.recommended_category.clone(), business_purpose: document.business_purpose.clone(),
|
||||
explanation,
|
||||
})
|
||||
}
|
||||
|
||||
fn candidates_from_connection(connection: &Connection, document_id: &str) -> Result<Vec<VoucherCandidate>, String> {
|
||||
let document = document_evidence(connection, document_id)?;
|
||||
if active_voucher_exists(connection, document_id, None)? { return Ok(Vec::new()); }
|
||||
let mut candidates = Vec::new();
|
||||
for transaction in effective_transactions(connection)? {
|
||||
if active_voucher_exists(connection, "", Some(&transaction.id))? { continue; }
|
||||
if let Some(candidate) = candidate_for(&document, &transaction) { candidates.push(candidate); }
|
||||
}
|
||||
candidates.sort_by(|left, right| right.score.total_cmp(&left.score)
|
||||
.then(left.date_distance_days.cmp(&right.date_distance_days))
|
||||
.then(left.transaction_id.cmp(&right.transaction_id)));
|
||||
candidates.truncate(8);
|
||||
Ok(candidates)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn list_voucher_candidates(app: AppHandle, document_id: String) -> Result<Vec<VoucherCandidate>, String> {
|
||||
candidates_from_connection(&open_database(&app)?, document_id.trim())
|
||||
}
|
||||
|
||||
fn create_voucher_id(document_id: &str, transaction_id: &str) -> Result<String, String> {
|
||||
let nonce = SystemTime::now().duration_since(UNIX_EPOCH).map_err(|error| format!("系统时间异常:{error}"))?.as_nanos();
|
||||
let hash = sha256_hex(format!("voucher|{nonce}|{document_id}|{transaction_id}").as_bytes());
|
||||
Ok(format!("VCH-{}", &hash[..18]))
|
||||
}
|
||||
|
||||
fn confirm_in_database(connection: &mut Connection, input: ConfirmVoucherInput) -> Result<String, String> {
|
||||
let document_id = input.document_id.trim();
|
||||
let transaction_id = input.transaction_id.trim();
|
||||
let category = input.category.trim();
|
||||
let business_purpose = input.business_purpose.trim();
|
||||
if !CATEGORIES.contains(&category) { return Err("凭证分类不在允许范围内".to_string()); }
|
||||
if !(2..=80).contains(&business_purpose.chars().count()) { return Err("经营用途应为 2 到 80 个字符".to_string()); }
|
||||
let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)
|
||||
.map_err(|error| format!("无法开始凭证确认:{error}"))?;
|
||||
if active_voucher_exists(&transaction, document_id, Some(transaction_id))? {
|
||||
return Err("所选票据或流水已经关联到有效凭证".to_string());
|
||||
}
|
||||
let document = document_evidence(&transaction, document_id)?;
|
||||
let effective = effective_transactions(&transaction)?.into_iter().find(|item| item.id == transaction_id)
|
||||
.ok_or_else(|| "找不到所选流水".to_string())?;
|
||||
let candidate = candidate_for(&document, &effective)
|
||||
.ok_or_else(|| "所选流水已不符合金额、方向或日期匹配条件,请刷新候选项".to_string())?;
|
||||
let voucher_id = create_voucher_id(document_id, transaction_id)?;
|
||||
let mut applied_review_id = None;
|
||||
if effective.review_decision.as_deref() == Some("personal") {
|
||||
transaction.execute("INSERT INTO transaction_reviews(transaction_id, decision, actor) VALUES (?1, 'business', 'user')", [transaction_id])
|
||||
.map_err(|error| format!("保存经营收支确认失败:{error}"))?;
|
||||
applied_review_id = Some(transaction.last_insert_rowid());
|
||||
}
|
||||
let mut applied_correction_id = None;
|
||||
if effective.category != category {
|
||||
transaction.execute(
|
||||
"INSERT INTO transaction_corrections(transaction_id, occurred_on, counterparty, category, kind, amount_in_cents, reason, actor) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'user')",
|
||||
params![transaction_id, effective.occurred_on, effective.counterparty, category, effective.kind, effective.amount_in_cents, format!("凭证 {voucher_id} 人工确认分类")],
|
||||
).map_err(|error| format!("保存凭证分类修订失败:{error}"))?;
|
||||
applied_correction_id = Some(transaction.last_insert_rowid());
|
||||
}
|
||||
let evidence = json!({
|
||||
"document": { "fileName": document.file_name, "invoiceNumber": document.invoice_number,
|
||||
"invoiceDate": document.invoice_date, "sellerName": document.seller_name,
|
||||
"amountInCents": document.amount_in_cents },
|
||||
"transactionBeforePosting": { "occurredOn": effective.occurred_on,
|
||||
"counterparty": effective.counterparty, "category": effective.category,
|
||||
"kind": effective.kind, "amountInCents": effective.amount_in_cents,
|
||||
"reviewDecision": effective.review_decision },
|
||||
"match": { "score": candidate.score, "dateDistanceDays": candidate.date_distance_days,
|
||||
"counterpartySimilarity": candidate.counterparty_similarity }
|
||||
});
|
||||
transaction.execute(
|
||||
"INSERT INTO vouchers(id, document_id, transaction_id, category, business_purpose, match_score, match_rule_version, evidence_json, prior_review_decision, prior_category, applied_review_id, applied_correction_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||
params![voucher_id, document_id, transaction_id, category, business_purpose, candidate.score,
|
||||
MATCH_RULE_VERSION, evidence.to_string(), effective.review_decision, effective.category,
|
||||
applied_review_id, applied_correction_id],
|
||||
).map_err(|error| format!("保存凭证失败:{error}"))?;
|
||||
transaction.execute(
|
||||
"INSERT INTO voucher_events(voucher_id, event_type, reason, actor) VALUES (?1, 'confirmed', '人工核对票据与流水后确认', 'user')",
|
||||
[&voucher_id],
|
||||
).map_err(|error| format!("保存凭证确认审计失败:{error}"))?;
|
||||
transaction.commit().map_err(|error| format!("提交凭证确认失败:{error}"))?;
|
||||
Ok(voucher_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn confirm_voucher_match(app: AppHandle, input: ConfirmVoucherInput) -> Result<String, String> {
|
||||
confirm_in_database(&mut open_database(&app)?, input)
|
||||
}
|
||||
|
||||
fn reverse_in_database(connection: &mut Connection, input: ReverseVoucherInput) -> Result<(), String> {
|
||||
let voucher_id = input.voucher_id.trim();
|
||||
let reason = input.reason.trim();
|
||||
if !(2..=200).contains(&reason.chars().count()) { return Err("撤销原因应为 2 到 200 个字符".to_string()); }
|
||||
let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)
|
||||
.map_err(|error| format!("无法开始凭证撤销:{error}"))?;
|
||||
let voucher: Option<(String, Option<String>, String, Option<i64>, Option<i64>)> = transaction.query_row(
|
||||
"SELECT transaction_id, prior_review_decision, prior_category, applied_review_id, applied_correction_id FROM vouchers WHERE id = ?1",
|
||||
[voucher_id], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?)),
|
||||
).optional().map_err(|error| format!("读取待撤销凭证失败:{error}"))?;
|
||||
let (transaction_id, prior_review, prior_category, applied_review_id, applied_correction_id) =
|
||||
voucher.ok_or_else(|| "找不到待撤销凭证".to_string())?;
|
||||
let latest_event: Option<String> = transaction.query_row(
|
||||
"SELECT event_type FROM voucher_events WHERE voucher_id = ?1 ORDER BY id DESC LIMIT 1",
|
||||
[voucher_id], |row| row.get(0),
|
||||
).optional().map_err(|error| format!("读取凭证状态失败:{error}"))?;
|
||||
if latest_event.as_deref() != Some("confirmed") { return Err("这张凭证已经撤销,无需重复处理".to_string()); }
|
||||
let effective = effective_transactions(&transaction)?.into_iter().find(|item| item.id == transaction_id)
|
||||
.ok_or_else(|| "关联流水已经不存在".to_string())?;
|
||||
if applied_correction_id.is_some() && effective.latest_correction_id == applied_correction_id {
|
||||
transaction.execute(
|
||||
"INSERT INTO transaction_corrections(transaction_id, occurred_on, counterparty, category, kind, amount_in_cents, reason, actor) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'user')",
|
||||
params![transaction_id, effective.occurred_on, effective.counterparty, prior_category,
|
||||
effective.kind, effective.amount_in_cents, format!("撤销凭证 {voucher_id}:{reason}")],
|
||||
).map_err(|error| format!("恢复凭证前分类失败:{error}"))?;
|
||||
}
|
||||
if applied_review_id.is_some() && effective.latest_review_id == applied_review_id {
|
||||
if let Some(decision) = prior_review.as_deref() {
|
||||
transaction.execute("INSERT INTO transaction_reviews(transaction_id, decision, actor) VALUES (?1, ?2, 'user')", params![transaction_id, decision])
|
||||
.map_err(|error| format!("恢复凭证前收支确认失败:{error}"))?;
|
||||
}
|
||||
}
|
||||
transaction.execute("INSERT INTO voucher_events(voucher_id, event_type, reason, actor) VALUES (?1, 'reversed', ?2, 'user')", params![voucher_id, reason])
|
||||
.map_err(|error| format!("保存凭证撤销审计失败:{error}"))?;
|
||||
transaction.commit().map_err(|error| format!("提交凭证撤销失败:{error}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn reverse_voucher(app: AppHandle, input: ReverseVoucherInput) -> Result<(), String> {
|
||||
reverse_in_database(&mut open_database(&app)?, input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn list_vouchers(app: AppHandle) -> Result<Vec<StoredVoucher>, String> {
|
||||
let connection = open_database(&app)?;
|
||||
let mut statement = connection.prepare(
|
||||
"SELECT v.id, v.document_id, v.transaction_id,
|
||||
(SELECT event_type FROM voucher_events WHERE voucher_id = v.id ORDER BY id DESC LIMIT 1),
|
||||
v.category, v.business_purpose, v.match_score, d.file_name,
|
||||
json_extract(v.evidence_json, '$.document.invoiceNumber'),
|
||||
json_extract(v.evidence_json, '$.document.sellerName'),
|
||||
json_extract(v.evidence_json, '$.document.invoiceDate'),
|
||||
json_extract(v.evidence_json, '$.document.amountInCents'),
|
||||
json_extract(v.evidence_json, '$.transactionBeforePosting.occurredOn'),
|
||||
json_extract(v.evidence_json, '$.transactionBeforePosting.counterparty'),
|
||||
json_extract(v.evidence_json, '$.transactionBeforePosting.amountInCents'),
|
||||
v.created_at,
|
||||
(SELECT created_at FROM voucher_events WHERE voucher_id = v.id ORDER BY id DESC LIMIT 1)
|
||||
FROM vouchers v JOIN documents d ON d.id = v.document_id
|
||||
ORDER BY v.created_at DESC, v.id DESC"
|
||||
).map_err(|error| format!("读取凭证列表失败:{error}"))?;
|
||||
let rows = statement.query_map([], |row| Ok(StoredVoucher {
|
||||
id: row.get(0)?, document_id: row.get(1)?, transaction_id: row.get(2)?, status: row.get(3)?,
|
||||
category: row.get(4)?, business_purpose: row.get(5)?, match_score: row.get(6)?,
|
||||
file_name: row.get(7)?, invoice_number: row.get(8)?, seller_name: row.get(9)?,
|
||||
invoice_date: row.get(10)?, invoice_amount_in_cents: row.get(11)?,
|
||||
transaction_occurred_on: row.get(12)?, transaction_counterparty: row.get(13)?,
|
||||
transaction_amount_in_cents: row.get(14)?, created_at: row.get(15)?, latest_event_at: row.get(16)?,
|
||||
})).map_err(|error| format!("读取凭证列表失败:{error}"))?;
|
||||
rows.collect::<Result<Vec<_>, _>>().map_err(|error| format!("读取凭证列表失败:{error}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::migrate;
|
||||
|
||||
fn setup() -> Connection {
|
||||
let connection = Connection::open_in_memory().unwrap();
|
||||
migrate(&connection).unwrap();
|
||||
connection
|
||||
}
|
||||
|
||||
fn seed(connection: &Connection) {
|
||||
connection.execute("INSERT INTO imports(file_hash, file_name, row_count) VALUES ('test', 'test.csv', 1)", []).unwrap();
|
||||
connection.execute("INSERT INTO transactions(id, row_hash, occurred_on, counterparty, category, kind, amount_in_cents, confidence, source, import_file_hash) VALUES ('TX-1', 'row-1', '2026-03-18', '京东支付', '待分类', 'expense', 6381, 0.7, '测试', 'test')", []).unwrap();
|
||||
let extraction = json!({"invoiceNumber":"2642", "invoiceDate":"2026-03-16", "sellerName":"武汉京东德瑞贸易有限公司", "totalWithTaxInCents":6381});
|
||||
connection.execute("INSERT INTO documents(id, content_hash, file_name, mime_type, size_bytes, encrypted_content, status) VALUES ('DOC-1', 'doc-hash', 'invoice.png', 'image/png', 1, X'01', 'confirmed')", []).unwrap();
|
||||
connection.execute("INSERT INTO ocr_runs(document_id, provider, operation, request_version, status) VALUES ('DOC-1', 'test', 'test', 'v1', 'succeeded')", []).unwrap();
|
||||
let run_id = connection.last_insert_rowid();
|
||||
connection.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_with_tax_in_cents, confidence, normalized_json) VALUES ('DOC-1', ?1, 'v1', '发票', '2642', '2026-03-16', '', '', '武汉京东德瑞贸易有限公司', '', 6381, 1, ?2)", params![run_id, extraction.to_string()]).unwrap();
|
||||
let suggestion = json!({"direction":"expense", "category":"办公支出", "counterparty":"武汉京东德瑞贸易有限公司", "occurredOn":"2026-03-16", "amountInCents":6381, "businessPurpose":"采购清洁用品", "confidence":0.8, "reason":"商品明细"});
|
||||
connection.execute("INSERT INTO ai_runs(document_id, provider, model, prompt_version, input_hash, status, normalized_json) VALUES ('DOC-1', 'test', 'test', 'v1', 'hash', 'succeeded', ?1)", [suggestion.to_string()]).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deterministic_candidate_requires_exact_amount_direction_and_nearby_date() {
|
||||
let connection = setup(); seed(&connection);
|
||||
let candidates = candidates_from_connection(&connection, "DOC-1").unwrap();
|
||||
assert_eq!(candidates.len(), 1);
|
||||
assert_eq!(candidates[0].transaction_id, "TX-1");
|
||||
assert_eq!(candidates[0].date_distance_days, 2);
|
||||
assert!(candidates[0].score >= 0.82);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirming_and_reversing_appends_auditable_changes() {
|
||||
let mut connection = setup(); seed(&connection);
|
||||
let voucher_id = confirm_in_database(&mut connection, ConfirmVoucherInput {
|
||||
document_id: "DOC-1".to_string(), transaction_id: "TX-1".to_string(),
|
||||
category: "办公支出".to_string(), business_purpose: "采购清洁用品".to_string(),
|
||||
}).unwrap();
|
||||
assert_eq!(connection.query_row("SELECT category FROM transaction_corrections WHERE transaction_id = 'TX-1' ORDER BY id DESC LIMIT 1", [], |row| row.get::<_, String>(0)).unwrap(), "办公支出");
|
||||
reverse_in_database(&mut connection, ReverseVoucherInput { voucher_id: voucher_id.clone(), reason: "流水选择错误".to_string() }).unwrap();
|
||||
assert_eq!(connection.query_row("SELECT event_type FROM voucher_events WHERE voucher_id = ?1 ORDER BY id DESC LIMIT 1", [voucher_id], |row| row.get::<_, String>(0)).unwrap(), "reversed");
|
||||
assert_eq!(connection.query_row("SELECT category FROM transaction_corrections WHERE transaction_id = 'TX-1' ORDER BY id DESC LIMIT 1", [], |row| row.get::<_, String>(0)).unwrap(), "待分类");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reversal_does_not_overwrite_a_later_manual_correction() {
|
||||
let mut connection = setup(); seed(&connection);
|
||||
let voucher_id = confirm_in_database(&mut connection, ConfirmVoucherInput {
|
||||
document_id: "DOC-1".to_string(), transaction_id: "TX-1".to_string(),
|
||||
category: "办公支出".to_string(), business_purpose: "采购清洁用品".to_string(),
|
||||
}).unwrap();
|
||||
connection.execute("INSERT INTO transaction_corrections(transaction_id, occurred_on, counterparty, category, kind, amount_in_cents, reason) VALUES ('TX-1', '2026-03-18', '京东支付', '进货成本', 'expense', 6381, '后续人工调整')", []).unwrap();
|
||||
reverse_in_database(&mut connection, ReverseVoucherInput { voucher_id, reason: "撤销票据关联".to_string() }).unwrap();
|
||||
assert_eq!(connection.query_row("SELECT category FROM transaction_corrections WHERE transaction_id = 'TX-1' ORDER BY id DESC LIMIT 1", [], |row| row.get::<_, String>(0)).unwrap(), "进货成本");
|
||||
}
|
||||
}
|
||||
+157
-5
@@ -168,6 +168,48 @@ interface StoredDocument {
|
||||
suggestion: BookkeepingSuggestion | null;
|
||||
}
|
||||
|
||||
interface VoucherCandidate {
|
||||
documentId: string;
|
||||
transactionId: string;
|
||||
occurredOn: string;
|
||||
counterparty: string;
|
||||
category: string;
|
||||
kind: "income" | "expense";
|
||||
amountInCents: number;
|
||||
reviewDecision: "business" | "personal" | null;
|
||||
score: number;
|
||||
dateDistanceDays: number;
|
||||
counterpartySimilarity: number;
|
||||
recommendedCategory: string;
|
||||
businessPurpose: string;
|
||||
explanation: string;
|
||||
}
|
||||
|
||||
interface StoredVoucher {
|
||||
id: string;
|
||||
documentId: string;
|
||||
transactionId: string;
|
||||
status: "confirmed" | "reversed";
|
||||
category: string;
|
||||
businessPurpose: string;
|
||||
matchScore: number;
|
||||
fileName: string;
|
||||
invoiceNumber: string;
|
||||
sellerName: string;
|
||||
invoiceDate: string;
|
||||
invoiceAmountInCents: number;
|
||||
transactionOccurredOn: string;
|
||||
transactionCounterparty: string;
|
||||
transactionAmountInCents: number;
|
||||
createdAt: string;
|
||||
latestEventAt: string;
|
||||
}
|
||||
|
||||
const bookkeepingCategories = [
|
||||
"销售收入", "进货成本", "经营房租", "水电燃气", "办公支出",
|
||||
"交通差旅", "业务招待", "平台服务费", "税费支出", "其他经营支出",
|
||||
] as const;
|
||||
|
||||
function entityTypeLabel(entityType: BusinessProfile["entityType"]) {
|
||||
return entityType === "sole_proprietor" ? "个体工商户" : "小微企业";
|
||||
}
|
||||
@@ -461,7 +503,7 @@ function App() {
|
||||
|
||||
{page === "dashboard" ? <Dashboard transactions={transactions} summary={activeSummary} isLocal={usingLocalData} onNavigate={setPage} onNotify={notify} /> : null}
|
||||
{page === "confirm" ? <ConfirmPage pending={pending} isLocal={usingLocalData} reviewingId={reviewingId} onConfirm={confirmTransaction} onNotify={notify} /> : null}
|
||||
{page === "documents" ? <DocumentsPage onNotify={notify} /> : null}
|
||||
{page === "documents" ? <DocumentsPage onNotify={notify} onTransactionsChanged={loadLocalTransactions} onNavigate={setPage} /> : null}
|
||||
{page === "transactions" ? <TransactionsPage transactions={transactions} isLocal={usingLocalData} isImporting={isImporting} importResult={importResult} onImport={importCsv} onCreate={createManualTransaction} onCorrect={correctStoredTransaction} onNotify={notify} /> : null}
|
||||
{page === "ledger" ? <LedgerPage transactions={transactions} isLocal={usingLocalData} onNotify={notify} /> : null}
|
||||
{page === "tax" ? <TaxPage onNotify={notify} /> : null}
|
||||
@@ -564,12 +606,105 @@ function documentStatus(document: StoredDocument) {
|
||||
return { label: "已归档", className: "personal" };
|
||||
}
|
||||
|
||||
function DocumentReview({ document, llmReady, busy, onConfirm, onSuggest }: {
|
||||
function VoucherMatchingPanel({ document, activeVoucher, onChanged, onNavigate, onNotify }: {
|
||||
document: StoredDocument;
|
||||
activeVoucher: StoredVoucher | null;
|
||||
onChanged: () => Promise<void>;
|
||||
onNavigate: (page: Page) => void;
|
||||
onNotify: (message: string) => void;
|
||||
}) {
|
||||
const [candidates, setCandidates] = useState<VoucherCandidate[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [category, setCategory] = useState("其他经营支出");
|
||||
const [businessPurpose, setBusinessPurpose] = useState("经营票据支出");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState<"confirm" | "reverse" | null>(null);
|
||||
const [showReverse, setShowReverse] = useState(false);
|
||||
const [reverseReason, setReverseReason] = useState("");
|
||||
const selected = candidates.find((item) => item.transactionId === selectedId) ?? candidates[0] ?? null;
|
||||
|
||||
const chooseCandidate = (candidate: VoucherCandidate) => {
|
||||
setSelectedId(candidate.transactionId);
|
||||
setCategory(candidate.recommendedCategory);
|
||||
setBusinessPurpose(candidate.businessPurpose);
|
||||
};
|
||||
|
||||
const loadCandidates = async () => {
|
||||
if (document.status !== "confirmed" || activeVoucher) { setCandidates([]); return; }
|
||||
setLoading(true);
|
||||
try {
|
||||
const stored = await invoke<VoucherCandidate[]>("list_voucher_candidates", { documentId: document.id });
|
||||
setCandidates(stored);
|
||||
if (stored.length) chooseCandidate(stored[0]);
|
||||
else setSelectedId(null);
|
||||
} catch (error) {
|
||||
onNotify(`读取流水候选失败:${String(error)}`);
|
||||
} finally { setLoading(false); }
|
||||
};
|
||||
|
||||
useEffect(() => { loadCandidates(); }, [document.id, document.status, activeVoucher?.id]);
|
||||
|
||||
const confirmMatch = async () => {
|
||||
if (!selected) return;
|
||||
if (businessPurpose.trim().length < 2) { onNotify("请填写至少 2 个字的经营用途"); return; }
|
||||
setSaving("confirm");
|
||||
try {
|
||||
await invoke<string>("confirm_voucher_match", { input: {
|
||||
documentId: document.id,
|
||||
transactionId: selected.transactionId,
|
||||
category,
|
||||
businessPurpose: businessPurpose.trim(),
|
||||
} });
|
||||
await onChanged();
|
||||
onNotify("票据与流水已关联,凭证和分类修改记录均已保存");
|
||||
} catch (error) { onNotify(`确认凭证失败:${String(error)}`); }
|
||||
finally { setSaving(null); }
|
||||
};
|
||||
|
||||
const reverseMatch = async () => {
|
||||
if (!activeVoucher || reverseReason.trim().length < 2) { onNotify("请填写至少 2 个字的撤销原因"); return; }
|
||||
setSaving("reverse");
|
||||
try {
|
||||
await invoke<void>("reverse_voucher", { input: { voucherId: activeVoucher.id, reason: reverseReason.trim() } });
|
||||
setShowReverse(false); setReverseReason("");
|
||||
await onChanged();
|
||||
onNotify("凭证关联已撤销,审计记录仍然保留");
|
||||
} catch (error) { onNotify(`撤销凭证失败:${String(error)}`); }
|
||||
finally { setSaving(null); }
|
||||
};
|
||||
|
||||
if (document.status !== "confirmed") {
|
||||
return <section className="voucher-panel voucher-locked"><Icon name="shield" size={21}/><div><h3>核对后才能匹配流水</h3><p>先确认发票字段,系统再按金额、方向、日期和交易对方寻找候选流水。</p></div></section>;
|
||||
}
|
||||
|
||||
if (activeVoucher) {
|
||||
return <section className="voucher-panel voucher-linked">
|
||||
<div className="voucher-panel-heading"><div><span>已生成凭证</span><h3>{activeVoucher.category}</h3><p>{activeVoucher.businessPurpose}</p></div><span className="status done">有效</span></div>
|
||||
<div className="voucher-evidence"><div><span>发票</span><strong>{activeVoucher.sellerName}</strong><small>{activeVoucher.invoiceDate},{formatCurrency(activeVoucher.invoiceAmountInCents)}</small></div><Icon name="arrow" size={20}/><div><span>流水</span><strong>{activeVoucher.transactionCounterparty}</strong><small>{activeVoucher.transactionOccurredOn.slice(0, 10)},{formatCurrency(activeVoucher.transactionAmountInCents)}</small></div></div>
|
||||
<div className="voucher-foot"><span>匹配把握 {Math.round(activeVoucher.matchScore * 100)}%,凭证号 {activeVoucher.id}</span><button className="text-button" onClick={() => setShowReverse((value) => !value)}>撤销关联</button></div>
|
||||
{showReverse ? <div className="voucher-reverse"><label><span>撤销原因</span><input maxLength={200} value={reverseReason} onChange={(event) => setReverseReason(event.target.value)} placeholder="例如:选择了错误的银行流水"/></label><button className="secondary-button danger-action" disabled={saving !== null} onClick={reverseMatch}>{saving === "reverse" ? "正在撤销" : "确认撤销"}</button></div> : null}
|
||||
</section>;
|
||||
}
|
||||
|
||||
return <section className="voucher-panel">
|
||||
<div className="voucher-panel-heading"><div><span>流水匹配</span><h3>用付款证据完成凭证</h3><p>金额与收支方向必须完全一致。系统只提供候选项,由你最终确认。</p></div><span className="safe-pill">确定性规则</span></div>
|
||||
{loading ? <div className="voucher-empty"><strong>正在核对本地流水</strong><p>不会调用 OCR 或 LLM。</p></div> : candidates.length === 0 ? <div className="voucher-empty"><strong>还没有符合条件的流水</strong><p>请先导入或手工记录相同金额、相同方向且日期相近的真实收付款流水。</p><button className="secondary-button" onClick={() => onNavigate("transactions")}>前往收支流水</button></div> : <>
|
||||
<div className="voucher-candidates" role="listbox" aria-label="候选流水">{candidates.map((candidate) => <button type="button" role="option" aria-selected={selected?.transactionId === candidate.transactionId} className={selected?.transactionId === candidate.transactionId ? "active" : ""} key={candidate.transactionId} onClick={() => chooseCandidate(candidate)}><span><strong>{candidate.counterparty}</strong><small>{candidate.occurredOn.slice(0, 10)},当前分类:{candidate.category}</small><small>{candidate.explanation}</small></span><span><b>{formatCurrency(candidate.amountInCents)}</b><small>{Math.round(candidate.score * 100)}% 匹配</small></span></button>)}</div>
|
||||
{selected ? <div className="voucher-confirm-form"><label><span>入账分类</span><select value={category} onChange={(event) => setCategory(event.target.value)}>{bookkeepingCategories.map((item) => <option key={item}>{item}</option>)}</select></label><label className="voucher-purpose"><span>经营用途</span><input maxLength={80} value={businessPurpose} onChange={(event) => setBusinessPurpose(event.target.value)} placeholder="说明这笔支出的经营用途"/></label><button className="primary-button" disabled={saving !== null} onClick={confirmMatch}>{saving === "confirm" ? "正在保存凭证" : "确认匹配并入账"}</button></div> : null}
|
||||
</>}
|
||||
</section>;
|
||||
}
|
||||
|
||||
function DocumentReview({ document, llmReady, busy, activeVoucher, onConfirm, onSuggest, onVoucherChanged, onNavigate, onNotify }: {
|
||||
document: StoredDocument;
|
||||
llmReady: boolean;
|
||||
busy: "ocr" | "llm" | "confirm" | null;
|
||||
activeVoucher: StoredVoucher | null;
|
||||
onConfirm: (input: Record<string, unknown>) => Promise<void>;
|
||||
onSuggest: () => Promise<void>;
|
||||
onVoucherChanged: () => Promise<void>;
|
||||
onNavigate: (page: Page) => void;
|
||||
onNotify: (message: string) => void;
|
||||
}) {
|
||||
const extraction = document.extraction;
|
||||
const [invoiceNumber, setInvoiceNumber] = useState(extraction?.invoiceNumber ?? "");
|
||||
@@ -622,19 +757,26 @@ function DocumentReview({ document, llmReady, busy, onConfirm, onSuggest }: {
|
||||
{document.suggestion ? <section className="suggestion-box"><div><span>AI 记账建议</span><strong>{document.suggestion.direction === "income" ? "经营收入" : "经营支出"},{document.suggestion.category}</strong><p>{document.suggestion.businessPurpose}。{document.suggestion.reason}</p></div><b>{Math.round(document.suggestion.confidence * 100)}% 把握</b></section> : null}
|
||||
<div className="document-review-actions"><button className="primary-button" disabled={busy !== null || document.status === "confirmed"} type="submit">{busy === "confirm" ? "正在保存" : document.status === "confirmed" ? "已完成人工核对" : "确认字段无误"}</button><button className="secondary-button" disabled={busy !== null || !llmReady} type="button" onClick={onSuggest}>{busy === "llm" ? "正在生成" : document.suggestion ? "重新生成记账建议" : "生成记账建议"}</button><span>AI 建议不会直接入账</span></div>
|
||||
</form>
|
||||
<VoucherMatchingPanel document={document} activeVoucher={activeVoucher} onChanged={onVoucherChanged} onNavigate={onNavigate} onNotify={onNotify}/>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function DocumentsPage({ onNotify }: { onNotify: (message: string) => void }) {
|
||||
function DocumentsPage({ onNotify, onTransactionsChanged, onNavigate }: {
|
||||
onNotify: (message: string) => void;
|
||||
onTransactionsChanged: () => Promise<Transaction[]>;
|
||||
onNavigate: (page: Page) => void;
|
||||
}) {
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const [providerStatus, setProviderStatus] = useState<ProviderStatus | null>(null);
|
||||
const [documents, setDocuments] = useState<StoredDocument[]>([]);
|
||||
const [vouchers, setVouchers] = useState<StoredVoucher[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(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 activeVoucher = selected ? vouchers.find((item) => item.documentId === selected.id && item.status === "confirmed") ?? null : null;
|
||||
|
||||
const loadDocuments = async () => {
|
||||
const stored = await invoke<StoredDocument[]>("list_documents");
|
||||
@@ -643,9 +785,15 @@ function DocumentsPage({ onNotify }: { onNotify: (message: string) => void }) {
|
||||
return stored;
|
||||
};
|
||||
|
||||
const loadVouchers = async () => {
|
||||
const stored = await invoke<StoredVoucher[]>("list_vouchers");
|
||||
setVouchers(stored);
|
||||
return stored;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauriRuntime()) { setLoading(false); return; }
|
||||
Promise.all([invoke<ProviderStatus>("get_provider_status"), loadDocuments()])
|
||||
Promise.all([invoke<ProviderStatus>("get_provider_status"), loadDocuments(), loadVouchers()])
|
||||
.then(([status]) => setProviderStatus(status))
|
||||
.catch((error) => onNotify(`票据中心初始化失败:${String(error)}`))
|
||||
.finally(() => setLoading(false));
|
||||
@@ -696,12 +844,16 @@ function DocumentsPage({ onNotify }: { onNotify: (message: string) => void }) {
|
||||
finally { setBusy(null); }
|
||||
};
|
||||
|
||||
const voucherChanged = async () => {
|
||||
await Promise.all([loadDocuments(), loadVouchers(), onTransactionsChanged()]);
|
||||
};
|
||||
|
||||
const pendingCount = documents.filter((item) => item.status === "needs_review").length;
|
||||
const confirmedCount = documents.filter((item) => item.status === "confirmed").length;
|
||||
return <div className="page documents-page"><PageIntro eyebrow="票据中心" title="票据先识别,再由你确认" description="原图和识别记录保存在本地加密账本;上传时只有当前图片会发送给百度 OCR。" action={<div className="page-actions"><input ref={fileInput} className="visually-hidden" type="file" accept="image/png,image/jpeg,image/bmp,image/webp" onChange={recognize}/><button className="primary-button" disabled={busy !== null || !ocrReady} onClick={chooseFile}><Icon name="plus" size={17}/>{busy === "ocr" ? "正在识别" : "上传并识别"}</button></div>} />
|
||||
<section className={`provider-strip ${ocrReady ? "ready" : "blocked"}`}><Icon name={ocrReady ? "shield" : "bell"} size={20}/><div><strong>{ocrReady ? `${providerStatus?.ocrProvider}已就绪` : "OCR 当前未启用"}</strong><p>{ocrReady ? "本次上传会调用云端识别;原图同时写入本地 SQLCipher 加密账本。" : loading ? "正在检查本地开发配置。" : "请把 local.providers.toml 中的百度 OCR enabled 改为 true。"}</p></div><span>{providerStatus?.localDevelopmentOnly ? "仅本地开发" : "服务端模式"}</span></section>
|
||||
<section className="document-stats"><article><span>加密归档</span><strong>{documents.length}</strong><small>按内容哈希自动去重</small></article><article><span>等待核对</span><strong>{pendingCount}</strong><small>不会自动写入账簿</small></article><article><span>已人工确认</span><strong>{confirmedCount}</strong><small>修改记录单独保存</small></article></section>
|
||||
{loading ? <section className="empty-state document-loading"><Icon name="file" size={28}/><h2>正在读取加密票据</h2><p>请稍候,系统不会把票据内容写入日志。</p></section> : documents.length === 0 ? <section className="empty-state feature-preview"><Icon name="file" size={32}/><span className="safe-pill">本地加密归档</span><h2>上传第一张经营票据</h2><p>支持 PNG、JPG、BMP 和 WebP,单张不超过 2.5 MB。点击上传即表示同意将当前图片发送给百度 OCR。</p><button className="secondary-button" disabled={!ocrReady} onClick={chooseFile}>选择测试票据</button></section> : <section className="documents-workspace"><div className="document-list-panel"><div className="document-list-heading"><div><h2>票据记录</h2><p>最近上传优先</p></div><span>{documents.length} 张</span></div><div className="document-list">{documents.map((document) => { const status = documentStatus(document); return <button key={document.id} className={selected?.id === document.id ? "active" : ""} onClick={() => setSelectedId(document.id)}><span className="document-list-icon"><Icon name="file" size={18}/></span><span><strong title={document.fileName}>{document.fileName}</strong><small>{document.extraction?.sellerName || document.lastError || "等待识别结果"}</small></span><span className={`status ${status.className}`}>{status.label}</span></button>; })}</div></div><div className="document-detail">{selected ? <DocumentReview key={`${selected.id}-${selected.updatedAt}`} document={selected} llmReady={llmReady} busy={busy} onConfirm={confirm} onSuggest={generateSuggestion}/> : null}</div></section>}
|
||||
{loading ? <section className="empty-state document-loading"><Icon name="file" size={28}/><h2>正在读取加密票据</h2><p>请稍候,系统不会把票据内容写入日志。</p></section> : documents.length === 0 ? <section className="empty-state feature-preview"><Icon name="file" size={32}/><span className="safe-pill">本地加密归档</span><h2>上传第一张经营票据</h2><p>支持 PNG、JPG、BMP 和 WebP,单张不超过 2.5 MB。点击上传即表示同意将当前图片发送给百度 OCR。</p><button className="secondary-button" disabled={!ocrReady} onClick={chooseFile}>选择测试票据</button></section> : <section className="documents-workspace"><div className="document-list-panel"><div className="document-list-heading"><div><h2>票据记录</h2><p>最近上传优先</p></div><span>{documents.length} 张</span></div><div className="document-list">{documents.map((document) => { const status = documentStatus(document); const linked = vouchers.some((item) => item.documentId === document.id && item.status === "confirmed"); return <button key={document.id} className={selected?.id === document.id ? "active" : ""} onClick={() => setSelectedId(document.id)}><span className="document-list-icon"><Icon name="file" size={18}/></span><span><strong title={document.fileName}>{document.fileName}</strong><small>{document.extraction?.sellerName || document.lastError || "等待识别结果"}</small></span><span className={`status ${linked ? "done" : status.className}`}>{linked ? "已入账" : status.label}</span></button>; })}</div></div><div className="document-detail">{selected ? <DocumentReview key={`${selected.id}-${selected.updatedAt}-${activeVoucher?.id ?? "unlinked"}`} document={selected} llmReady={llmReady} busy={busy} activeVoucher={activeVoucher} onConfirm={confirm} onSuggest={generateSuggestion} onVoucherChanged={voucherChanged} onNavigate={onNavigate} onNotify={onNotify}/> : null}</div></section>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
|
||||
@@ -481,6 +481,44 @@ th:nth-child(1) { width: 21%; } th:nth-child(2) { width: 25%; } th:nth-child(3)
|
||||
.danger-button:disabled { cursor: wait; opacity: .6; }
|
||||
.backup-note { padding: 11px 18px; display: flex; align-items: center; gap: 8px; color: #58716b; background: var(--accent-050); border-top: 1px solid #d8e9e4; font-size: 9.5px; }
|
||||
|
||||
.voucher-panel { margin: 0 19px 19px; padding: 15px; border: 1px solid #dfe5e7; border-radius: 9px; background: #f9fbfb; }
|
||||
.voucher-panel-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; }
|
||||
.voucher-panel-heading span:first-child { color: var(--accent-700); font-size: 9px; font-weight: 650; }
|
||||
.voucher-panel-heading h3 { margin: 5px 0 0; font-size: 12px; }
|
||||
.voucher-panel-heading p { margin: 5px 0 0; color: var(--muted); font-size: 9.5px; line-height: 1.5; }
|
||||
.voucher-locked { display: flex; align-items: flex-start; gap: 11px; color: #55716b; background: var(--accent-050); border-color: #cce2dc; }
|
||||
.voucher-locked h3, .voucher-locked p { margin: 0; }
|
||||
.voucher-locked h3 { color: #294741; font-size: 11px; }
|
||||
.voucher-locked p { margin-top: 4px; font-size: 9.5px; }
|
||||
.voucher-linked { background: var(--accent-050); border-color: #c7dfd8; }
|
||||
.voucher-evidence { margin-top: 13px; padding: 12px; display: grid; grid-template-columns: minmax(0, 1fr) 24px minmax(0, 1fr); align-items: center; gap: 10px; background: rgba(255, 255, 255, .7); border: 1px solid #d8e7e3; border-radius: 8px; }
|
||||
.voucher-evidence .icon { color: var(--accent-600); }
|
||||
.voucher-evidence span, .voucher-evidence strong, .voucher-evidence small { display: block; }
|
||||
.voucher-evidence span { color: var(--muted); font-size: 8.5px; }
|
||||
.voucher-evidence strong { margin-top: 4px; overflow: hidden; font-size: 10px; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.voucher-evidence small { margin-top: 4px; color: #64736f; font-size: 9px; }
|
||||
.voucher-foot { margin-top: 11px; display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.voucher-foot > span { color: #60716d; font-size: 8.5px; }
|
||||
.voucher-reverse { margin-top: 10px; padding-top: 11px; display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: 10px; border-top: 1px solid #d3e3df; }
|
||||
.voucher-reverse label > span, .voucher-confirm-form label > span { display: block; margin-bottom: 5px; color: #344149; font-size: 9px; font-weight: 650; }
|
||||
.voucher-reverse input, .voucher-confirm-form input, .voucher-confirm-form select { width: 100%; height: 34px; padding: 0 9px; color: var(--text); background: #fff; border: 1px solid #cbd3d8; border-radius: var(--radius-control); outline: none; font-size: 9.5px; }
|
||||
.voucher-reverse input:focus, .voucher-confirm-form input:focus, .voucher-confirm-form select:focus { border-color: var(--accent-600); box-shadow: 0 0 0 2px rgba(38, 124, 112, .13); }
|
||||
.danger-action { color: #873e32; border-color: #d8aaa2; }
|
||||
.voucher-empty { min-height: 110px; padding: 20px 12px 6px; display: grid; place-content: center; justify-items: center; text-align: center; }
|
||||
.voucher-empty strong { font-size: 11px; }
|
||||
.voucher-empty p { max-width: 500px; margin: 6px 0 11px; color: var(--muted); font-size: 9.5px; line-height: 1.6; }
|
||||
.voucher-candidates { margin-top: 13px; display: grid; gap: 5px; }
|
||||
.voucher-candidates > button { min-height: 65px; padding: 10px 11px; display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 15px; text-align: left; color: var(--text); background: #fff; border: 1px solid #dfe5e7; border-radius: 8px; cursor: pointer; }
|
||||
.voucher-candidates > button:hover { border-color: #b9d4cd; }
|
||||
.voucher-candidates > button.active { background: var(--accent-050); border-color: var(--accent-500); box-shadow: inset 3px 0 0 var(--accent-600); }
|
||||
.voucher-candidates span, .voucher-candidates strong, .voucher-candidates small, .voucher-candidates b { display: block; }
|
||||
.voucher-candidates strong { font-size: 10.5px; }
|
||||
.voucher-candidates small { margin-top: 4px; color: var(--muted); font-size: 8.8px; }
|
||||
.voucher-candidates > button > span:last-child { text-align: right; }
|
||||
.voucher-candidates b { font-size: 11px; font-variant-numeric: tabular-nums; }
|
||||
.voucher-confirm-form { margin-top: 11px; padding-top: 12px; display: grid; grid-template-columns: 150px minmax(190px, 1fr) auto; align-items: end; gap: 10px; border-top: 1px solid #e0e5e7; }
|
||||
.voucher-confirm-form .primary-button { white-space: nowrap; }
|
||||
|
||||
.toast { position: fixed; right: 24px; bottom: 24px; z-index: 10; padding: 12px 15px; color: #fff; background: #24343d; border-radius: var(--radius-control); box-shadow: var(--shadow-float); font-size: 12px; animation: toast-in .18s ease both; }
|
||||
@keyframes toast-in { from { opacity: 0; transform: translateY(8px); } }
|
||||
|
||||
@@ -508,4 +546,6 @@ th:nth-child(1) { width: 21%; } th:nth-child(2) { width: 25%; } th:nth-child(3)
|
||||
.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); }
|
||||
.voucher-confirm-form { grid-template-columns: 1fr 1.4fr; }
|
||||
.voucher-confirm-form .primary-button { grid-column: 1 / -1; justify-self: start; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user