用 PySide6 写一个真正能用的个人财务软件:FinBoard 开发记

• 28 分钟阅读 • code

FinBoard 总览

一、为什么写了一个记账软件

市面上的记账工具并不少,但真正顺手的很少:有的把数据锁在账号里,有的把简单记账做成社区,有的打开一次要加载半天。对我而言,个人财务工具最重要的三件事是:数据属于自己、输入足够快、账目看得懂。

于是有了 FinBoard(中文名「财观」)。它是一个完全离线的 Windows 桌面应用:账户、收支、预算、报表都在一个窗口里,数据存放在本机的 SQLite 文件中,不登录、不联网、不上传。界面走扁平化路线,提供浅色和深色两套主题,资产与负债的比例用环形图直接展示。

这篇文章记录几个关键设计,以及开发过程中踩过的坑。它们大多不是「Qt 不会用」的问题,而是桌面应用特有的细节。

二、选型:为什么是 PySide6,而不是 Electron

最初考虑过 Electron + React:生态成熟、界面好写、图表库多。但这个项目的数据量很小,功能也不依赖网络,引入一整套浏览器运行时并不划算。Electron 安装包动辄一百多 MB,内存占用也高;对一个「打开就能记一笔」的工具来说,太重了。

PySide6 的优势正好相反:

代价是 UI 的开发效率不如 Web,样式系统 QSS 也有自己的脾气。但项目规模不大,这个取舍是值得的。

数据库选择 SQLite:标准库自带,单文件,备份就是复制文件,查询能力足够支撑按月统计、分类聚合和趋势分析。

三、金额绝不用 float

财务软件最不应该出现的就是「一分钱对不上」。浮点数在十进制小数上的表示误差,会在反复累加后变成肉眼可见的偏差。因此 FinBoard 从数据库到内存,所有金额都是 整数分(int cents):

def parse_amount(value: str) -> int:
    text = unicodedata.normalize("NFKC", value).strip()
    text = text.replace("¥", "").replace(",", "").replace(" ", "")
    amount = Decimal(text).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
    cents = int(amount * 100)
    if cents <= 0:
        raise ValueError("金额必须大于 0")
    return cents

显示时再除以 100,并用 Decimal 或整数除法格式化。解析时先做 NFKC 归一化,兼容全角数字、¥、千分位逗号和空格;超出上限的金额直接拒绝,避免溢出和误输入。

这个决定后来救了好几次:CSV 导入、转账、预算进度都只需要处理整数,不用到处担心精度。

四、余额不落库,让它永远算得出来

很多记账软件会在账户表里存一个 balance 字段,每笔流水都去加减。这样做查询快,但一旦某次写入失败、或者删除记录时漏了回滚,余额就会永久性地对不上,而且很难排查。

FinBoard 选择不存余额。账户余额由期初余额和流水实时聚合:

SELECT a.*,
       a.opening_balance
       + COALESCE(SUM(CASE
           WHEN t.kind = 'income' AND t.account_id = a.id THEN t.amount
           WHEN t.kind = 'expense' AND t.account_id = a.id THEN -t.amount
           WHEN t.kind = 'transfer' AND t.account_id = a.id THEN -t.amount
           WHEN t.kind = 'transfer' AND t.to_account_id = a.id THEN t.amount
           ELSE 0 END), 0) AS balance
FROM accounts a
LEFT JOIN transactions t
       ON t.account_id = a.id OR t.to_account_id = a.id
GROUP BY a.id

转账只存一条记录,同时出现在两个账户的聚合里。这样「转出账户减少、转入账户增加」天然一致,不需要事务里写两行,也不会出现只成功一半的情况。

代价是每次账户列表都要做一次聚合。以个人账本的量级(几千到几万条)来说,SQLite 完全撑得住;如果将来数据量真的很大,可以再加汇总表,但那是另一个问题。

报表统计收入/支出时会排除转账,避免「自己转给自己」被算成收入。这个规则在 Repository 层统一处理,页面不需要各自判断。

五、扁平化界面与双主题

界面没有用任何第三方 UI 库,所有控件都是 Qt 原生控件加 QSS。设计上尽量克制:

主题系统的核心是一个 Palette dataclass:

@dataclass(frozen=True)
class Palette:
    mode: str
    bg: str
    surface: str
    text: str
    muted: str
    primary: str
    income: str
    expense: str
    ...

浅色和深色各实例化一份,apply_theme() 同时设置 QPalette、全局 QSS,并发出 theme_store.changed 信号。图表、图标、列表行都监听这个信号,切主题时立即重绘。这样就不需要在每个控件里写 if dark: ...。

QSS 用 string.Template 生成,变量从 Palette 注入。这里踩了一个很隐蔽的坑:Badge 的样式字符串由两段拼接,第二段结尾多写了一个 },结果生成 ...}}。Qt 不会报「多了一个括号」,只会反复打印:

Could not parse stylesheet of object Badge(0x...)

定位时把 Badge.styleSheet() 打印出来才看到问题。QSS 解析失败不会抛异常,只会静默丢弃样式,所以这类问题必须看日志。

六、图表全部自己画

项目没有引入 matplotlib、pyqtgraph 或 ECharts。三类图表都在 paintEvent 里用 QPainter 绘制:

自己画的好处是颜色、圆角、留白都能和界面完全一致,也不需要为了一个环图引入几 MB 的依赖。坏处是坐标计算、文字避让、空数据状态都要自己处理。比如折线图的 Y 轴上限不能直接取最大值,而要向上取整到一个「好看」的刻度;分类太多时只显示前 8 个,其余合并为「其他」。

环形图的百分比直接来自 NetWorth:

assets = sum(a.balance for a in accounts if a.balance > 0)
liabilities = -sum(a.balance for a in accounts if a.balance < 0)
net = assets - liabilities
liability_ratio = liabilities / (assets + liabilities) * 100

负债率用「负债 / (资产 + 负债)」而不是「负债 / 资产」,这样负债超过资产时不会出现大于 100% 的奇怪数字。

七、几个 Qt 桌面开发的坑

1. 离屏模式没有字体

一开始想在 CI 里用 QT_QPA_PLATFORM=offscreen 自动截图,结果 Qt 6.12 报:

Cannot find font directory .../PySide6/lib/fonts
QFontDatabase.families() == 0

所有文字都变成空白。后来改为在原生 Windows 平台下运行截图脚本,系统字体(微软雅黑、Segoe UI)可用,问题消失。离屏渲染适合无字体的纯图形测试,不适合验收界面。

2. resource_path 多了一层 resources

打包前资源路径是 finboard/resources/chevron-down.svg,打包后 _MEIPASS 下也保持同样结构。但早期代码写成了:

resource_path("resources", name)

而 resource_path 内部已经拼了 resources,于是实际路径变成 resources/resources/...。Qt SVG 加载失败时只在控制台输出一行 qt.svg: Cannot open file,界面上只是箭头消失。统一成 resource_path(name) 后解决。

3. QLabel 在水平布局里宽度变成 0

账户列表行原本是「名称 + 类型徽章」横向排列。名称用了自定义的 ElidedLabel,为了实现省略号,把 QSizePolicy 设成了 Ignored。结果在水平布局里它的建议宽度是 0,名称完全不显示,徽章孤零零地飘在左边。

修复方式是改成 Preferred 并设置最小宽度:

name_label.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred)
name_label.setMinimumWidth(40)

4. deleteLater() 不会立刻把控件从布局里拿走

页面刷新时用 clear_layout() 清空列表。最初只调用 deleteLater(),但事件循环还没处理删除事件,旧控件仍然占着位置;连续刷新几次后,新旧行叠在一起,截图里能看到文字重影。

修复是三步:

widget.hide()
widget.setParent(None)
widget.deleteLater()

先隐藏、再脱离父级,最后排队删除。布局立刻重排,视觉上不会残留。

5. 打包后资源与插件

PyInstaller 打包后,资源会放进 sys._MEIPASS 指向的目录(one-file 是临时解压目录,one-dir 是旁边的 _internal)。resource_path() 需要同时兼容源码和打包环境:

bundle_root = getattr(sys, "_MEIPASS", None)
base = Path(bundle_root) / "finboard" / "resources" if bundle_root else ...

spec 文件里显式收集了 ico、png 和两个 SVG,并把 PySide6.QtSvg 加进 hiddenimports,避免打包后 SVG 箭头和勾选图标失效。

6. CSV 导入要「全有或全无」

CSV 导入看起来只是读文件、逐行插库,实际很容易做出半成品:前 100 行导入成功,第 101 行因为账户名写错而失败,数据库就处于一半新一半旧的状态。

FinBoard 的做法是先在内存里把每一行解析成规范化记录,校验日期、类型、金额、账户、分类和转入账户;任何一行出错只记录原因并跳过,不立即写库。所有合法行最后交给 insert_normalized(),在一个事务里批量插入。这样要么全部成功,要么完全不改数据库;导入结果再统一显示「成功多少、跳过多少、每行原因」。

金额列还兼容 ¥、千分位和全角数字,编码先试 UTF-8(含 BOM),失败再回退 GB18030。国内用户从支付宝、微信导出的账单经常是 GB18030,这一步能省掉很多「打开是乱码」的反馈。

7. 设置与窗口状态

主题、货币和窗口几何信息保存在 QSettings("FinBoard", "FinBoard"),不写进数据库。这样即使清空账本、切换演示数据,界面偏好仍然保留。窗口关闭时保存 saveGeometry(),下次启动恢复;如果显示器分辨率变了,Qt 会自动把窗口拉回可见区域。

这些细节不起眼,但用户第一次打开软件时,看到的就是「上次离开时的样子」,这比任何新手引导都自然。

八、测试:把「算得对」交给自动化

UI 很好看,但财务软件的底线是数字正确。项目写了 109 个 unittest,覆盖:

另外有几个冒烟脚本:

python scripts/smoke_core.py       # 核心数字与 CSV
python scripts/smoke_dialogs.py    # 五类编辑对话框
python scripts/smoke_ui.py         # 六个页面截图
python scripts/smoke_app.py        # 真实启动后自动退出

界面截图脚本会在浅色和深色下逐页渲染,保存到 build/pages/。它不能替代人工看设计,但能快速发现「某页打不开」「某个控件宽度为 0」「切主题后文字消失」这类问题。

九、多币种:把「今天」的汇率留在今天

最初的 FinBoard 只有一个全局显示符号,严格来说不算多币种。真正要把外币账户接进来,问题立刻分成三层:账户用哪种币种、交易用哪种币种、报表用哪种币种。我的选择是「账户级 + 交易级」——账户有自己的币种,交易也可以选另一个币种,但汇总永远回到基准币种。

数据库用一次 v1 → v2 迁移解决:accounts 加 currency;transactions 加 currency、account_amount(账户币种的实际入账额)和 to_amount(跨币种转账的到账额);新增 exchange_rates 与 app_meta。旧数据全部默认 CNY,account_amount = amount,不需要用户做任何操作。

汇率表只存一个锚:1 CNY = rate。任意两种币种都经 CNY 中转,避免维护 N×N 张汇率表。换算用 Decimal 做除法和乘法,最后 ROUND_HALF_UP 回整数分;金额在数据库里始终是整数,不会因为连续换算积累浮点误差。

最关键的决定是「按交易日期折算」,而不是按今天的汇率重算历史。每笔交易保存发生日的金额,报表查询时再按 date 取当天或之前最近一个工作日的汇率。这样周末、节假日、甚至几个月后改汇率来源,历史报表都不会漂移。代价是外币交易必须有对应日期的缓存;没有汇率时程序会明确报错,而不是静默按 1:1 处理。

每日更新放在后台线程:worker 只负责 HTTP 请求,拿到快照后回主线程写 SQLite。SQLite 连接不跨线程,UI 也不会因为网络超时卡住。更新失败时保留上一次缓存,并把错误显示在设置页;离线仍然可以记账,只是新增外币交易会被拦住。

界面上,交易对话框会在金额下方实时显示「约 ¥…」的折算预览;跨币种转账可以留空到账金额,由汇率自动算,也可以手动覆盖。账户一旦有流水就禁止改币种,避免历史余额被悄悄重算——这是一个刻意的限制,比提供「一键改币种」更安全。

多币种明细

十、钱包提现手续费:规则要落在余额上

支付宝和微信的零钱提现会收手续费:都是提现金额的 0.1%,支付宝每笔最低 0.1 元,微信每笔最低 0.01 元。需求听起来只是「转账时多扣一点」,真正写起来却牵动账本的四个地方:转出账户余额、转入账户到账额、月度支出统计、分类报表。

我把它做成转账记录上的 fee 字段,而不是一条独立流水。schema 从 v2 升到 v3 时只加 fee,v5 又补了 fee_waived、fee_included 两个状态位,旧交易默认 0,迁移不需要用户操作。理由很实际:用户操作的仍然是一次转账,拆成两条记录会让「转给谁、转了多少」变得难读,也容易在编辑、删除时留下孤儿数据。规则集中在 core/fees.py:按账户名识别「支付宝钱包 / 微信钱包」,以折算后的人民币金额乘 0.1%,四舍五入到分,再套最低值。

手续费有两种落点。部分提现:转出账户扣「提现金额 + 手续费」,转入账户收到填写的金额;全部提现:手续费包含在提现金额里,转出账户只扣填写的金额,转入账户收到扣费后的净额。程序不靠一个「全部提现」复选框,而是比较提现金额与源账户当前可用余额;相等就按全部提现处理。用户还可以勾选「本次豁免手续费」,此时 fee=0,两个账户都按原金额变动。

这个设计有一个必须守住的边界:每个读取余额和统计的地方都要理解 fee 与 included。账户余额 SQL 要写 -(account_amount + CASE WHEN fee_included=0 THEN fee ELSE 0 END);月度支出和趋势要把 fee 按账户币种折算进去;分类报表要把它归入虚拟的「提现手续费」;CSV 导入则不读 fee 列,而是按账户名、金额和日期重新计算。漏掉任何一处,用户就会看到「余额少了,但支出没变」或者「报表说没花钱,账户却少了钱」。

交易对话框会实时显示「预计转入 ¥4,995.00 · 实际扣款 ¥5,000.00」,并把手续费和「本次豁免手续费」放在一起。编辑一笔旧转账时,手续费也会按当前规则和可用余额重新判定。测试覆盖最低值、跨币种、全部/部分提现、豁免、可用余额不足、编辑重算、删除和统计归类,共 12 个用例。

钱包提现手续费

十一、投资市值:把浮盈浮亏留在账户里

证券账户是记账软件里很特殊的一类账户。它的余额不是「期初 + 流水」能解释的:买入、卖出、分红、汇率变化、持仓涨跌都会改变它,但只有真正落袋的收支才应该进入损益。如果每次看盘都去改期初余额,成本、历史和「这个月到底赚了多少」会一起被改掉。

FinBoard 原来的投资账户也是「期初余额 + 流水」。这能记录本金进出,却看不见持仓涨跌。用户提出「三个证券账户每日更新余额」时,我给了三个方案:每天改期初、把市值变动伪装成收入/支出、或者单独存市值快照。最后选了第三种,因为它只改余额,不污染收支。

实现上只加了一张 account_valuations 表(schema v4):账户、日期、市值、备注,UNIQUE(account_id, date)。同一天重复保存就覆盖,不会出现两条互相矛盾的收盘价。最新一条快照接管账户余额:余额 = 最新市值 + 快照日之后的收入 − 支出 ± 转账。这里的关键是严格用 date > 快照日期,而不是 >=:收盘市值本身已经包含了当天发生的买卖和费用,再把当天的流水加一遍就会重复计算。没有快照时,仍然退回「期初余额 + 全部流水」,所以老账户不需要做任何迁移。

SQL 里用一个 LEFT JOIN 找每个账户的最新快照,再用条件聚合只累加快照之后的流水。这个查询同时服务账户列表、净资产和账户详情,避免三个地方各算一套。快照不写入 transactions,因此月度收入、支出、预算和分类报表完全不会把浮盈当成收入;它只改变账户余额和净资产。外币投资账户也一样:市值用账户币种记录,净资产按最新汇率折算。

界面没有做一张复杂的行情表,而是在账户行加了一个折线按钮。点开「市值记录」,上方是最新市值、较上次变化和当前余额,中间是一条自绘的市值曲线,下方是历史快照列表。曲线和折线图共用 QPainter 的画法:网格、面积、圆点、悬停提示,不引入图表库。记录时可以选日期、填收盘总市值、加一句备注;删除旧快照后,余额会按上一份快照(或期初余额)重新计算,账户页立即刷新。

这个功能也带来几个必须守住的约束:只有投资理财账户能记录市值;快照日期不能晚于今天;有快照后不能再改期初余额、账户类型或币种,否则历史余额会失去解释。测试覆盖了快照日流水的边界、同日覆盖、删除重算、快照后转账、净资产和服务信号,共 9 个用例。

它仍然不是券商对账单。用户需要自己填每天的收盘总市值;程序负责把它和现金流分开,并保留一条可回看的曲线。对个人记账来说,这比自动抓行情更可控,也不需要保存券商的登录凭证。

投资市值

十二、英语界面:把文案从代码里拿出来

英语界面是最后一个加进来的需求,也是最容易「看起来做完了、实际上到处漏」的一个。要求很简单:设置里能切中文/English,默认中文,切换立即生效。

没有引入 Qt Linguist 的 .ts/.qm 流程。这个项目的界面文案是中文源码,量不大,用一份 locales/en.json 做精确映射更直接:t("总览") 查表得到 Overview,带参数的句子用 {amount} 占位符;像「共 3 笔记录」「已用 ¥X · 超支 ¥Y」这种运行时拼出来的句子,再用一组正则兜底。这样不需要给每个控件改写成 tr(),也不会因为漏了某个 .ts 文件而在打包后整片空白。

真正麻烦的是三件事。第一,用户数据不能翻译。账户名「支付宝」、交易对方「山姆会员店」、备注和自定义分类名,都不是界面文案;如果遍历控件树时无差别翻译,它们会变成 Alipay、Sam’s Club,甚至被截断。解决方法是给这些控件打上 i18nSkip 属性,翻译器沿父链检查,整棵子树跳过。

第二,动态字符串必须换成模板。原来代码里大量 f"已用 {amount} · 剩余 {rest}",英文语序不同,不能靠替换中文词解决。只能把这些句子改成 t("已用 {used} · 剩余 {amount}", ...),再把对应模板放进词典。图表里的「万/亿」也不能直接给英文用户看,于是加了语言感知的 compact_amount():中文仍显示 ¥2万,英文显示 ¥20k。

第三,重建窗口后要恢复现场。切换语言时销毁中央控件、重新构建页面,再恢复到当前页;同时更新窗口标题、侧栏余额、主题按钮和语言下拉。设置写入 QSettings,下次启动继续使用;命令行 --language en 可以临时覆盖,方便截图和排查。

还有一个细节:英文按钮文字更长,原来按中文宽度写死的类型徽章、分类列、币种下拉会截断。于是徽章改成按字体宽度动态画,表格列宽重新分配;对话框拉宽后仍在 720p 屏幕上放得下。截图验收时逐页看英文版,比只看翻译文件可靠得多。

这一轮把测试加到 59 个,新增的 tests/test_i18n.py 覆盖默认语言、词典与模板、日期/金额格式、动态单位,以及「用户数据子树不翻译」这个最容易回归的规则。

十三、定期交易与账户对账:自动化也要有边界

定期交易最诱人的做法是「到点自动记账」,但这个项目故意没有这么做。规则只保存模板和 next_date,到期后出现在「定期」页,用户点确认才真正写入。原因很实际:程序没开的那几天,房租、工资、订阅并不会因为软件不知道就不发生;如果启动后静默补一堆账,用户很难判断哪一笔是真的、哪一笔是重复的。把确认权留在用户手里,比炫技式的全自动可靠。

规则支持每天、每周、每月、每年和自定义间隔。每月 31 日的规则在 2 月会自动夹到 28/29 日,避免 date(2026,2,31) 直接抛异常;入账后按同一规则推进下一次日期,超过结束日期则自动停用。核心测试覆盖了月末夹取、到期判断、单笔/全部入账和暂停。

账户对账解决的是另一个方向的误差:账本和银行实际余额对不上。做法不是补一条「神秘支出」,而是把差额写进 accounts.adjustment,同时留下一条对账历史。它不产生 transactions,所以不会污染收支、预算和分类报表;删除对账记录时再把这个差额撤销。投资账户不开放对账,因为它们应该用市值快照校准。

这一轮测试从 64 增加到 74 个,新增 tests/test_v2.py 覆盖定期规则推进、对账差额/撤销、页面和对话框。

定期交易

十四、2.1:持仓、结转、储蓄与预测

2.1 加的四件事,看起来是四个功能,落到数据层其实是四种不同的「时间观」。

持仓:数量不能是 float

股票、基金、ETF 的数量可以是 100 股、1.5 份、0.001 份,但不能用浮点数存。原因和金额一样:0.1 + 0.2 在二进制里不等于 0.3,持仓数量一旦参与成本、市值和收益率计算,误差会被放大。

所以 holdings.quantity_units 用「百万分之一份」的整数存:100 股存成 100_000_000,1.5 份存成 1_500_000。成本、市值和盈亏全部先乘后除,最后四舍五入到分:

cost_value   = round(quantity_units * cost_price / 1_000_000)
market_value = round(quantity_units * price / 1_000_000)

代码里用 (a * b + 500_000) // 1_000_000 做整数四舍五入,不经过浮点。持仓只在投资账户上允许;录入名称、代码、数量、成本价和现价后,表里直接显示成本、市值、浮动盈亏和收益率。

持仓和现金分开。投资账户没有市值快照时,账户总值 = 现金 + 持仓市值;现金通过差额校准写入 adjustment,不生成收支,也不影响持仓成本。这样用户既可以只填持仓,也可以填「现金 + 持仓」,不会把未投资现金误算进成本。对话框底部提供「按市值校准账户」:把现金和持仓合计写成当天的估值快照,和手动记市值走同一条路径。

预算结转:只把「正剩余」带下去

预算结转的规则很容易写错。第一版想法是「本月预算 − 本月支出」,如果剩下 700 元就带到下月。但这样会忽略上个月本身也可能带入了上个月的结转,递归几层后金额会重复。

正确做法是递归计算每个月的「可用预算」:

available(month) = amount(month) + carry(month)
carry(month)     = previous.rollover ? max(0, available(prev) - spent(prev)) : 0

只带上一个月的正剩余;超支时 carry = 0,不会把负预算继续滚下去。实现里用 (month, category_id) 缓存,避免同一个月被重复递归。总预算和分类预算都走同一条路径,BudgetLine 同时暴露 amount、rollover、available 和 remaining,UI 就能把「预算 8000 + 上月结转 500 = 可用 8500」解释清楚。

储蓄目标:一个很小的账本

储蓄目标不需要和账户余额绑定。它只是一个独立的「目标金额 + 已存金额」小账本:存入增加已存,取出减少已存,但不允许小于 0。目标日期可选,进度按 min(100, saved / target) 计算,达成后显示 Badge。真正的钱仍然在账户里,目标只负责表达意图,这样不会出现「为了记目标而伪造一笔转账」的脏数据。

现金流预测:把「定期」和「平均」分开

预测最怕两件事:把转账当成收入/支出,以及把定期交易和历史平均重复计算。

实现里先把两者分开:

  1. 期初现金只统计 cash / debit 账户,按基准币种折算;投资账户和信用卡不参与,避免把「卖不出去的资产」当成可花的钱。
  2. 非定期收支取最近 3 个完整月的平均值,查询时排除 recurring_id IS NOT NULL 的流水,避免已经由定期规则投影的金额再被平均一次。
  3. 定期规则从 next_date 开始逐次投影,直到预测窗口结束;月度规则用 calendar.monthrange 处理 31 号,遇到 2 月自动夹到月末;超过 end_date 立即停止。
  4. 转账完全不计入现金流,因为它只是账户之间移动。

预测结果是 income / expense / net / ending_cash 四个数,曲线用已有的 TrendChart 画,逐月表把假设写清楚。它不是预言,只是把「如果继续这样生活,现金会怎么走」摊开给用户看。

这一轮测试从 74 增加到 91 个,新增 tests/test_v21.py、tests/test_v21_ui.py,覆盖结转递归、持仓整数计算、按市值校准、储蓄目标存取、预测的现金口径、结束日期和转账排除。

十五、3.0:把「一笔账」变成可拆、可找、可带走

3.0 的三组功能,分别解决三个长期问题:CSV 导入太死、交易信息太薄、报表只能在软件里看。

CSV 导入:规则是映射,不是硬编码

上一版只认真实导出时的固定表头。现实里的银行账单可能是「交易时间 / 收/支 / 金额 / 户名 / 商户」,也可能是 GB18030 编码。3.0 把导入拆成三步:

  1. 先读表头,自动识别常见别名;
  2. 让用户把 10 个目标字段映射到实际列,并可保存为规则;
  3. 逐行解析、校验,再在一个事务里插入,失败行只跳过自己。

去重不靠「整行文本相同」,而是生成 import_hash:日期、类型、金额、币种、账户、转入账户、对方。数据库对它建部分唯一索引,同时再和已有流水做一次字段匹配,避免规则改动后哈希失效。重复行计入 duplicates,不会悄悄写第二遍。

拆分、标签、附件:账本要能解释自己

一笔 100 元的超市小票,可能 40 元是餐饮、60 元是日用品。3.0 允许把它拆成多条 transaction_splits,但有一条硬约束:

sum(split.amount) == transaction.amount

合计不等于交易金额就直接拒绝;转账也不允许拆分。拆分金额使用交易币种;分类报表按拆分统计,预算也复用同一份 category_breakdown,所以「预算里看到的餐饮」和「报表里看到的餐饮」不会打架。

标签是轻量的多对多:最多 20 个,每个 20 字,重名不区分大小写。附件则把文件复制到 data_dir/attachments/,数据库只保存原文件名、存储名、MIME、大小和创建时间;删除交易不会偷偷删文件,删除附件才删副本。附件不加密,所以备份数据库时要连同目录一起备份。

导出:让报表离开窗口

Excel 用 openpyxl,三个 sheet:概览、支出分类、收支明细。PDF 不额外引入报表库,直接用 QTextDocument + QPdfWriter 排 A4:标题、四张汇总卡、分类表、明细表。这里踩了一个小坑:给 QTextDocument 设置 writer.width()/height() 会把内容缩成左上角一小块;正确做法是让 writer 用 96 DPI,由 print_() 自己处理页面。表格列宽也要显式写 width,否则日期会被拆成两行。

这一轮测试从 91 增加到 97 个,新增 tests/test_v30.py,覆盖 schema v10、拆分/标签回放、拆分合计校验、附件复制/删除、CSV 规则与去重、Excel 导出。

CSV 导入

十六、4.0:收益要完整,批量操作要能回头

3.0 让账本更完整,4.0 继续补两块:投资账户的「累计收益」不能只算浮动盈亏,以及批量改账不能一改就回不去。

持仓收益:浮动、已实现、分红分开记

一只股票的真实收益不只是「现价减成本」。卖出后的已实现收益、持有期间收到的现金分红,都应该进入这只持仓的累计收益,但不应该重复计入市值。于是 holdings 表增加两列:

累计收益 = 浮动盈亏 + 已实现收益 + 现金分红。成本收益率仍以当前持仓成本为分母,累计收益率则把这三部分都算进去。这样既保留「当前仓位赚了多少」,也能回答「这只标的到今天一共赚了多少」。

批量编辑与撤销:先记旧值,再改新值

持续记账之后,最常见的维护动作不是单笔编辑,而是「这个月的餐饮都归到外卖」「给这几笔报销加同一个标签」。4.0 在收支明细里支持多选,批量修改分类、添加或移除标签。

批量操作的风险是误操作。实现上,每次批量更新前把受影响交易的「原分类 + 原标签」序列化存入 undo_log,再在同一个事务里更新。点「撤销」时读取最后一条日志,把分类和标签恢复,删除日志。只支持撤销上一笔批量修改,不尝试做无限撤销栈——对桌面工具来说,这个边界足够清晰,也更容易保证数据一致。

转账、已拆分交易、分类类型不匹配的交易会被跳过,并在结果里告诉用户更新了多少笔。测试覆盖了批量更新、跳过规则、撤销恢复、空撤销和持仓累计收益,目前总计 109 个 unittest。

十七、4.1:固定资产不只是一串流水

账户和流水适合描述“钱怎么进出”,但不适合描述“家里有什么”。房子、车子平时不会产生一笔交易,却实实在在占着净资产的大头。4.1 增加了独立的固定资产模块,首版刻意做得很小:名称、类型、购买价、当前估值、估值日期、备注。

数据上新增 fixed_assets 表(schema v12),金额仍然是整数分,币种按估值日汇率折算成基准币种。它不写入 transactions,所以不会污染收入、支出、预算或现金流预测;只在计算净资产和资产结构时,作为独立的一块加进来。

净资产因此拆成三部分:账户资产、固定资产、负债。总览和报表的环图也多了一个“固定资产”扇区。这个区分很重要:投资账户里的钱可以随时卖掉变成现金,房子不行;把两者混成一个“资产”数字,会让现金流预测显得过于乐观。

界面没有做折旧、贷款关联、产权比例和处置流程。它们当然有用,但会把“记录一个估值”变成一套会计系统。首版的边界是:用户自己填当前估值,程序负责把它正确地计入总资产,并在环图里单独展示。等真实使用暴露出需求,再决定要不要往下做。

这一版测试增加到 109 个,新增用例覆盖固定资产 CRUD、买卖价与估值、外币折算、净值汇总,以及“固定资产不影响现金流预测”的边界。

十八、打包与结果

最终用 PyInstaller 打成 one-dir:

python -m PyInstaller FinBoard.spec --noconfirm --clean

产物是 dist/FinBoard/FinBoard.exe,分发时把整个 FinBoard/ 目录一起打包。没有选 one-file,是因为 one-file 启动时要先把 Qt 运行时解压到临时目录,速度更慢;在临时目录受限的 Windows 环境里,自解压还可能直接失败。one-dir 启动更直接,排查资源问题也更清楚。

数据默认写入 %LOCALAPPDATA%\FinBoard\,升级 exe 不会影响账本;如果用户目录不可写(便携盘或受限环境),程序会回退到 exe 旁的 FinBoard-Data\ 并在启动时提示。

目前 FinBoard 已实现:

深色主题

十九、后续可以做什么

下一步想做的不是堆功能,而是把几个真实场景补完整:

  1. 预算预测:根据本月已用速度和剩余天数,提示「按当前节奏会超支多少」。
  2. 账单预测:基于已有定期规则,提前提示下个月的固定支出和现金流缺口。
  3. 银行/支付账单导入:不同渠道的 CSV 列名差异很大,可以做成可配置的映射模板。
  4. 数据加密:本地 SQLite 目前是明文;如果需要,可以用 SQLCipher 或系统密钥加密。

源码详见Github仓库:https://github.com/Urtaome0211/FinBoard

阅读进度 0%