From 0a291ee1c83b8350227726ca53b91ae6fa0c6cb9 Mon Sep 17 00:00:00 2001 From: leefer Date: Sat, 1 Aug 2026 19:25:55 +0800 Subject: [PATCH] feat: link reviewed invoices to ledger transactions --- docs/ARCHITECTURE.md | 6 + docs/MAINTENANCE_GUIDE.md | 3 + docs/SMART_DOCUMENTS.md | 9 +- src-tauri/src/lib.rs | 50 +++- src-tauri/src/vouchers.rs | 465 ++++++++++++++++++++++++++++++++++++++ src/App.tsx | 162 ++++++++++++- src/styles.css | 40 ++++ 7 files changed, 727 insertions(+), 8 deletions(-) create mode 100644 src-tauri/src/vouchers.rs diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 33a05bb..d482ba3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -75,3 +75,9 @@ - 服务端按经营主体做鉴权、限流、调用审计和数据隔离。 - OCR 与 LLM 的响应都先经过服务端结构校验;客户端仍保留金额、日期和枚举白名单等确定性校验。 - AI 只生成建议,不能直接写入账簿、修改税额或代替人工确认。 + +## 票据、流水与凭证 + +票据证明交易内容,流水证明资金实际收付,凭证负责把两类证据关联起来。三者不互相冒充:确认发票不会创建流水,导入流水也不会自动认定某张发票属于它。 + +当前一对一匹配由版本化确定性规则完成。金额和收支方向必须完全一致,日期必须在允许窗口内,交易对方名称只影响排序。用户确认后,系统保存匹配证据快照并按需追加流水分类修订;撤销通过新的审计事件和反向修订完成,不删除原记录,也不覆盖撤销前已经发生的后续人工修改。 diff --git a/docs/MAINTENANCE_GUIDE.md b/docs/MAINTENANCE_GUIDE.md index d540ae1..b9b2fe4 100644 --- a/docs/MAINTENANCE_GUIDE.md +++ b/docs/MAINTENANCE_GUIDE.md @@ -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` 把项目根目录映射到纯英文盘符后构建;映射只服务于编译,不得写入程序配置或用户数据路径。 + ## 故障定位顺序 先记录用户看到的中文错误和触发步骤,再判断属于界面、命令、数据库迁移还是文件系统。修复后必须增加能复现问题的自动测试。不要让用户手工编辑数据库或密钥文件。 diff --git a/docs/SMART_DOCUMENTS.md b/docs/SMART_DOCUMENTS.md index dee5250..a78c378 100644 --- a/docs/SMART_DOCUMENTS.md +++ b/docs/SMART_DOCUMENTS.md @@ -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. 确认凭证不会新建第二笔流水,撤销后原始票据、流水和凭证历史仍可追溯。 diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 48f622c..93bad25 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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); diff --git a/src-tauri/src/vouchers.rs b/src-tauri/src/vouchers.rs new file mode 100644 index 0000000..877bb7f --- /dev/null +++ b/src-tauri/src/vouchers.rs @@ -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, + latest_review_id: Option, + latest_correction_id: Option, +} + +#[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, + 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 { + let row: Option<(String, String, Option, Option)> = 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).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, 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::, _>>().map_err(|error| format!("读取可匹配流水失败:{error}")) +} + +fn active_voucher_exists(connection: &Connection, document_id: &str, transaction_id: Option<&str>) -> Result { + 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 { + let mut parts = value.get(..10)?.split('-'); + let mut year = parts.next()?.parse::().ok()?; + let month = parts.next()?.parse::().ok()?; + let day = parts.next()?.parse::().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::().to_lowercase(); + for suffix in ["有限责任公司", "股份有限公司", "有限公司", "公司"] { + normalized = normalized.replace(suffix, ""); + } + normalized +} + +fn longest_common_run(left: &str, right: &str) -> usize { + let right: Vec = 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 { + 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, 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, String> { + candidates_from_connection(&open_database(&app)?, document_id.trim()) +} + +fn create_voucher_id(document_id: &str, transaction_id: &str) -> Result { + 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 { + 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 { + 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, Option, Option)> = 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 = 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, 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::, _>>().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(), "进货成本"); + } +} diff --git a/src/App.tsx b/src/App.tsx index d4b7223..84ee55d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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" ? : null} {page === "confirm" ? : null} - {page === "documents" ? : null} + {page === "documents" ? : null} {page === "transactions" ? : null} {page === "ledger" ? : null} {page === "tax" ? : 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; + onNavigate: (page: Page) => void; + onNotify: (message: string) => void; +}) { + const [candidates, setCandidates] = useState([]); + const [selectedId, setSelectedId] = useState(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("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("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("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

核对后才能匹配流水

先确认发票字段,系统再按金额、方向、日期和交易对方寻找候选流水。

; + } + + if (activeVoucher) { + return
+
已生成凭证

{activeVoucher.category}

{activeVoucher.businessPurpose}

有效
+
发票{activeVoucher.sellerName}{activeVoucher.invoiceDate},{formatCurrency(activeVoucher.invoiceAmountInCents)}
流水{activeVoucher.transactionCounterparty}{activeVoucher.transactionOccurredOn.slice(0, 10)},{formatCurrency(activeVoucher.transactionAmountInCents)}
+
匹配把握 {Math.round(activeVoucher.matchScore * 100)}%,凭证号 {activeVoucher.id}
+ {showReverse ?
: null} +
; + } + + return
+
流水匹配

用付款证据完成凭证

金额与收支方向必须完全一致。系统只提供候选项,由你最终确认。

确定性规则
+ {loading ?
正在核对本地流水

不会调用 OCR 或 LLM。

: candidates.length === 0 ?
还没有符合条件的流水

请先导入或手工记录相同金额、相同方向且日期相近的真实收付款流水。

: <> +
{candidates.map((candidate) => )}
+ {selected ?
: null} + } +
; +} + +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) => Promise; onSuggest: () => Promise; + onVoucherChanged: () => Promise; + 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 ?
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 }) { +function DocumentsPage({ onNotify, onTransactionsChanged, onNavigate }: { + onNotify: (message: string) => void; + onTransactionsChanged: () => Promise; + onNavigate: (page: Page) => void; +}) { const fileInput = useRef(null); const [providerStatus, setProviderStatus] = useState(null); const [documents, setDocuments] = useState([]); + const [vouchers, setVouchers] = 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 activeVoucher = selected ? vouchers.find((item) => item.documentId === selected.id && item.status === "confirmed") ?? null : null; const loadDocuments = async () => { const stored = await invoke("list_documents"); @@ -643,9 +785,15 @@ function DocumentsPage({ onNotify }: { onNotify: (message: string) => void }) { return stored; }; + const loadVouchers = async () => { + const stored = await invoke("list_vouchers"); + setVouchers(stored); + return stored; + }; + useEffect(() => { if (!isTauriRuntime()) { setLoading(false); return; } - Promise.all([invoke("get_provider_status"), loadDocuments()]) + Promise.all([invoke("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
} />
{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}
} + {loading ?

正在读取加密票据

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

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

上传第一张经营票据

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

:

票据记录

最近上传优先

{documents.length} 张
{documents.map((document) => { const status = documentStatus(document); const linked = vouchers.some((item) => item.documentId === document.id && item.status === "confirmed"); return ; })}
{selected ? : null}
} ; } diff --git a/src/styles.css b/src/styles.css index 52d124d..e488174 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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; } }