固定资产还有借用记录能报废吗?Java前置检查、停止折旧与SQL验收

理员为电脑8001申请报废,页面提示成功,库房却找不到实物:电脑还在员工手上,借用单B-8001没有归还。随后定时任务又给这台"已报废"电脑生成了10月折旧。问题出在两个时点:报废申请没有锁住在途业务,审批完成又被误当成实物已处置。

本例先让未归还借用明确阻塞申请,再完成归还、申请、审批和实物处置。目标结果是资产RETIRED、10月不再生成折旧、处置净差额为-1000.00元,重复确认不新增处置事实。

环境限定为Java 17、JUnit Jupiter 5.8.2、Spring Boot 3.x风格服务层和MySQL 8.0.16以上InnoDB。Java类可独立运行,SQL在独立演示库执行。财务口径在案例中固定为"处置当月已经月结,次月停止",只演示软件如何实施已确认口径,不把它当作所有资产的统一会计规则。

目录

  1. 一、把未归还借用和处置金额列成输入
  2. 二、为什么审批通过不能立即改为已报废
  3. 三、核心表结构如何防止重复申请和重复处置
  4. 四、可运行Java实现前置阻塞和处置确认
  5. 五、运行输出与JUnit如何证明状态一致
  6. 六、数据库事务怎样阻止检查后又被借出
  7. 七、用SQL复现阻塞、成功结果和异常扫描
  8. 八、上线时必须故意制造的失败

一、把未归还借用和处置金额列成输入

输入 本例数值 来源
公司、资产 公司1,电脑8001 资产卡
阻塞单据 B-8001,BORROWED 借用明细
账面原值 10000.00元 财务确认值
截至2026-09累计折旧 7600.00元 已结历史与本月流水合计
实物处置日期 2026-09-30 交接记录
处置收入、费用 1500.00、100.00元 已确认处置记录
交接附件 HANDOVER-8001-v1 上传完成的受控附件版本

第一遍执行申请时,后端返回OPEN_BORROW:B-8001,不创建报废单,不改变资产状态,也不设置停止折旧期间。业务人员先办理实物归还,核对序列号,再重新申请。不能为了通过检查把B-8001从数据库直接删掉,否则报废链路缺少实物归还证据。

资产净值为10000.00-7600.00=2400.00元。本例的技术核对额为1500.00-100.00-2400.00=-1000.00元。这里未引入税额、减值、清理科目等因素;有这些要素时,应扩充输入和财务核对式,不能把这个净差额直接当作完整记账凭证。

图1:8001:先归还,再申请报废。未归还借用单B-8001必须阻塞,不能直接改资产状态。

二、为什么审批通过不能立即改为已报废

本例将申请、审批和处置分开:NONE表示尚无申请,APPLIED表示申请已受理,APPROVED表示可以安排处置,DISPOSED才表示实物交接已经确认。相应资产状态依次为IN_USE、RETIREMENT_PENDING、DISPOSING、RETIRED。

从申请成功开始,新的借用、维修和调拨不得启动。处置前仍要重新读取阻塞业务,因为历史入口或旧版本任务可能没有遵守冻结约定。重新检查发现异常时保持APPROVED并报告具体单据,不把资产提前改为RETIRED。

阶段 是否允许停止折旧 是否允许重复确认
申请被借用阻塞 否,保留原状态 修复借用后重新申请
已申请或已审批 否,尚无实物处置事实 按动作请求号返回原结果
实物处置已确认 按已批准期间口径设置stop_from 同请求同内容返回原结果
已处置但内容改变 不原地覆盖 返回CONFIRM_CONFLICT,转更正流程

图2:审批、实物和折旧分开判断。申请后冻结新流转;确认前再检查未结束业务。

三、核心表结构如何防止重复申请和重复处置

open_asset仅覆盖APPLIED和APPROVED两种开放状态。这样同一资产任意开放阶段都只能有一张单,已经驳回的历史申请不占用开放名额。资产是否仍可申请还要由状态机判断,唯一键本身不会阻止已报废资产再次申请。

sql 复制代码
CREATE TABLE r08_asset (
 id BIGINT PRIMARY KEY, company_id BIGINT NOT NULL, status VARCHAR(24) NOT NULL,
 cost DECIMAL(18,2) NOT NULL, accumulated DECIMAL(18,2) NOT NULL,
 closed_through DATE NOT NULL, stop_from DATE NULL,
 CHECK(cost>=0 AND accumulated>=0 AND accumulated<=cost),
 CHECK(status IN ('IN_USE','IDLE','RETIREMENT_PENDING','DISPOSING','RETIRED')),
 CHECK((status='RETIRED' AND stop_from IS NOT NULL) OR (status<>'RETIRED' AND stop_from IS NULL))
) ENGINE=InnoDB;
CREATE TABLE r08_borrow (
 id VARCHAR(32) PRIMARY KEY, asset_id BIGINT NOT NULL,
 status VARCHAR(16) NOT NULL, returned_at DATETIME NULL,
 FOREIGN KEY(asset_id) REFERENCES r08_asset(id),
 INDEX ix_asset_status(asset_id,status),
 CHECK(status IN ('BORROWED','RETURNED')),
 CHECK((status='BORROWED' AND returned_at IS NULL) OR (status='RETURNED' AND returned_at IS NOT NULL))
) ENGINE=InnoDB;
CREATE TABLE r08_retirement (
 id BIGINT PRIMARY KEY, asset_id BIGINT NOT NULL,
 apply_request VARCHAR(64) NOT NULL UNIQUE, status VARCHAR(20) NOT NULL,
 approved_by BIGINT NULL, approved_at DATETIME NULL,
 confirm_request VARCHAR(64) NULL UNIQUE, disposed_on DATE NULL,
 evidence VARCHAR(64) NULL, proceeds DECIMAL(18,2) NULL, fee DECIMAL(18,2) NULL,
 open_asset BIGINT GENERATED ALWAYS AS
   (CASE WHEN status IN ('APPLIED','APPROVED') THEN asset_id ELSE NULL END) STORED,
 UNIQUE KEY uk_open_asset(open_asset),
 FOREIGN KEY(asset_id) REFERENCES r08_asset(id),
 CHECK(status IN ('APPLIED','APPROVED','DISPOSED','REJECTED')),
 CHECK(status NOT IN ('APPROVED','DISPOSED') OR (approved_by IS NOT NULL AND approved_at IS NOT NULL)),
 CHECK(status<>'DISPOSED' OR (confirm_request IS NOT NULL AND disposed_on IS NOT NULL
   AND evidence IS NOT NULL AND CHAR_LENGTH(evidence)>0
   AND proceeds IS NOT NULL AND proceeds>=0 AND fee IS NOT NULL AND fee>=0))
) ENGINE=InnoDB;
CREATE TABLE r08_depreciation (
 asset_id BIGINT NOT NULL, period DATE NOT NULL, amount DECIMAL(18,2) NOT NULL,
 PRIMARY KEY(asset_id,period), FOREIGN KEY(asset_id) REFERENCES r08_asset(id),
 CHECK(amount>=0), CHECK(DAYOFMONTH(period)=1)
) ENGINE=InnoDB;

期间使用每月1日的DATE,不依赖字符串排序。本例资产累计折旧来自已结快照7600.00;后面的演示流水只放9月一条200.00,期初历史余额为7400.00,不能把这一条流水误当成全部历史合计。

四、可运行Java实现前置阻塞和处置确认

把下面代码保存为DisposalDemo.javablockers是服务端锁定资产后汇总的单据清单,不能由客户端提交"我没有借用"的布尔值。主函数先演示阻塞,再模拟归还后的成功路径。

java 复制代码
import java.math.*;
import java.time.*;
import java.util.*;

public final class DisposalDemo {
    enum Stage { NONE, APPLIED, APPROVED, DISPOSED }
    record Snapshot(long assetId, Stage stage, String assetStatus,
                    BigDecimal cost, BigDecimal accumulated,
                    YearMonth closedThrough, YearMonth stopFrom, Disposal disposal) {}
    record Disposal(String request, LocalDate date, String evidence,
                    BigDecimal proceeds, BigDecimal fee) {
        Disposal {
            if (request == null || request.isBlank() || date == null
                    || evidence == null || evidence.isBlank())
                throw new IllegalArgumentException("EVIDENCE_REQUIRED");
            proceeds = money(proceeds); fee = money(fee);
        }
    }
    static BigDecimal money(BigDecimal x) {
        if (x == null || x.signum() < 0) throw new IllegalArgumentException("INVALID_AMOUNT");
        return x.setScale(2, RoundingMode.UNNECESSARY);
    }
    static void unblocked(List<String> blockers) {
        if (!blockers.isEmpty()) throw new IllegalStateException(String.join(",", blockers));
    }
    static Snapshot apply(Snapshot a, List<String> blockers) {
        unblocked(blockers);
        if (a.stage() != Stage.NONE || !Set.of("IN_USE", "IDLE").contains(a.assetStatus()))
            throw new IllegalStateException("NOT_APPLICABLE");
        return copy(a, Stage.APPLIED, "RETIREMENT_PENDING", null, null);
    }
    static Snapshot approve(Snapshot a) {
        if (a.stage() != Stage.APPLIED) throw new IllegalStateException("NOT_APPLIED");
        return copy(a, Stage.APPROVED, "DISPOSING", null, null);
    }
    static Snapshot confirm(Snapshot a, Disposal d, List<String> blockers) {
        if (a.stage() == Stage.DISPOSED) {
            if (a.disposal().equals(d)) return a;
            throw new IllegalStateException("CONFIRM_CONFLICT");
        }
        if (a.stage() != Stage.APPROVED) throw new IllegalStateException("NOT_APPROVED");
        unblocked(blockers);
        // 示例制度:处置当月已月结,从下月停止。
        if (!YearMonth.from(d.date()).equals(a.closedThrough()))
            throw new IllegalStateException("CLOSE_PERIOD_MISMATCH");
        if (a.accumulated().compareTo(a.cost()) > 0)
            throw new IllegalStateException("NEGATIVE_BOOK_VALUE");
        return copy(a, Stage.DISPOSED, "RETIRED", YearMonth.from(d.date()).plusMonths(1), d);
    }
    static Snapshot copy(Snapshot a, Stage stage, String status, YearMonth stop, Disposal d) {
        return new Snapshot(a.assetId(), stage, status, a.cost(), a.accumulated(),
                a.closedThrough(), stop, d);
    }
    static boolean depreciates(Snapshot a, YearMonth period) {
        return a.stopFrom() == null || period.isBefore(a.stopFrom());
    }
    static BigDecimal netResult(Snapshot a) {
        if (a.disposal() == null) throw new IllegalStateException("NOT_DISPOSED");
        return a.disposal().proceeds().subtract(a.disposal().fee())
                .subtract(a.cost().subtract(a.accumulated()));
    }
    static Snapshot initial() {
        return new Snapshot(8001, Stage.NONE, "IN_USE", new BigDecimal("10000.00"),
                new BigDecimal("7600.00"), YearMonth.of(2026, 9), null, null);
    }
    static Disposal command() {
        return new Disposal("D-8001", LocalDate.of(2026, 9, 30), "HANDOVER-8001-v1",
                new BigDecimal("1500.00"), new BigDecimal("100.00"));
    }
    public static void main(String[] args) {
        try { apply(initial(), List.of("OPEN_BORROW:B-8001")); }
        catch (IllegalStateException e) { System.out.println(e.getMessage()); }
        var a = confirm(approve(apply(initial(), List.of())), command(), List.of());
        System.out.println(a.stage() + " / " + a.assetStatus() + " / stop=" + a.stopFrom());
        System.out.println("net=" + netResult(a) + ", October=" + depreciates(a, YearMonth.of(2026, 10)));
    }
}

confirm先处理已经处置的幂等请求,再判断当前前置条件。已成功处置的网络重试应返回原结果,不能因为之后月份变化而把原成功误判成期间错误。幂等比较覆盖请求号、日期、附件、收入和费用,同一个请求号改变金额不会被静默接受。

depreciates只判断期间边界,不代表月结的完整资格判断。实际月结还需检查启用日期、累计限额和已结流水。9月允许计提不意味着重新计提:数据库的资产加期间唯一键必须继续防重。

图3:8001的Java金额与期间输出。演示口径:9月已月结,9月30日处置,10月停止。

五、运行输出与JUnit如何证明状态一致

shell 复制代码
javac -encoding UTF-8 --release 17 DisposalDemo.java
java DisposalDemo

预期输出:

text 复制代码
OPEN_BORROW:B-8001
DISPOSED / RETIRED / stop=2026-10
net=-1000.00, October=false

测试使用JUnit Jupiter 5.8.2。除了检查报错,还检查失败后的原状态和停止期间:仅证明"抛了异常"不能证明没有部分修改。

java 复制代码
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.YearMonth;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;

class DisposalDemoTest {
    @Test void borrowBlocksWithoutChangingState() {
        var a = DisposalDemo.initial();
        var e = assertThrows(IllegalStateException.class,
                () -> DisposalDemo.apply(a, List.of("OPEN_BORROW:B-8001")));
        assertEquals("OPEN_BORROW:B-8001", e.getMessage());
        assertEquals(DisposalDemo.Stage.NONE, a.stage());
        assertNull(a.stopFrom());
    }
    @Test void futurePeriodsStopAndConfirmIsIdempotent() {
        var approved = DisposalDemo.approve(DisposalDemo.apply(DisposalDemo.initial(), List.of()));
        var a = DisposalDemo.confirm(approved, DisposalDemo.command(), List.of());
        assertEquals("RETIRED", a.assetStatus());
        assertEquals(new BigDecimal("-1000.00"), DisposalDemo.netResult(a));
        assertEquals(new BigDecimal("7600.00"), a.accumulated());
        assertTrue(DisposalDemo.depreciates(a, YearMonth.of(2026, 9)));
        assertFalse(DisposalDemo.depreciates(a, YearMonth.of(2026, 10)));
        assertSame(a, DisposalDemo.confirm(a, DisposalDemo.command(), List.of()));
    }
    @Test void changedRepeatAndNewBlockerFail() {
        var approved = DisposalDemo.approve(DisposalDemo.apply(DisposalDemo.initial(), List.of()));
        assertThrows(IllegalStateException.class,
                () -> DisposalDemo.confirm(approved, DisposalDemo.command(), List.of("OPEN_REPAIR:R-9")));
        var a = DisposalDemo.confirm(approved, DisposalDemo.command(), List.of());
        var d = DisposalDemo.command();
        var changed = new DisposalDemo.Disposal(d.request(), d.date(), d.evidence(),
                new BigDecimal("1600.00"), d.fee());
        assertThrows(IllegalStateException.class, () -> DisposalDemo.confirm(a, changed, List.of()));
        assertEquals(DisposalDemo.Stage.APPROVED, approved.stage());
    }
    @Test void missingApprovalOrWrongPeriodCannotRetire() {
        assertThrows(IllegalStateException.class,
                () -> DisposalDemo.confirm(DisposalDemo.initial(), DisposalDemo.command(), List.of()));
        var a = DisposalDemo.approve(DisposalDemo.apply(DisposalDemo.initial(), List.of()));
        var d = DisposalDemo.command();
        var wrong = new DisposalDemo.Disposal(d.request(), d.date().plusMonths(1),
                d.evidence(), d.proceeds(), d.fee());
        assertThrows(IllegalStateException.class, () -> DisposalDemo.confirm(a, wrong, List.of()));
        assertNull(a.stopFrom());
    }
}

上述是业务规则测试。审批权限、真实附件存在性、借用查询完整性和Spring回滚要在接入数据库后另外验证;核心函数不会代替这些外部事实检查。

六、数据库事务怎样阻止检查后又被借出

申请事务先锁公司期间控制行(需要与月结互斥时),再锁资产8001,读取BORROWED借用、开放维修和调拨。发现B-8001就结束事务,不写申请。没有阻塞才创建APPLIED并将资产改为RETIREMENT_PENDING。新的借用入口必须锁同一资产行、要求资产可借,不能只有报废入口检查借用表。

审批事务记录审批人900和时间,把申请改为APPROVED、资产改为DISPOSING;审批人从登录身份取得,不使用请求传入的任意用户ID。实物交接单先上传完成并验证资产归属,确认事务再读取其固定版本。附件不存在时不得先改状态后补资料。

处置确认和月结使用一致锁顺序:公司期间控制行→资产→报废单。确认时核对9月已结、10月未生成流水,随后在同一事务保存处置事实、RETIRED和stop_from=2026-10-01。若10月已经计提,应阻塞本次普通确认并进入期间更正流程,而不是删除10月流水。月结取得锁后读取stop_from,只为早于该期间的月份生成折旧。

Spring服务使用@Transactional(rollbackFor = Exception.class),条件更新影响行数必须为1;任何一步失败都抛异常回滚。锁超时可以对整个命令做有限重试,重试仍用原请求号。实物已离场属于外部事实,数据库回滚后需重试登记或人工核实,程序不能宣称已自动撤销交接。

七、用SQL复现阻塞、成功结果和异常扫描

先在空白演示库执行DDL,再插入初始状态。下面SELECT必须先得到B-8001,这一步验证阻塞原因确实来自数据。

sql 复制代码
INSERT INTO r08_asset VALUES (8001,1,'IN_USE',10000,7600,'2026-09-01',NULL);
INSERT INTO r08_borrow VALUES ('B-8001',8001,'BORROWED',NULL);
INSERT INTO r08_depreciation VALUES (8001,'2026-09-01',200.00);
SELECT id FROM r08_borrow WHERE asset_id=8001 AND status='BORROWED';
-- 预期1行:B-8001

-- 以下是归还、审批和确认完成后的演示快照,不替代真实审批接口。
START TRANSACTION;
UPDATE r08_borrow SET status='RETURNED',returned_at='2026-09-30 09:00:00'
WHERE id='B-8001';
INSERT INTO r08_retirement
(id,asset_id,apply_request,status,approved_by,approved_at,confirm_request,disposed_on,evidence,proceeds,fee)
VALUES (81,8001,'A-8001','DISPOSED',900,'2026-09-30 10:00:00',
 'D-8001','2026-09-30','HANDOVER-8001-v1',1500,100);
UPDATE r08_asset SET status='RETIRED',stop_from='2026-10-01' WHERE id=8001;
COMMIT;

SELECT a.id,a.status,r.status AS disposal_status,a.stop_from,
 r.proceeds-r.fee-(a.cost-a.accumulated) AS net_result
FROM r08_asset a JOIN r08_retirement r ON r.asset_id=a.id
WHERE a.company_id=1 AND a.id=8001 AND r.status='DISPOSED';
-- 8001 | RETIRED | DISPOSED | 2026-10-01 | -1000.00

SELECT d.* FROM r08_depreciation d JOIN r08_asset a ON a.id=d.asset_id
WHERE a.company_id=1 AND a.id=8001 AND d.period>=a.stop_from;
-- 预期0行:没有停止期间及之后的流水。

SELECT a.id FROM r08_asset a
WHERE a.company_id=1 AND a.id=8001 AND
 ((a.status='RETIRED' AND NOT EXISTS
   (SELECT 1 FROM r08_retirement r WHERE r.asset_id=a.id AND r.status='DISPOSED'))
 OR (a.status<>'RETIRED' AND EXISTS
   (SELECT 1 FROM r08_retirement r WHERE r.asset_id=a.id AND r.status='DISPOSED'))
 OR (a.status='RETIRED' AND EXISTS
   (SELECT 1 FROM r08_borrow b WHERE b.asset_id=a.id AND b.status='BORROWED')));
-- 预期0行:资产、处置和借用状态没有互相矛盾。

图4:重复确认和月结竞争如何验证。同请求同内容返回原结果;改变金额必须拒绝。

八、上线时必须故意制造的失败

操作 预期结果 核对位置
B-8001未归还即申请 明确返回单据号,申请数0 借用与报废表
申请后并发借用 新借用被资产状态拦截 两事务执行记录
未审批直接确认 NOT_APPROVED,stop_from为空 JUnit与接口
处置确认写到一半断开数据库 三项本地结果全部回滚 资产、报废单、停止期间
同请求重复确认 1条处置记录,-1000.00不变 请求唯一键与查询
同请求把收入改为1600 CONFIRM_CONFLICT 原处置金额仍1500
确认与10月月结竞争 不产生10月错误折旧,或确认被期间检查阻塞 期间锁与流水

审批驳回可以恢复申请前资产状态并保留REJECTED申请;实物处置完成后不能走普通撤回恢复在用。批量报废时逐项输出阻塞单据,不把一批资产直接执行UPDATE ... RETIRED。本例的成功标志必须同时包含归还证据、审批记录、交接附件、停止期间与金额核对。

图5:8001上线验收的四个关键失败。失败必须检查数据库原状态,不能只检查错误提示。

参考资料

相关推荐
飞虹地星海1 小时前
从零到一跑通苍穹外卖:一个大二学生的暑假项目复盘
java
她的男孩1 小时前
账号锁定配了"错 4 次锁 30 分钟",我连错 100 次一次没锁上:扒完 4091 行认证源码,找到 5 个坑
java·后端·架构
程序猿乐锅1 小时前
【黑马点评 | 第二篇】Redis 缓存更新策略与商铺缓存实现
java·网络·redis·spring·mybatis
livemetee1 小时前
MySQL联合唯一索引(code,deleted)与逻辑删除问题
java·数据库
MacroZheng1 小时前
Redis官方发布高颜值可视化工具,功能更是强的离谱!
java·redis·后端
bksczm1 小时前
MySQL基础篇之索引
数据库·sql·mysql
wuminyu1 小时前
纯轻量级锁体系下C2编译器锁粗化和消除机制剖析
java·linux·c语言·jvm·c++
ss2731 小时前
AI全栈实战 | 1.4-02 Spring AOP:@Transactional 失效的 N 种场景,根因其实只有一个
java·spring·ai编程
beijixinghe2 小时前
第15节 指针作为函数参数的工程实战用法
开发语言·c++·c++基础·c++入门·几何引擎c++