SpringBoot3+Vue3 固定资产折旧计提:平均年限法、月度批次与净值回写
🌐文档地址:https://ruoyioffice.com
📦源码1·GitHub:https://github.com/yuqing2026/ruoyi-office
📦源码2·GitCode:https://gitcode.com/zhouzhongyan/ruoyi-office
📦源码3·Gitee:https://gitee.com/yqzy1688/ruoyi-office
💬微信:17156169080(备注「RuoYi Office」)
一句话:折旧计提要验收的不是「能不能除出月折旧」,而是试算、审批、过账三跳分开。RuoYi Office 生成批次只写计提行,卡片净值不动;Flowable 通过后才回写累计折旧和净值。同一期间算错了,走冲销单把累计减回去,而不是改历史行。
▲ 左列 StraightLineCalculator:残值、应计总额、月折旧、末月夹紧;右列 generate 试算 → 审批期间卡片不动 → onProcessApproved 过账。底部是进池条件与冲销独立 bizType
引言:财务最怕的是点一次按钮就把净值改了
资产全生命周期里,入库、领用、盘点都有单据。折旧却常被做成「月底跑个 SQL 直接 update 卡片」。现场炸掉的不是公式,是这三件事缠在一起:
| 现场原话 | 直接改卡片的后果 |
|---|---|
| 「这个月不该提,提了怎么退」 | 没有冲销单,只能手工改累计,对不上凭证 |
| 「审批还在走,净值已经少了一截」 | 试算和过账同一跳,驳回了数回不去 |
| 「同一台电脑这个月提了两次」 | 没有「公司 + 期间」唯一在途单,对账只能翻日志 |
资产模块总览、领用调拨报废,已有专文。本文只写折旧计算器 + 月度批次 + 审批回写:平均年限法怎么夹紧残值、generate 跳过哪些卡片、过账写哪些字段、冲销如何把累计减回去。
技术栈锚点(2026):Spring Boot 3.5 + Java 17,PC 为 Vue3 折旧计提单详情,审批走 Flowable,金额一律BigDecimal两位HALF_UP。
一、计提单是账,卡片是余额
结论:资产卡片上的累计折旧和净值,是过账结果,不是试算结果。
一次月度折旧拆成三张东西:
| 对象 | 何时写入 | 能否当余额用 |
|---|---|---|
| 计提批次头 | 点「生成」 | 否,只汇总期间、金额、过账状态 |
| 计提明细行 | 点「生成」 | 否,是建议分录 |
| 资产卡片财务字段 | 审批通过postAccrual | 是,下游盘点、处置都读它 |
同一公司同一期间,只允许一张可编辑的在途计提单。已存在且不可编辑(已在审、已过账)再生成,直接拒绝。草稿则删行重算,不算另开一张单。
冲销是第二种bizType,不是在原单上把金额改成负数。原单过账后才能冲;冲销单自己再走一遍审批,通过后把卡片累计减回去,原单过账状态改为已冲销。两边单据号互相写,对账不用猜。
二、平均年限法:月折旧,末月夹紧
结论:月折旧 = (原值 − 原值×残值率) ÷ 使用年限(月);本期再和剩余应计额取小。
DepreciationCalculator只暴露一个supports(method)和一个calculate(...)。当前实现是平均年限(直线法),方法值1。换工作量法或双倍余额递减,加一个 Calculator Bean 即可,批次服务只resolveCalculator。
publicBigDecimalcalculate(BigDecimaloriginalValue,BigDecimalresidualRate,IntegerusefulLife,BigDecimalaccumulatedBefore){if(originalValue==null||usefulLife==null||usefulLife<=0){returnBigDecimal.ZERO;}BigDecimalrate=residualRate!=null?residualRate:BigDecimal.ZERO;BigDecimalresidualValue=originalValue.multiply(rate).divide(BigDecimal.valueOf(100),2,RoundingMode.HALF_UP);BigDecimaldepreciable=originalValue.subtract(residualValue).max(BigDecimal.ZERO);BigDecimalmonthly=depreciable.divide(BigDecimal.valueOf(usefulLife),2,RoundingMode.HALF_UP);BigDecimalaccumulated=accumulatedBefore!=null?accumulatedBefore:BigDecimal.ZERO;BigDecimalremaining=depreciable.subtract(accumulated).max(BigDecimal.ZERO);returnmonthly.min(remaining).max(BigDecimal.ZERO);}几个容易踩的口径:
- 残值率是百分数。卡片上写
5,表示 5%,不是 0.05。计算器里要先/100。 - 使用年限是月。36 表示三年,不要再乘 12。
- 末月夹紧。
monthly.min(remaining)保证累计不会超过「原值 − 残值」,净值不会打穿残值。 - 算出来 ≤ 0 不进明细。已提满、年限非法、原值为空,计入
skipCount,不生成 0 元行干扰对账。
用卡片上的真实数验一笔。演示资产MOCK-CS-006华为 MateBook D16:原值 6499,残值率 5%,预计残值 325,年限 36 月,累计折旧 1300,净值 5199。
应计总额 = 6499 − 325 = 6174;月折旧 = 6174 ÷ 36 ≈ 171.50;剩余应计 = 6174 − 1300 = 4874。本期取min(171.50, 4874),还没到末月,不会夹紧。净值 6499 − 1300 = 5199,和卡片财务信息一致。
▲ MOCK-CS-006 华为 MateBook D16,财务信息页签。原值 6499、累计折旧 1300、净值 5199、残值率 5%、预计残值 325、平均年限法、使用年限 36 月、折旧起算 2024-12-01。净值 = 原值 − 累计,计算器吃的就是这些字段
折旧起算日决定这张卡何时进池:未报废、起算日 ≤ 期间末日、年限 > 0。维修中仍计提——演示这台电脑状态是维修中,财务字段照样在。报废才排除。
三、PC 端:看明细行,不看空列表
结论:计提单详情必须同时看见期间合计和每张卡的期初 / 本期 / 期末。
3.1 计提单:13 行、合计 2479.38、过账后被冲销
演示单AM708-2026080200003是 2026-07 期间的折旧计提,13 条明细,合计 2479.38,审批通过,过账状态已冲销——它已经被后面那张冲销单处理过。
▲ 单据 AM708-2026080200003。表头期间 2026-07、数量 13、合计 2479.38、单据类型折旧计提、过账状态已冲销。明细能看到原值、计提前净值、本期折旧、期末净值;第一行 ZC20260728-0001 原值 800.00,本期 12.67,期末净值 787.33
明细列是对账口:期初累计、计提前净值、本期折旧、期末累计、期末净值。生成时一行都不能少,审批过程只读。财务要问「这 12.67 怎么来的」,回到卡片原值 / 残值率 / 年限,用上一节公式能手算出来。
3.2 冲销单:原单号、同样金额、过账状态已过账
AM708-2026080200004的单据类型是折旧冲销,原计提单号指向AM708-2026080200003,期间仍是 2026-07,合计仍是 2479.38,过账状态已过账。备注写明「冲销原单」。
▲ 单据 AM708-2026080200004。单据类型折旧冲销、原计提单号 AM708-2026080200003、期间 2026-07、合计 2479.38、已过账。下方「来自原计提单」明细与原单同一批资产、同一套金额,冲销过账时按本期折旧把卡片累计减回去
两张单不要合成一张「红字行」。计提过账改余额,冲销过账再改回来,凭证和卡片都留痕迹。原单不允许再生成计提,也不允许对冲销单再冲销。
四、生成:试算进行,跳过要计数
结论:generate的职责是算出建议分录,不是改卡片。
进池条件在 Mapper:asset_status ≠ 已报废,折旧起算日非空且 ≤ 期间末日,使用年限 > 0,可按公司过滤。
试算循环里还要再跳三类:本期这张卡已有计提行、找不到对应 Calculator、calculate得到空或 ≤ 0。跳过的张数进skipCount,进明细的张数进totalCount,金额加总进totalAmount。一批全跳过则拒绝生成空单。
TrialResulttrial=trialCalculate(reqVO.getCompanyId(),reqVO.getDepreciationPeriod(),batchId);if(CollUtil.isEmpty(trial.lines)){throwexception(ASSET_DEPRECIATION_BATCH_EMPTY);}recordMapper.insertBatch(trial.lines);AssetDepreciationBatchDOtotals=newAssetDepreciationBatchDO();totals.setId(batchId);totals.setTotalCount(trial.lines.size());totals.setTotalAmount(trial.totalAmount);totals.setSkipCount(trial.skipCount);batchMapper.updateById(totals);明细行把「计提前」和「计提后」都存下来:accumulatedBefore/accumulatedAfter/cardNetValueBefore/netValue/depreciationAmount。过账时不再重算一遍,避免审批期间有人改了卡片年限,过账数和审批看到的对不上。
五、过账:审批通过才碰卡片
结论:onProcessApproved是唯一允许改卡片累计折旧和净值的入口。
publicvoidonProcessApproved(StringbusinessKey){AssetDepreciationBatchDOhead=validateExists(Long.parseLong(businessKey));if(Objects.equals(head.getPostStatus(),POST_POSTED)){return;// 已过账,审批回调重入直接返回}if(Objects.equals(head.getBizType(),BIZ_REVERSE)){postReverse(head);}else{postAccrual(head);}}计提过账逐行:
- 卡片
accumulatedDepreciation = line.accumulatedAfter - 卡片
netValue = line.netValue - 写一条资产日志,内容带期间、本期、累计前→后、净值
- 批次
postStatus = 已过账
冲销过账反向:用原计提行的depreciationAmount去减卡片累计,净值按原值 − 新累计回写;原单标成已冲销并互写单号;冲销单自己标已过账。财务凭证如果接了 SPI,冲销时带上原凭证号,失败只打 warn,不回滚卡片——卡片对账优先于外部凭证。
过账状态三态:0未过账、1已过账、2已冲销。审批状态和过账状态分开,驳回不会误把已过账改回去;已过账的回调重入直接 return,避免重复减净值。
六、设计决策与边界
| 决策点 | 方案 | 理由 |
|---|---|---|
| 试算与过账分离 | generate 只写行 | 审批中净值不能先变 |
| 期间唯一在途 | 公司 + 期间一把锁 | 防止同月提两次 |
| 草稿可重算 | 删行再 trial | 改残值率后不用作废单号 |
| 计算器可插拔 | supports(method) | 先落地平均年限,不堵后续方法 |
| 年限用月 | usefulLife直接做除数 | 避免「年×12」各写各的 |
| 残值率百分数 | 除以 100 | 和卡片展示5%一致 |
| 末月夹紧 | min(月折旧, 剩余应计) | 不打穿残值 |
| 冲销独立单据 | 新bizType+ 再审批 | 红字可审计,不是改历史 |
| 过账幂等 | 已过账则 return | 流程回调重入安全 |
| 跳过计数 | skipCount与明细分开 | 空行不进账,漏提能查出原因 |
边界:
- 维修中、闲置仍计提,只有报废排除。若现场「停用停提」,要改进池条件,不要在计算器里偷偷返回 0。
- 本期已有行就 skip,依赖「一卡一期一行」。手工插行或跨公司重复卡号会让 skip 失真。
- 财务推送是过账之后的可选动作,失败不影响卡片。对账先看卡片和计提行,再看凭证号。
七、数据结构要点
批次头:bill_code、biz_type(计提/冲销)、company_id、depreciation_period(yyyy-MM)、total_count/total_amount/skip_count、process_status、post_status、source_batch_id/reverse_batch_id(双向追溯)、财务推送三件套。
明细行:batch_id、asset_id、asset_code、depreciation_period、depreciation_method、原值、期初累计、计提前净值、本期、期末累计、期末净值、line_status(有效/已冲销)。
卡片财务:原值、累计折旧、净值、残值率、预计残值、折旧方法、使用年限(月)、开始使用日、折旧起算日。计算器读原值 / 残值率 / 年限 / 已累计;过账写累计和净值。
金额字段全部两位小数。期间用YearMonth解析,期间末日拿来跟起算日比较,不要拿期间首日,否则月初购入当月可能进不了池。
八、技术亮点总结
| 设计要点 | 实现方式 | 价值 |
|---|---|---|
| 账条与余额分离 | 行先写、卡片后写 | 审批驳回不影响净值 |
| 平均年限按月 | 应计总额 ÷ 月数 | 和财务口径一致 |
| 末月夹紧 | min(月折旧, 剩余) | 不打穿残值 |
| 进池过滤 | 未报废 + 起算日 + 年限 | 报废资产不再提 |
| 跳过计数 | skipCount | 漏提可解释 |
| 期间唯一 | 公司+期间在途锁 | 同月不双提 |
| 过账幂等 | POST_POSTED 短路 | 回调重入安全 |
| 冲销独立单 | bizType=2 再审批 | 红字可审计 |
| 单号互写 | source / reverse | 两张单能对上 |
| 日志带数 | 本期/累计前→后/净值 | 卡片时间线能翻 |
九、快速体验
在线演示:https://ruoyioffice.com/web/(账号 admin / admin123)
- 打开资产 → 资产台账,点进一台在用或维修中的设备,切到「财务信息」,记下原值、残值率、年限、累计、净值。
- 手算月折旧:
(原值 − 原值×残值率/100) ÷ 年限,和下一张计提明细对一下本期金额。 - 打开折旧计提单
AM708-2026080200003,看 2026-07、13 行、合计 2479.38、过账状态已冲销。 - 再打开
AM708-2026080200004,确认单据类型是冲销、原单号指向 0003、已过账。 - 回到卡片,核对累计和净值是否已被冲销回写(演示环境以当前数为准)。
- 本地可再生成一个未过账草稿:改残值率后重新生成,确认卡片净值在审批通过前不变。
源码仓库:GitHub:https://github.com/yuqing2026/ruoyi-office | GitCode:https://gitcode.com/zhouzhongyan/ruoyi-office | Gitee:https://gitee.com/yqzy1688/ruoyi-office
常见问题(FAQ)
生成折旧批次会立刻改卡片净值吗?
不会。generate只插入计提行并回写合计。卡片的累计折旧和净值只在审批通过的postAccrual里更新。
残值率填 5 是 5% 还是 0.05?
是 5%。计算器先除以 100 再乘原值。卡片财务信息展示为5%,预计残值应等于原值 × 5%。
为什么最后几个月的本期折旧比前面小?
末月夹紧。本期 = min(标准月折旧, 应计总额 − 已累计)。提满到残值之后再生成,calculate得到 0,这张卡会进 skipCount,不再出现 0 元明细。
已经过账的月份想作废,改原单行可以吗?
不可以。对已过账计提单发起冲销,生成独立冲销单再审批。通过后按原行本期金额减累计。冲销单不能再冲,原单过账状态变为已冲销。
维修中的资产还提不提?
提。进池只排除已报废,并要求折旧起算日已到、年限大于 0。停用停提要改过滤条件,不要指望计算器对维修状态返回 0。
结语
固定资产折旧的工程难点不在四则运算,而在让试算可驳回、让过账可对账、让冲销可审计。平均年限法把残值率和月数说清楚,末月夹紧保证净值不打穿残值;批次按公司+期间生成建议分录;Flowable 通过后才写卡片;算错了走另一张冲销单,而不是在历史行上改数字。
入库、领用、盘点解决资产在不在、谁在用。折旧解决这台资产这个月该少多少净值。两件事单据分开,财务才肯认系统里的数。
你们现在的折旧,是月底一条 SQL 直接 update,还是每张卡都能从计提行手算回卡片?欢迎在评论里对一下残值率和年限口径。
💡想要体验 RuoYi Office 的强大功能?
🌐在线演示:https://ruoyioffice.com/web/(账号 admin / admin123)
📦源码仓库:GitHub:https://github.com/yuqing2026/ruoyi-office | GitCode:https://gitcode.com/zhouzhongyan/ruoyi-office | Gitee:https://gitee.com/yqzy1688/ruoyi-office
💬技术咨询:添加微信17156169080,备注「RuoYi Office」
⭐如果觉得不错,请给个 Star 支持一下!