从零写个税模拟器:收入记录建模与只读查询源码
做个税模拟器的教学页面时,一个月份可能对应多条收入记录。本文结合我维护的 Mou652 项目,给出一段独立源码,演示怎样保留记录身份、生成查询快照,并让列表和合计采用同一口径。
从记录模型开始
这是一篇界面教学程序的实现练习。示例在内存里工作,使用固定的虚构金额与年度,不连接税务账户。预扣税额是样本中预置的展示值,程序只做筛选和加总,不根据收入推算税额。
先给每条记录一个稳定的 id。月份表示时间范围,id 表示某一笔记录。两条记录同属一月,甚至金额相同,也不能因为月份相同就互相覆盖。页面列表、单笔详情和汇总都应围绕同一组记录组织。
本例的数据约定是:记录为扁平对象,字段只含字符串和数字;id 是上游已校验的唯一字符串;年度和月份是整数;收入与预置税额均以整数“分”保存。示例函数会检查查询月份和选中记录的金额,不承担完整的数据导入校验。
先写出期望结果
准备五条记录:2025 年一月两笔工资薪金、五月一笔工资薪金、一月一笔劳务示例,以及 2024 年的一笔工资薪金。查询条件是 2025 年、一月和五月、工资薪金。
| 记录 | 年度与月份 | 类型 | 收入(分) | 预置税额(分) |
|---|---|---|---|---|
| a | 2025-01 | salary | 600000 | 10000 |
| b | 2025-01 | salary | 650000 | 12000 |
| c | 2025-05 | salary | 700000 | 14000 |
| d | 2025-01 | labor | 90000 | 0 |
| e | 2024-01 | salary | 800000 | 0 |
正确结果是三笔记录、覆盖两个月,收入合计 1950000 分、预置税额合计 36000 分。换成元分别为 19500.00 和 360.00。这里不评价示例税额是否符合任何实际税务规则。
核心代码:生成一份查询结果
把下面的完整代码保存为 income-view.mjs,在已有 Node.js 环境执行 node income-view.mjs 即可看到输出。不需要安装第三方依赖。它是为本文独立编写的教学实现,代码按 MIT 许可提供,并非手机 App 私有工程的摘录。
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Mou652
export function buildIncomeView(records, { year, months, incomeType }) {
if (!Number.isInteger(year) || !Array.isArray(months) ||
!months.every(m => Number.isInteger(m) && m >= 1 && m <= 12)) {
throw new TypeError('年度或月份不符合约定');
}
const allowedMonths = new Set(months);
// 生成当前条件下的记录副本
const rows = records
.filter(r => r.year === year && r.incomeType === incomeType &&
allowedMonths.has(r.month))
.map(r => {
for (const amount of [r.incomeFen, r.withheldFen]) {
if (!Number.isSafeInteger(amount) || amount < 0) {
throw new TypeError('示例金额必须为非负安全整数分');
}
}
return Object.freeze({ ...r });
})
.sort((a, b) => b.month - a.month || a.id.localeCompare(b.id));
// 从同一份明细计算收入与预置税额
const incomeFen = rows.reduce((n, r) => n + r.incomeFen, 0);
const withheldFen = rows.reduce((n, r) => n + r.withheldFen, 0);
if (![incomeFen, withheldFen].every(Number.isSafeInteger)) {
throw new RangeError('合计超出安全整数范围');
}
return Object.freeze({
year, incomeType, rows: Object.freeze(rows),
rowCount: rows.length,
monthCount: new Set(rows.map(r => r.month)).size,
incomeFen, withheldFen,
notice: '虚构示例,仅用于界面教学'
});
}
const records = [
{ id: 'a', year: 2025, month: 1, incomeType: 'salary', incomeFen: 600000, withheldFen: 10000 },
{ id: 'b', year: 2025, month: 1, incomeType: 'salary', incomeFen: 650000, withheldFen: 12000 },
{ id: 'c', year: 2025, month: 5, incomeType: 'salary', incomeFen: 700000, withheldFen: 14000 },
{ id: 'd', year: 2025, month: 1, incomeType: 'labor', incomeFen: 90000, withheldFen: 0 },
{ id: 'e', year: 2024, month: 1, incomeType: 'salary', incomeFen: 800000, withheldFen: 0 }
];
const view = buildIncomeView(records, {
year: 2025, months: [1, 5], incomeType: 'salary'
});
console.log({
rowCount: view.rowCount, monthCount: view.monthCount,
incomeFen: view.incomeFen, withheldFen: view.withheldFen
});
终端输出应为:
{ rowCount: 3, monthCount: 2, incomeFen: 1950000, withheldFen: 36000 }
这段源码做了哪些取舍
筛选只在一个地方完成。先得到 rows,再从 rows 计算收入、预置税额和覆盖月份。接到界面时,列表渲染 rows,顶部汇总读取 incomeFen 与 withheldFen,详情按照 id 从同一份 rows 查找,三个区域便有了明确的数据来源。
months 通过集合表达可选月份。一月在查询条件中出现两次,不会把记录复制两遍;传入空数组则返回空结果。本例不把空数组偷偷解释成“全年”,调用者需要全年时应明确传入一至十二月。
记录笔数和覆盖月份数分开计算。一月有两条记录时,rowCount 增加两笔,monthCount 只计一个月。不能用月份作为唯一键保存记录,也不能用列表长度直接代表有收入的月份数量。
结果按月份倒序排列,同月按 id 排列。这里的 id 排序只是让演示结果稳定,不代表同月的业务先后顺序;需要展示申报时间时,应增加相应字段和明确规则。
为什么要复制并冻结记录
只复制数组,里面的对象仍可能和编辑区共享。这里逐条复制记录,再冻结记录、数组及结果对象,因此在本例的扁平数据约定下,修改原始记录不会改变已生成的查询快照。界面要更新时,重新生成下一份结果。
冻结是内存中的变化约束,不等于写入磁盘、事务提交或安全防护。如果以后加入嵌套对象,浅复制与浅冻结就不足以隔开内部变化,需要重新定义数据结构或复制策略。手机端的保存、恢复和异步请求也需要各自实现。
金额在安全整数范围内加总,超过范围直接报错。展示时再把分格式化成元;不要一边累加格式化字符串,一边依赖页面文本反向计算。这一练习只支持非负示例金额,退款或更正记录需要单独定义规则。
怎样验证后再接到界面
可以围绕同一组输入检查这些情况:
- 同月两笔记录都保留,详情仍能按不同 id 找到
- 把月份改成 [1],结果为两笔、一个月、收入 1250000 分
- 选择没有样本的年度或传入空月份,列表为空、两个合计都为零
- 重复选择同一个月份,记录不重复;改变原始记录,旧快照不跟着改变
- 选中记录含负数、小数分或超出安全整数的金额,得到明确错误
接入界面时,可以先展示“条件、明细、合计、虚构示例说明”四个区域,再补加载和错误提示。异步查询需要避免旧请求覆盖新条件;本文没有实现网络、持久化或移动端页面,独立示例的运行结果也不代表整套 App 的设备验证。
项目资料与交流
本文由 Mou652 个税模拟器维护者整理。该项目提供安卓 Android 与 iPhone/iOS 的非官方界面教学演示,可配置虚构资料,讲解年度查询、收入明细和截图录屏。
公开仓库目前以文档、界面展示和使用教程为主,不包含可构建的完整 App 主体源码。本文已给出独立练习的全部代码,可以直接运行;代码问题可在评论区或项目 Issues 中交流。版本与教学使用咨询 QQ:2430239942。
创作说明:本文含人工智能辅助创作内容,示例数据均为虚构,仅用于技术学习与界面教学。

浙公网安备 33010602011771号