财务核算系统设计与实现

财务核算系统设计与实现

前言

财务核算系统是企业信息化建设的核心系统之一,承担着记录、分类、汇总企业经济业务的重要职责。一个完善的财务核算系统需要满足会计准则要求,支持多维度核算,保证数据准确性和一致性。本文将从系统架构、核心模块、代码实现等方面,详细讲解如何设计一个企业级财务核算系统。

一、财务核算基础概念

1.1 会计恒等式

diff 复制代码
+------------------------------------------------------------------+
|                        会计恒等式                                 |
+------------------------------------------------------------------+
|                                                                   |
|              资产 = 负债 + 所有者权益                              |
|                                                                   |
|              Assets = Liabilities + Owner's Equity                |
|                                                                   |
+------------------------------------------------------------------+
|                                                                   |
|    借方(Debit)              |        贷方(Credit)              |
|    资产增加                   |        资产减少                   |
|    负债减少                   |        负债增加                   |
|    费用增加                   |        收入增加                   |
|    所有者权益减少             |        所有者权益增加              |
|                                                                   |
+------------------------------------------------------------------+

1.2 核算体系结构

lua 复制代码
+------------------+     +------------------+     +------------------+
|    科目体系      |     |    辅助核算      |     |    期间管理      |
+------------------+     +------------------+     +------------------+
| 一级科目         |     | 部门核算         |     | 会计年度         |
| 二级科目         |     | 项目核算         |     | 会计期间         |
| 三级科目         |     | 往来核算         |     | 期间状态         |
| 末级科目         |     | 现金流核算       |     | 结账/反结账      |
+------------------+     +------------------+     +------------------+
         |                       |                       |
         +-------------------+---+-------------------+---+
                             |
                    +--------v--------+
                    |    凭证管理      |
                    +-----------------+
                    | 凭证录入         |
                    | 凭证审核         |
                    | 凭证过账         |
                    | 凭证冲销         |
                    +-----------------+
                             |
                    +--------v--------+
                    |    账簿管理      |
                    +-----------------+
                    | 总账             |
                    | 明细账           |
                    | 多栏账           |
                    | 序时账           |
                    +-----------------+
                             |
                    +--------v--------+
                    |    报表管理      |
                    +-----------------+
                    | 资产负债表       |
                    | 利润表           |
                    | 现金流量表       |
                    +-----------------+

1.3 核心业务指标

指标 说明 要求
借贷平衡 每笔凭证借贷必须相等 100%准确
科目余额 期初+本期发生=期末 100%准确
期间完整 期间连续不断 100%完整
审计追踪 所有操作可追溯 完整日志

二、系统架构设计

2.1 整体架构

lua 复制代码
                              +------------------+
                              |     前端应用      |
                              |  Vue/React/H5   |
                              +---------+--------+
                                        |
                              +---------v--------+
                              |   API Gateway    |
                              |   认证/限流/路由  |
                              +---------+--------+
                                        |
         +------------------------------+------------------------------+
         |                              |                              |
+--------v--------+           +---------v--------+           +---------v--------+
|   核算服务       |           |    报表服务      |           |    基础服务      |
| Accounting-Svc  |           |  Report-Service  |           |  Base-Service   |
+--------+--------+           +---------+--------+           +---------+--------+
         |                              |                              |
         +------------------------------+------------------------------+
                                        |
         +------------------------------+------------------------------+
         |                              |                              |
+--------v--------+           +---------v--------+           +---------v--------+
|   Redis缓存     |           |    消息队列      |           |    数据库        |
|  科目/期间缓存   |           |  异步处理/通知   |           |   核算数据       |
+-----------------+           +------------------+           +------------------+

2.2 核算服务核心模块

sql 复制代码
+------------------------------------------------------------------+
|                        核算服务                                   |
+------------------------------------------------------------------+
|  +-------------+  +-------------+  +-------------+  +-----------+ |
|  | 科目管理    |  | 凭证管理    |  | 期间管理    |  | 辅助核算  | |
|  | Account     |  | Voucher     |  | Period      |  | Auxiliary | |
|  +-------------+  +-------------+  +-------------+  +-----------+ |
|                                                                   |
|  +-------------+  +-------------+  +-------------+  +-----------+ |
|  | 账簿查询    |  | 结账处理    |  | 期末结转    |  | 对账服务  | |
|  | Ledger      |  | Closing     |  | Carryover   |  | Reconcile | |
|  +-------------+  +-------------+  +-------------+  +-----------+ |
+------------------------------------------------------------------+

三、数据库设计

3.1 核心表结构

sql 复制代码
-- 会计科目表
CREATE TABLE `acc_subject` (
    `id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '科目ID',
    `subject_code` VARCHAR(32) NOT NULL COMMENT '科目编码',
    `subject_name` VARCHAR(64) NOT NULL COMMENT '科目名称',
    `parent_code` VARCHAR(32) DEFAULT NULL COMMENT '上级科目编码',
    `subject_level` INT(11) NOT NULL DEFAULT 1 COMMENT '科目级次',
    `subject_type` TINYINT(1) NOT NULL COMMENT '科目类型:1-资产,2-负债,3-权益,4-成本,5-损益',
    `balance_direction` TINYINT(1) NOT NULL COMMENT '余额方向:1-借方,2-贷方',
    `is_leaf` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否末级:0-否,1-是',
    `is_cash_subject` TINYINT(1) DEFAULT 0 COMMENT '是否现金科目',
    `is_bank_subject` TINYINT(1) DEFAULT 0 COMMENT '是否银行科目',
    `auxiliary_types` VARCHAR(128) DEFAULT NULL COMMENT '辅助核算类型(JSON数组)',
    `status` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '状态:0-停用,1-启用',
    `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
    `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_subject_code` (`subject_code`),
    KEY `idx_parent_code` (`parent_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会计科目表';

-- 会计期间表
CREATE TABLE `acc_period` (
    `id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '期间ID',
    `period_year` INT(11) NOT NULL COMMENT '会计年度',
    `period_month` INT(11) NOT NULL COMMENT '会计月份',
    `period_code` VARCHAR(10) NOT NULL COMMENT '期间编码(如:202401)',
    `start_date` DATE NOT NULL COMMENT '开始日期',
    `end_date` DATE NOT NULL COMMENT '结束日期',
    `status` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '状态:0-未启用,1-已启用,2-已结账',
    `close_time` DATETIME DEFAULT NULL COMMENT '结账时间',
    `close_user` VARCHAR(32) DEFAULT NULL COMMENT '结账人',
    `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
    `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_period_code` (`period_code`),
    KEY `idx_year_month` (`period_year`, `period_month`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会计期间表';

-- 会计凭证表(凭证头)
CREATE TABLE `acc_voucher` (
    `id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '凭证ID',
    `voucher_no` VARCHAR(32) NOT NULL COMMENT '凭证号',
    `voucher_type` VARCHAR(10) NOT NULL DEFAULT '记' COMMENT '凭证字(记/收/付/转)',
    `voucher_date` DATE NOT NULL COMMENT '凭证日期',
    `period_code` VARCHAR(10) NOT NULL COMMENT '所属期间',
    `attachment_count` INT(11) DEFAULT 0 COMMENT '附件数',
    `total_debit` DECIMAL(18,2) NOT NULL COMMENT '借方合计',
    `total_credit` DECIMAL(18,2) NOT NULL COMMENT '贷方合计',
    `summary` VARCHAR(256) DEFAULT NULL COMMENT '凭证摘要',
    `status` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '状态:1-未审核,2-已审核,3-已过账,4-已作废',
    `source_type` VARCHAR(32) DEFAULT NULL COMMENT '来源类型(手工/导入/自动生成)',
    `source_no` VARCHAR(64) DEFAULT NULL COMMENT '来源单据号',
    `create_user` VARCHAR(32) NOT NULL COMMENT '制单人',
    `audit_user` VARCHAR(32) DEFAULT NULL COMMENT '审核人',
    `audit_time` DATETIME DEFAULT NULL COMMENT '审核时间',
    `post_user` VARCHAR(32) DEFAULT NULL COMMENT '过账人',
    `post_time` DATETIME DEFAULT NULL COMMENT '过账时间',
    `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
    `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_voucher_no` (`voucher_no`),
    KEY `idx_period_code` (`period_code`),
    KEY `idx_voucher_date` (`voucher_date`),
    KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会计凭证表';

-- 凭证分录表(凭证明细行)
CREATE TABLE `acc_voucher_entry` (
    `id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '分录ID',
    `voucher_id` BIGINT(20) NOT NULL COMMENT '凭证ID',
    `voucher_no` VARCHAR(32) NOT NULL COMMENT '凭证号',
    `line_no` INT(11) NOT NULL COMMENT '行号',
    `subject_code` VARCHAR(32) NOT NULL COMMENT '科目编码',
    `subject_name` VARCHAR(64) NOT NULL COMMENT '科目名称',
    `summary` VARCHAR(256) NOT NULL COMMENT '摘要',
    `debit_amount` DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '借方金额',
    `credit_amount` DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '贷方金额',
    `currency_code` VARCHAR(10) DEFAULT 'CNY' COMMENT '币种',
    `exchange_rate` DECIMAL(10,6) DEFAULT 1 COMMENT '汇率',
    `original_amount` DECIMAL(18,2) DEFAULT NULL COMMENT '原币金额',
    `auxiliary_data` TEXT DEFAULT NULL COMMENT '辅助核算数据(JSON)',
    `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (`id`),
    KEY `idx_voucher_id` (`voucher_id`),
    KEY `idx_subject_code` (`subject_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='凭证分录表';

-- 科目余额表
CREATE TABLE `acc_subject_balance` (
    `id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `period_code` VARCHAR(10) NOT NULL COMMENT '期间编码',
    `subject_code` VARCHAR(32) NOT NULL COMMENT '科目编码',
    `begin_debit` DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '期初借方',
    `begin_credit` DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '期初贷方',
    `period_debit` DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '本期借方',
    `period_credit` DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '本期贷方',
    `year_debit` DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '本年累计借方',
    `year_credit` DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '本年累计贷方',
    `end_debit` DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '期末借方',
    `end_credit` DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '期末贷方',
    `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
    `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_period_subject` (`period_code`, `subject_code`),
    KEY `idx_subject_code` (`subject_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='科目余额表';

-- 辅助核算明细表
CREATE TABLE `acc_auxiliary_balance` (
    `id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `period_code` VARCHAR(10) NOT NULL COMMENT '期间编码',
    `subject_code` VARCHAR(32) NOT NULL COMMENT '科目编码',
    `auxiliary_type` VARCHAR(32) NOT NULL COMMENT '辅助核算类型',
    `auxiliary_id` BIGINT(20) NOT NULL COMMENT '辅助核算ID',
    `auxiliary_code` VARCHAR(64) DEFAULT NULL COMMENT '辅助核算编码',
    `auxiliary_name` VARCHAR(128) DEFAULT NULL COMMENT '辅助核算名称',
    `begin_debit` DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '期初借方',
    `begin_credit` DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '期初贷方',
    `period_debit` DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '本期借方',
    `period_credit` DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '本期贷方',
    `end_debit` DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '期末借方',
    `end_credit` DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '期末贷方',
    `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
    `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_balance` (`period_code`, `subject_code`, `auxiliary_type`, `auxiliary_id`),
    KEY `idx_auxiliary` (`auxiliary_type`, `auxiliary_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='辅助核算余额表';

-- 操作日志表(审计追踪)
CREATE TABLE `acc_operation_log` (
    `id` BIGINT(20) NOT NULL AUTO_INCREMENT,
    `operation_type` VARCHAR(32) NOT NULL COMMENT '操作类型',
    `business_type` VARCHAR(32) NOT NULL COMMENT '业务类型(凭证/科目/期间等)',
    `business_id` VARCHAR(64) NOT NULL COMMENT '业务ID',
    `business_no` VARCHAR(64) DEFAULT NULL COMMENT '业务单号',
    `operation_content` TEXT DEFAULT NULL COMMENT '操作内容',
    `before_data` TEXT DEFAULT NULL COMMENT '操作前数据',
    `after_data` TEXT DEFAULT NULL COMMENT '操作后数据',
    `operator` VARCHAR(32) NOT NULL COMMENT '操作人',
    `operate_time` DATETIME NOT NULL COMMENT '操作时间',
    `ip_address` VARCHAR(64) DEFAULT NULL COMMENT 'IP地址',
    PRIMARY KEY (`id`),
    KEY `idx_business` (`business_type`, `business_id`),
    KEY `idx_operate_time` (`operate_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='操作日志表';

3.2 ER关系图

lua 复制代码
+------------------+       +------------------+       +------------------+
|   acc_subject    |       |   acc_voucher    |       | acc_voucher_entry|
+------------------+       +------------------+       +------------------+
| subject_code(PK) |<------| subject_code(FK) |       | id (PK)          |
| subject_name     |       | id (PK)          |<------| voucher_id (FK)  |
| parent_code      |       | voucher_no (UK)  |       | subject_code     |
| subject_type     |       | period_code      |       | debit_amount     |
| balance_direction|       | total_debit      |       | credit_amount    |
+------------------+       | total_credit     |       | auxiliary_data   |
                           | status           |       +------------------+
                           +------------------+
                                   |
                           +-------v--------+
                           |  acc_period    |
                           +----------------+
                           | period_code(PK)|
                           | period_year    |
                           | period_month   |
                           | status         |
                           +----------------+

+------------------+       +------------------------+
|acc_subject_balance|      | acc_auxiliary_balance  |
+------------------+       +------------------------+
| period_code      |       | period_code            |
| subject_code     |       | subject_code           |
| begin_debit      |       | auxiliary_type         |
| period_debit     |       | auxiliary_id           |
| end_debit        |       | begin_debit            |
+------------------+       | period_debit           |
                           +------------------------+

四、核心实体类

4.1 科目实体

java 复制代码
package com.accounting.service.entity;

import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;

/**
 * 会计科目实体
 */
@Data
@TableName("acc_subject")
public class AccSubject {

    @TableId(type = IdType.AUTO)
    private Long id;

    /**
     * 科目编码
     */
    private String subjectCode;

    /**
     * 科目名称
     */
    private String subjectName;

    /**
     * 上级科目编码
     */
    private String parentCode;

    /**
     * 科目级次
     */
    private Integer subjectLevel;

    /**
     * 科目类型:1-资产,2-负债,3-权益,4-成本,5-损益
     */
    private Integer subjectType;

    /**
     * 余额方向:1-借方,2-贷方
     */
    private Integer balanceDirection;

    /**
     * 是否末级科目
     */
    private Integer isLeaf;

    /**
     * 是否现金科目
     */
    private Integer isCashSubject;

    /**
     * 是否银行科目
     */
    private Integer isBankSubject;

    /**
     * 辅助核算类型(JSON数组)
     */
    private String auxiliaryTypes;

    /**
     * 状态:0-停用,1-启用
     */
    private Integer status;

    @TableField(fill = FieldFill.INSERT)
    private LocalDateTime createTime;

    @TableField(fill = FieldFill.INSERT_UPDATE)
    private LocalDateTime updateTime;

    /**
     * 子科目列表(非数据库字段)
     */
    @TableField(exist = false)
    private List<AccSubject> children;
}

4.2 凭证实体

java 复制代码
package com.accounting.service.entity;

import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;

/**
 * 会计凭证实体
 */
@Data
@TableName("acc_voucher")
public class AccVoucher {

    @TableId(type = IdType.AUTO)
    private Long id;

    /**
     * 凭证号
     */
    private String voucherNo;

    /**
     * 凭证字(记/收/付/转)
     */
    private String voucherType;

    /**
     * 凭证日期
     */
    private LocalDate voucherDate;

    /**
     * 所属期间
     */
    private String periodCode;

    /**
     * 附件数
     */
    private Integer attachmentCount;

    /**
     * 借方合计
     */
    private BigDecimal totalDebit;

    /**
     * 贷方合计
     */
    private BigDecimal totalCredit;

    /**
     * 凭证摘要
     */
    private String summary;

    /**
     * 状态:1-未审核,2-已审核,3-已过账,4-已作废
     */
    private Integer status;

    /**
     * 来源类型
     */
    private String sourceType;

    /**
     * 来源单据号
     */
    private String sourceNo;

    private String createUser;

    private String auditUser;

    private LocalDateTime auditTime;

    private String postUser;

    private LocalDateTime postTime;

    @TableField(fill = FieldFill.INSERT)
    private LocalDateTime createTime;

    @TableField(fill = FieldFill.INSERT_UPDATE)
    private LocalDateTime updateTime;

    /**
     * 凭证分录列表(非数据库字段)
     */
    @TableField(exist = false)
    private List<AccVoucherEntry> entries;
}
java 复制代码
package com.accounting.service.entity;

import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;

/**
 * 凭证分录实体
 */
@Data
@TableName("acc_voucher_entry")
public class AccVoucherEntry {

    @TableId(type = IdType.AUTO)
    private Long id;

    private Long voucherId;

    private String voucherNo;

    /**
     * 行号
     */
    private Integer lineNo;

    private String subjectCode;

    private String subjectName;

    /**
     * 摘要
     */
    private String summary;

    /**
     * 借方金额
     */
    private BigDecimal debitAmount;

    /**
     * 贷方金额
     */
    private BigDecimal creditAmount;

    /**
     * 币种
     */
    private String currencyCode;

    /**
     * 汇率
     */
    private BigDecimal exchangeRate;

    /**
     * 原币金额
     */
    private BigDecimal originalAmount;

    /**
     * 辅助核算数据(JSON)
     */
    private String auxiliaryData;

    @TableField(fill = FieldFill.INSERT)
    private LocalDateTime createTime;
}

4.3 枚举定义

java 复制代码
package com.accounting.service.enums;

import lombok.Getter;

/**
 * 科目类型枚举
 */
@Getter
public enum SubjectTypeEnum {

    ASSET(1, "资产"),
    LIABILITY(2, "负债"),
    EQUITY(3, "所有者权益"),
    COST(4, "成本"),
    PROFIT_LOSS(5, "损益");

    private final Integer code;
    private final String desc;

    SubjectTypeEnum(Integer code, String desc) {
        this.code = code;
        this.desc = desc;
    }

    public static SubjectTypeEnum of(Integer code) {
        for (SubjectTypeEnum value : values()) {
            if (value.getCode().equals(code)) {
                return value;
            }
        }
        return null;
    }
}
java 复制代码
package com.accounting.service.enums;

import lombok.Getter;

/**
 * 凭证状态枚举
 */
@Getter
public enum VoucherStatusEnum {

    DRAFT(1, "未审核"),
    AUDITED(2, "已审核"),
    POSTED(3, "已过账"),
    VOID(4, "已作废");

    private final Integer code;
    private final String desc;

    VoucherStatusEnum(Integer code, String desc) {
        this.code = code;
        this.desc = desc;
    }
}
java 复制代码
package com.accounting.service.enums;

import lombok.Getter;

/**
 * 余额方向枚举
 */
@Getter
public enum BalanceDirectionEnum {

    DEBIT(1, "借方"),
    CREDIT(2, "贷方");

    private final Integer code;
    private final String desc;

    BalanceDirectionEnum(Integer code, String desc) {
        this.code = code;
        this.desc = desc;
    }
}

4.4 DTO定义

java 复制代码
package com.accounting.service.dto;

import lombok.Data;
import javax.validation.constraints.*;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;

/**
 * 创建凭证请求
 */
@Data
public class CreateVoucherRequest {

    /**
     * 凭证字
     */
    @NotBlank(message = "凭证字不能为空")
    private String voucherType;

    /**
     * 凭证日期
     */
    @NotNull(message = "凭证日期不能为空")
    private LocalDate voucherDate;

    /**
     * 附件数
     */
    private Integer attachmentCount;

    /**
     * 凭证摘要
     */
    private String summary;

    /**
     * 来源类型
     */
    private String sourceType;

    /**
     * 来源单据号
     */
    private String sourceNo;

    /**
     * 凭证分录
     */
    @NotEmpty(message = "凭证分录不能为空")
    @Size(min = 2, message = "凭证分录至少2行")
    private List<VoucherEntryDTO> entries;
}
java 复制代码
package com.accounting.service.dto;

import lombok.Data;
import javax.validation.constraints.*;
import java.math.BigDecimal;

/**
 * 凭证分录DTO
 */
@Data
public class VoucherEntryDTO {

    /**
     * 科目编码
     */
    @NotBlank(message = "科目编码不能为空")
    private String subjectCode;

    /**
     * 摘要
     */
    @NotBlank(message = "摘要不能为空")
    private String summary;

    /**
     * 借方金额
     */
    private BigDecimal debitAmount;

    /**
     * 贷方金额
     */
    private BigDecimal creditAmount;

    /**
     * 币种
     */
    private String currencyCode;

    /**
     * 汇率
     */
    private BigDecimal exchangeRate;

    /**
     * 原币金额
     */
    private BigDecimal originalAmount;

    /**
     * 辅助核算数据
     */
    private AuxiliaryDataDTO auxiliaryData;
}
java 复制代码
package com.accounting.service.dto;

import lombok.Data;
import java.util.Map;

/**
 * 辅助核算数据DTO
 */
@Data
public class AuxiliaryDataDTO {

    /**
     * 部门
     */
    private Long departmentId;
    private String departmentName;

    /**
     * 项目
     */
    private Long projectId;
    private String projectName;

    /**
     * 往来单位
     */
    private Long partnerId;
    private String partnerName;

    /**
     * 员工
     */
    private Long employeeId;
    private String employeeName;

    /**
     * 现金流项目
     */
    private Long cashFlowItemId;
    private String cashFlowItemName;

    /**
     * 扩展字段
     */
    private Map<String, Object> extFields;
}

五、科目管理服务

5.1 科目树结构

yaml 复制代码
1001 库存现金
1002 银行存款
  1002.01 工商银行
  1002.02 建设银行
1122 应收账款
  1122.01 客户A
  1122.02 客户B
1403 原材料
  1403.01 主要材料
    1403.01.01 钢材
    1403.01.02 铝材
  1403.02 辅助材料
1601 固定资产
  1601.01 房屋建筑物
  1601.02 机器设备

5.2 科目管理服务

java 复制代码
package com.accounting.service.service;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.accounting.common.exception.AccountingException;
import com.accounting.common.result.ResultCode;
import com.accounting.service.dto.CreateSubjectRequest;
import com.accounting.service.entity.AccSubject;
import com.accounting.service.mapper.AccSubjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.*;
import java.util.stream.Collectors;

/**
 * 科目管理服务
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class SubjectService {

    private final AccSubjectMapper subjectMapper;

    /**
     * 获取科目树
     */
    @Cacheable(value = "subject", key = "'tree'")
    public List<AccSubject> getSubjectTree() {
        List<AccSubject> allSubjects = subjectMapper.selectList(
            new LambdaQueryWrapper<AccSubject>()
                .eq(AccSubject::getStatus, 1)
                .orderByAsc(AccSubject::getSubjectCode)
        );

        return buildTree(allSubjects);
    }

    /**
     * 构建科目树
     */
    private List<AccSubject> buildTree(List<AccSubject> subjects) {
        Map<String, List<AccSubject>> parentMap = subjects.stream()
            .filter(s -> s.getParentCode() != null)
            .collect(Collectors.groupingBy(AccSubject::getParentCode));

        List<AccSubject> roots = subjects.stream()
            .filter(s -> s.getParentCode() == null || s.getParentCode().isEmpty())
            .collect(Collectors.toList());

        for (AccSubject root : roots) {
            buildChildren(root, parentMap);
        }

        return roots;
    }

    private void buildChildren(AccSubject parent, Map<String, List<AccSubject>> parentMap) {
        List<AccSubject> children = parentMap.get(parent.getSubjectCode());
        if (children != null && !children.isEmpty()) {
            parent.setChildren(children);
            for (AccSubject child : children) {
                buildChildren(child, parentMap);
            }
        }
    }

    /**
     * 根据编码获取科目
     */
    @Cacheable(value = "subject", key = "#subjectCode")
    public AccSubject getByCode(String subjectCode) {
        return subjectMapper.selectOne(
            new LambdaQueryWrapper<AccSubject>()
                .eq(AccSubject::getSubjectCode, subjectCode)
        );
    }

    /**
     * 获取末级科目列表
     */
    public List<AccSubject> getLeafSubjects() {
        return subjectMapper.selectList(
            new LambdaQueryWrapper<AccSubject>()
                .eq(AccSubject::getIsLeaf, 1)
                .eq(AccSubject::getStatus, 1)
                .orderByAsc(AccSubject::getSubjectCode)
        );
    }

    /**
     * 创建科目
     */
    @Transactional(rollbackFor = Exception.class)
    @CacheEvict(value = "subject", allEntries = true)
    public AccSubject createSubject(CreateSubjectRequest request) {
        // 1. 校验科目编码唯一性
        AccSubject existSubject = getByCode(request.getSubjectCode());
        if (existSubject != null) {
            throw new AccountingException(ResultCode.SUBJECT_CODE_EXISTS);
        }

        // 2. 校验上级科目
        AccSubject parent = null;
        if (request.getParentCode() != null && !request.getParentCode().isEmpty()) {
            parent = getByCode(request.getParentCode());
            if (parent == null) {
                throw new AccountingException(ResultCode.PARENT_SUBJECT_NOT_EXIST);
            }
            // 上级科目必须是非末级
            if (parent.getIsLeaf() == 1) {
                // 检查上级科目是否已有发生额,有则不能添加下级
                if (hasBalance(parent.getSubjectCode())) {
                    throw new AccountingException(ResultCode.PARENT_HAS_BALANCE);
                }
                // 更新上级为非末级
                parent.setIsLeaf(0);
                subjectMapper.updateById(parent);
            }
        }

        // 3. 创建科目
        AccSubject subject = new AccSubject();
        subject.setSubjectCode(request.getSubjectCode());
        subject.setSubjectName(request.getSubjectName());
        subject.setParentCode(request.getParentCode());
        subject.setSubjectLevel(parent != null ? parent.getSubjectLevel() + 1 : 1);
        subject.setSubjectType(parent != null ? parent.getSubjectType() : request.getSubjectType());
        subject.setBalanceDirection(parent != null ? parent.getBalanceDirection() : request.getBalanceDirection());
        subject.setIsLeaf(1);
        subject.setIsCashSubject(request.getIsCashSubject() != null ? request.getIsCashSubject() : 0);
        subject.setIsBankSubject(request.getIsBankSubject() != null ? request.getIsBankSubject() : 0);
        subject.setAuxiliaryTypes(request.getAuxiliaryTypes());
        subject.setStatus(1);

        subjectMapper.insert(subject);

        log.info("创建科目成功: code={}, name={}", subject.getSubjectCode(), subject.getSubjectName());

        return subject;
    }

    /**
     * 检查科目是否有余额
     */
    private boolean hasBalance(String subjectCode) {
        // TODO: 查询科目余额表判断
        return false;
    }

    /**
     * 校验科目是否可用于记账
     */
    public void validateForVoucher(String subjectCode) {
        AccSubject subject = getByCode(subjectCode);
        if (subject == null) {
            throw new AccountingException(ResultCode.SUBJECT_NOT_EXIST);
        }
        if (subject.getStatus() != 1) {
            throw new AccountingException(ResultCode.SUBJECT_DISABLED);
        }
        if (subject.getIsLeaf() != 1) {
            throw new AccountingException(ResultCode.SUBJECT_NOT_LEAF);
        }
    }
}

六、凭证管理服务

6.1 凭证处理流程

rust 复制代码
+----------+     +----------+     +----------+     +----------+     +----------+
|  录入    | --> |  保存    | --> |  审核    | --> |  过账    | --> |  完成    |
+----------+     +----------+     +----------+     +----------+     +----------+
     |               |               |               |               |
     v               v               v               v               v
  填写分录       校验借贷平衡    审核人确认     更新科目余额    凭证状态完成
  选择科目       生成凭证号      检查合规性     记录明细账      生成账簿
  辅助核算       保存数据库      更新状态       更新辅助余额

6.2 凭证服务实现

java 复制代码
package com.accounting.service.service;

import com.alibaba.fastjson.JSON;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.accounting.common.exception.AccountingException;
import com.accounting.common.result.ResultCode;
import com.accounting.service.dto.*;
import com.accounting.service.entity.*;
import com.accounting.service.enums.VoucherStatusEnum;
import com.accounting.service.mapper.*;
import com.accounting.service.utils.VoucherNoGenerator;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;

/**
 * 凭证管理服务
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class VoucherService {

    private final AccVoucherMapper voucherMapper;
    private final AccVoucherEntryMapper entryMapper;
    private final AccPeriodMapper periodMapper;
    private final SubjectService subjectService;
    private final PeriodService periodService;
    private final BalanceService balanceService;
    private final OperationLogService logService;

    /**
     * 创建凭证
     */
    @Transactional(rollbackFor = Exception.class)
    public AccVoucher createVoucher(String operator, CreateVoucherRequest request) {
        // 1. 校验期间状态
        String periodCode = periodService.getPeriodCode(request.getVoucherDate());
        AccPeriod period = periodService.getByCode(periodCode);
        if (period == null || period.getStatus() != 1) {
            throw new AccountingException(ResultCode.PERIOD_NOT_OPEN);
        }

        // 2. 校验分录
        validateEntries(request.getEntries());

        // 3. 计算借贷合计
        BigDecimal totalDebit = BigDecimal.ZERO;
        BigDecimal totalCredit = BigDecimal.ZERO;
        for (VoucherEntryDTO entry : request.getEntries()) {
            totalDebit = totalDebit.add(
                entry.getDebitAmount() != null ? entry.getDebitAmount() : BigDecimal.ZERO);
            totalCredit = totalCredit.add(
                entry.getCreditAmount() != null ? entry.getCreditAmount() : BigDecimal.ZERO);
        }

        // 4. 校验借贷平衡
        if (totalDebit.compareTo(totalCredit) != 0) {
            throw new AccountingException(ResultCode.DEBIT_CREDIT_NOT_BALANCE);
        }

        // 5. 生成凭证号
        String voucherNo = VoucherNoGenerator.generate(request.getVoucherType(), periodCode);

        // 6. 创建凭证
        AccVoucher voucher = new AccVoucher();
        voucher.setVoucherNo(voucherNo);
        voucher.setVoucherType(request.getVoucherType());
        voucher.setVoucherDate(request.getVoucherDate());
        voucher.setPeriodCode(periodCode);
        voucher.setAttachmentCount(request.getAttachmentCount() != null ? request.getAttachmentCount() : 0);
        voucher.setTotalDebit(totalDebit);
        voucher.setTotalCredit(totalCredit);
        voucher.setSummary(request.getSummary());
        voucher.setStatus(VoucherStatusEnum.DRAFT.getCode());
        voucher.setSourceType(request.getSourceType());
        voucher.setSourceNo(request.getSourceNo());
        voucher.setCreateUser(operator);

        voucherMapper.insert(voucher);

        // 7. 创建分录
        int lineNo = 1;
        for (VoucherEntryDTO entryDTO : request.getEntries()) {
            AccSubject subject = subjectService.getByCode(entryDTO.getSubjectCode());

            AccVoucherEntry entry = new AccVoucherEntry();
            entry.setVoucherId(voucher.getId());
            entry.setVoucherNo(voucherNo);
            entry.setLineNo(lineNo++);
            entry.setSubjectCode(entryDTO.getSubjectCode());
            entry.setSubjectName(subject.getSubjectName());
            entry.setSummary(entryDTO.getSummary());
            entry.setDebitAmount(entryDTO.getDebitAmount() != null ? entryDTO.getDebitAmount() : BigDecimal.ZERO);
            entry.setCreditAmount(entryDTO.getCreditAmount() != null ? entryDTO.getCreditAmount() : BigDecimal.ZERO);
            entry.setCurrencyCode(entryDTO.getCurrencyCode() != null ? entryDTO.getCurrencyCode() : "CNY");
            entry.setExchangeRate(entryDTO.getExchangeRate() != null ? entryDTO.getExchangeRate() : BigDecimal.ONE);
            entry.setOriginalAmount(entryDTO.getOriginalAmount());

            if (entryDTO.getAuxiliaryData() != null) {
                entry.setAuxiliaryData(JSON.toJSONString(entryDTO.getAuxiliaryData()));
            }

            entryMapper.insert(entry);
        }

        // 8. 记录操作日志
        logService.log("CREATE", "VOUCHER", voucher.getId().toString(), voucherNo,
            "创建凭证", null, JSON.toJSONString(voucher), operator);

        log.info("创建凭证成功: voucherNo={}, totalDebit={}", voucherNo, totalDebit);

        voucher.setEntries(getEntriesByVoucherId(voucher.getId()));
        return voucher;
    }

    /**
     * 校验分录
     */
    private void validateEntries(List<VoucherEntryDTO> entries) {
        for (VoucherEntryDTO entry : entries) {
            // 校验科目
            subjectService.validateForVoucher(entry.getSubjectCode());

            // 校验金额(借贷必须有一个)
            boolean hasDebit = entry.getDebitAmount() != null && entry.getDebitAmount().compareTo(BigDecimal.ZERO) > 0;
            boolean hasCredit = entry.getCreditAmount() != null && entry.getCreditAmount().compareTo(BigDecimal.ZERO) > 0;

            if (!hasDebit && !hasCredit) {
                throw new AccountingException(ResultCode.ENTRY_AMOUNT_REQUIRED);
            }

            if (hasDebit && hasCredit) {
                throw new AccountingException(ResultCode.ENTRY_DEBIT_CREDIT_BOTH);
            }
        }
    }

    /**
     * 审核凭证
     */
    @Transactional(rollbackFor = Exception.class)
    public void auditVoucher(String operator, Long voucherId) {
        AccVoucher voucher = voucherMapper.selectById(voucherId);
        if (voucher == null) {
            throw new AccountingException(ResultCode.VOUCHER_NOT_EXIST);
        }

        // 校验状态
        if (!VoucherStatusEnum.DRAFT.getCode().equals(voucher.getStatus())) {
            throw new AccountingException(ResultCode.VOUCHER_STATUS_ERROR);
        }

        // 校验期间
        AccPeriod period = periodService.getByCode(voucher.getPeriodCode());
        if (period.getStatus() != 1) {
            throw new AccountingException(ResultCode.PERIOD_NOT_OPEN);
        }

        // 制单人不能审核自己的凭证
        if (operator.equals(voucher.getCreateUser())) {
            throw new AccountingException(ResultCode.CANNOT_AUDIT_OWN_VOUCHER);
        }

        // 更新状态
        LocalDateTime now = LocalDateTime.now();
        voucherMapper.update(null,
            new LambdaUpdateWrapper<AccVoucher>()
                .eq(AccVoucher::getId, voucherId)
                .set(AccVoucher::getStatus, VoucherStatusEnum.AUDITED.getCode())
                .set(AccVoucher::getAuditUser, operator)
                .set(AccVoucher::getAuditTime, now)
        );

        logService.log("AUDIT", "VOUCHER", voucherId.toString(), voucher.getVoucherNo(),
            "审核凭证", null, null, operator);

        log.info("审核凭证成功: voucherNo={}, auditor={}", voucher.getVoucherNo(), operator);
    }

    /**
     * 弃审凭证
     */
    @Transactional(rollbackFor = Exception.class)
    public void unauditVoucher(String operator, Long voucherId) {
        AccVoucher voucher = voucherMapper.selectById(voucherId);
        if (voucher == null) {
            throw new AccountingException(ResultCode.VOUCHER_NOT_EXIST);
        }

        // 校验状态(只能弃审已审核的凭证)
        if (!VoucherStatusEnum.AUDITED.getCode().equals(voucher.getStatus())) {
            throw new AccountingException(ResultCode.VOUCHER_STATUS_ERROR);
        }

        // 校验期间
        AccPeriod period = periodService.getByCode(voucher.getPeriodCode());
        if (period.getStatus() != 1) {
            throw new AccountingException(ResultCode.PERIOD_NOT_OPEN);
        }

        // 更新状态
        voucherMapper.update(null,
            new LambdaUpdateWrapper<AccVoucher>()
                .eq(AccVoucher::getId, voucherId)
                .set(AccVoucher::getStatus, VoucherStatusEnum.DRAFT.getCode())
                .set(AccVoucher::getAuditUser, null)
                .set(AccVoucher::getAuditTime, null)
        );

        logService.log("UNAUDIT", "VOUCHER", voucherId.toString(), voucher.getVoucherNo(),
            "弃审凭证", null, null, operator);

        log.info("弃审凭证成功: voucherNo={}", voucher.getVoucherNo());
    }

    /**
     * 过账凭证
     */
    @Transactional(rollbackFor = Exception.class)
    public void postVoucher(String operator, Long voucherId) {
        AccVoucher voucher = voucherMapper.selectById(voucherId);
        if (voucher == null) {
            throw new AccountingException(ResultCode.VOUCHER_NOT_EXIST);
        }

        // 校验状态
        if (!VoucherStatusEnum.AUDITED.getCode().equals(voucher.getStatus())) {
            throw new AccountingException(ResultCode.VOUCHER_NOT_AUDITED);
        }

        // 校验期间
        AccPeriod period = periodService.getByCode(voucher.getPeriodCode());
        if (period.getStatus() != 1) {
            throw new AccountingException(ResultCode.PERIOD_NOT_OPEN);
        }

        // 获取分录
        List<AccVoucherEntry> entries = getEntriesByVoucherId(voucherId);

        // 更新科目余额
        for (AccVoucherEntry entry : entries) {
            balanceService.updateBalance(voucher.getPeriodCode(), entry);
        }

        // 更新状态
        LocalDateTime now = LocalDateTime.now();
        voucherMapper.update(null,
            new LambdaUpdateWrapper<AccVoucher>()
                .eq(AccVoucher::getId, voucherId)
                .set(AccVoucher::getStatus, VoucherStatusEnum.POSTED.getCode())
                .set(AccVoucher::getPostUser, operator)
                .set(AccVoucher::getPostTime, now)
        );

        logService.log("POST", "VOUCHER", voucherId.toString(), voucher.getVoucherNo(),
            "过账凭证", null, null, operator);

        log.info("过账凭证成功: voucherNo={}", voucher.getVoucherNo());
    }

    /**
     * 作废凭证
     */
    @Transactional(rollbackFor = Exception.class)
    public void voidVoucher(String operator, Long voucherId, String reason) {
        AccVoucher voucher = voucherMapper.selectById(voucherId);
        if (voucher == null) {
            throw new AccountingException(ResultCode.VOUCHER_NOT_EXIST);
        }

        // 已过账的凭证不能直接作废
        if (VoucherStatusEnum.POSTED.getCode().equals(voucher.getStatus())) {
            throw new AccountingException(ResultCode.POSTED_VOUCHER_CANNOT_VOID);
        }

        // 校验期间
        AccPeriod period = periodService.getByCode(voucher.getPeriodCode());
        if (period.getStatus() != 1) {
            throw new AccountingException(ResultCode.PERIOD_NOT_OPEN);
        }

        // 更新状态
        voucherMapper.update(null,
            new LambdaUpdateWrapper<AccVoucher>()
                .eq(AccVoucher::getId, voucherId)
                .set(AccVoucher::getStatus, VoucherStatusEnum.VOID.getCode())
        );

        logService.log("VOID", "VOUCHER", voucherId.toString(), voucher.getVoucherNo(),
            "作废凭证:" + reason, null, null, operator);

        log.info("作废凭证成功: voucherNo={}, reason={}", voucher.getVoucherNo(), reason);
    }

    /**
     * 获取凭证分录
     */
    public List<AccVoucherEntry> getEntriesByVoucherId(Long voucherId) {
        return entryMapper.selectList(
            new LambdaQueryWrapper<AccVoucherEntry>()
                .eq(AccVoucherEntry::getVoucherId, voucherId)
                .orderByAsc(AccVoucherEntry::getLineNo)
        );
    }

    /**
     * 查询凭证详情
     */
    public AccVoucher getVoucherDetail(Long voucherId) {
        AccVoucher voucher = voucherMapper.selectById(voucherId);
        if (voucher != null) {
            voucher.setEntries(getEntriesByVoucherId(voucherId));
        }
        return voucher;
    }
}

6.3 凭证号生成器

java 复制代码
package com.accounting.service.utils;

import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

import java.time.LocalDate;
import java.time.format.DateTimeFormatter;

/**
 * 凭证号生成器
 */
@Component
public class VoucherNoGenerator {

    private static RedisTemplate<String, Object> redisTemplate;

    private static final String VOUCHER_NO_KEY = "accounting:voucher:no:";

    public VoucherNoGenerator(RedisTemplate<String, Object> redisTemplate) {
        VoucherNoGenerator.redisTemplate = redisTemplate;
    }

    /**
     * 生成凭证号
     * 格式:凭证字-期间-序号,如:记-202401-0001
     */
    public static String generate(String voucherType, String periodCode) {
        String key = VOUCHER_NO_KEY + periodCode + ":" + voucherType;

        // 使用Redis自增生成序号
        Long seq = redisTemplate.opsForValue().increment(key);

        return String.format("%s-%s-%04d", voucherType, periodCode, seq);
    }

    /**
     * 重置序号(期间启用时调用)
     */
    public static void resetSequence(String periodCode, String voucherType) {
        String key = VOUCHER_NO_KEY + periodCode + ":" + voucherType;
        redisTemplate.delete(key);
    }
}

七、余额管理服务

7.1 余额计算公式

diff 复制代码
+------------------------------------------------------------------+
|                        余额计算公式                               |
+------------------------------------------------------------------+
|                                                                   |
|  借方余额科目(资产/费用):                                       |
|  期末余额 = 期初余额 + 本期借方 - 本期贷方                          |
|                                                                   |
|  贷方余额科目(负债/权益/收入):                                   |
|  期末余额 = 期初余额 + 本期贷方 - 本期借方                          |
|                                                                   |
+------------------------------------------------------------------+
|                                                                   |
|  本年累计借方 = 上期本年累计借方 + 本期借方                         |
|  本年累计贷方 = 上期本年累计贷方 + 本期贷方                         |
|                                                                   |
+------------------------------------------------------------------+

7.2 余额服务实现

java 复制代码
package com.accounting.service.service;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.accounting.service.entity.*;
import com.accounting.service.enums.BalanceDirectionEnum;
import com.accounting.service.mapper.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.math.BigDecimal;
import java.util.List;

/**
 * 余额管理服务
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class BalanceService {

    private final AccSubjectBalanceMapper balanceMapper;
    private final AccAuxiliaryBalanceMapper auxiliaryBalanceMapper;
    private final SubjectService subjectService;

    /**
     * 更新科目余额(凭证过账时调用)
     */
    @Transactional(rollbackFor = Exception.class)
    public void updateBalance(String periodCode, AccVoucherEntry entry) {
        String subjectCode = entry.getSubjectCode();
        BigDecimal debitAmount = entry.getDebitAmount();
        BigDecimal creditAmount = entry.getCreditAmount();

        // 1. 更新科目余额
        AccSubjectBalance balance = getOrCreateBalance(periodCode, subjectCode);

        // 更新本期发生额
        balance.setPeriodDebit(balance.getPeriodDebit().add(debitAmount));
        balance.setPeriodCredit(balance.getPeriodCredit().add(creditAmount));

        // 更新本年累计
        balance.setYearDebit(balance.getYearDebit().add(debitAmount));
        balance.setYearCredit(balance.getYearCredit().add(creditAmount));

        // 计算期末余额
        AccSubject subject = subjectService.getByCode(subjectCode);
        calculateEndBalance(balance, subject.getBalanceDirection());

        balanceMapper.updateById(balance);

        // 2. 更新辅助核算余额
        if (entry.getAuxiliaryData() != null && !entry.getAuxiliaryData().isEmpty()) {
            updateAuxiliaryBalance(periodCode, entry);
        }

        // 3. 级联更新上级科目余额
        updateParentBalance(periodCode, subjectCode, debitAmount, creditAmount);
    }

    /**
     * 获取或创建科目余额
     */
    private AccSubjectBalance getOrCreateBalance(String periodCode, String subjectCode) {
        AccSubjectBalance balance = balanceMapper.selectOne(
            new LambdaQueryWrapper<AccSubjectBalance>()
                .eq(AccSubjectBalance::getPeriodCode, periodCode)
                .eq(AccSubjectBalance::getSubjectCode, subjectCode)
        );

        if (balance == null) {
            balance = new AccSubjectBalance();
            balance.setPeriodCode(periodCode);
            balance.setSubjectCode(subjectCode);
            balance.setBeginDebit(BigDecimal.ZERO);
            balance.setBeginCredit(BigDecimal.ZERO);
            balance.setPeriodDebit(BigDecimal.ZERO);
            balance.setPeriodCredit(BigDecimal.ZERO);
            balance.setYearDebit(BigDecimal.ZERO);
            balance.setYearCredit(BigDecimal.ZERO);
            balance.setEndDebit(BigDecimal.ZERO);
            balance.setEndCredit(BigDecimal.ZERO);

            // 获取上期期末作为本期期初
            String lastPeriodCode = getLastPeriodCode(periodCode);
            if (lastPeriodCode != null) {
                AccSubjectBalance lastBalance = balanceMapper.selectOne(
                    new LambdaQueryWrapper<AccSubjectBalance>()
                        .eq(AccSubjectBalance::getPeriodCode, lastPeriodCode)
                        .eq(AccSubjectBalance::getSubjectCode, subjectCode)
                );
                if (lastBalance != null) {
                    balance.setBeginDebit(lastBalance.getEndDebit());
                    balance.setBeginCredit(lastBalance.getEndCredit());
                    // 如果是年初,本年累计从0开始
                    if (!periodCode.endsWith("01")) {
                        balance.setYearDebit(lastBalance.getYearDebit());
                        balance.setYearCredit(lastBalance.getYearCredit());
                    }
                }
            }

            balanceMapper.insert(balance);
        }

        return balance;
    }

    /**
     * 计算期末余额
     */
    private void calculateEndBalance(AccSubjectBalance balance, Integer balanceDirection) {
        BigDecimal beginBalance = balance.getBeginDebit().subtract(balance.getBeginCredit());
        BigDecimal periodChange = balance.getPeriodDebit().subtract(balance.getPeriodCredit());
        BigDecimal endBalance = beginBalance.add(periodChange);

        if (endBalance.compareTo(BigDecimal.ZERO) >= 0) {
            balance.setEndDebit(endBalance);
            balance.setEndCredit(BigDecimal.ZERO);
        } else {
            balance.setEndDebit(BigDecimal.ZERO);
            balance.setEndCredit(endBalance.abs());
        }
    }

    /**
     * 更新上级科目余额
     */
    private void updateParentBalance(String periodCode, String subjectCode,
                                      BigDecimal debitAmount, BigDecimal creditAmount) {
        AccSubject subject = subjectService.getByCode(subjectCode);
        if (subject.getParentCode() == null || subject.getParentCode().isEmpty()) {
            return;
        }

        AccSubjectBalance parentBalance = getOrCreateBalance(periodCode, subject.getParentCode());

        parentBalance.setPeriodDebit(parentBalance.getPeriodDebit().add(debitAmount));
        parentBalance.setPeriodCredit(parentBalance.getPeriodCredit().add(creditAmount));
        parentBalance.setYearDebit(parentBalance.getYearDebit().add(debitAmount));
        parentBalance.setYearCredit(parentBalance.getYearCredit().add(creditAmount));

        AccSubject parentSubject = subjectService.getByCode(subject.getParentCode());
        calculateEndBalance(parentBalance, parentSubject.getBalanceDirection());

        balanceMapper.updateById(parentBalance);

        // 递归更新更上级
        updateParentBalance(periodCode, subject.getParentCode(), debitAmount, creditAmount);
    }

    /**
     * 更新辅助核算余额
     */
    private void updateAuxiliaryBalance(String periodCode, AccVoucherEntry entry) {
        // TODO: 解析辅助核算数据并更新对应余额
    }

    /**
     * 获取上期期间编码
     */
    private String getLastPeriodCode(String periodCode) {
        int year = Integer.parseInt(periodCode.substring(0, 4));
        int month = Integer.parseInt(periodCode.substring(4, 6));

        if (month == 1) {
            return String.format("%d%02d", year - 1, 12);
        } else {
            return String.format("%d%02d", year, month - 1);
        }
    }

    /**
     * 查询科目余额
     */
    public AccSubjectBalance getBalance(String periodCode, String subjectCode) {
        return balanceMapper.selectOne(
            new LambdaQueryWrapper<AccSubjectBalance>()
                .eq(AccSubjectBalance::getPeriodCode, periodCode)
                .eq(AccSubjectBalance::getSubjectCode, subjectCode)
        );
    }

    /**
     * 查询期间所有科目余额
     */
    public List<AccSubjectBalance> getBalancesByPeriod(String periodCode) {
        return balanceMapper.selectList(
            new LambdaQueryWrapper<AccSubjectBalance>()
                .eq(AccSubjectBalance::getPeriodCode, periodCode)
                .orderByAsc(AccSubjectBalance::getSubjectCode)
        );
    }

    /**
     * 试算平衡检查
     */
    public TrialBalanceResult checkTrialBalance(String periodCode) {
        List<AccSubjectBalance> balances = getBalancesByPeriod(periodCode);

        BigDecimal totalBeginDebit = BigDecimal.ZERO;
        BigDecimal totalBeginCredit = BigDecimal.ZERO;
        BigDecimal totalPeriodDebit = BigDecimal.ZERO;
        BigDecimal totalPeriodCredit = BigDecimal.ZERO;
        BigDecimal totalEndDebit = BigDecimal.ZERO;
        BigDecimal totalEndCredit = BigDecimal.ZERO;

        for (AccSubjectBalance balance : balances) {
            // 只统计一级科目
            if (balance.getSubjectCode().length() == 4) {
                totalBeginDebit = totalBeginDebit.add(balance.getBeginDebit());
                totalBeginCredit = totalBeginCredit.add(balance.getBeginCredit());
                totalPeriodDebit = totalPeriodDebit.add(balance.getPeriodDebit());
                totalPeriodCredit = totalPeriodCredit.add(balance.getPeriodCredit());
                totalEndDebit = totalEndDebit.add(balance.getEndDebit());
                totalEndCredit = totalEndCredit.add(balance.getEndCredit());
            }
        }

        TrialBalanceResult result = new TrialBalanceResult();
        result.setPeriodCode(periodCode);
        result.setBeginDebit(totalBeginDebit);
        result.setBeginCredit(totalBeginCredit);
        result.setPeriodDebit(totalPeriodDebit);
        result.setPeriodCredit(totalPeriodCredit);
        result.setEndDebit(totalEndDebit);
        result.setEndCredit(totalEndCredit);

        // 检查平衡
        result.setBeginBalanced(totalBeginDebit.compareTo(totalBeginCredit) == 0);
        result.setPeriodBalanced(totalPeriodDebit.compareTo(totalPeriodCredit) == 0);
        result.setEndBalanced(totalEndDebit.compareTo(totalEndCredit) == 0);

        return result;
    }
}

八、期间管理与结账

8.1 期间结账流程

rust 复制代码
+----------+     +----------+     +----------+     +----------+     +----------+
| 结账检查  | --> | 试算平衡  | --> | 期末结转  | --> | 更新状态  | --> | 结账完成  |
+----------+     +----------+     +----------+     +----------+     +----------+
     |               |               |               |               |
     v               v               v               v               v
  凭证全过账     借贷必须平衡    损益结转本年利润   期间状态=已结账  生成下期期初
  无未审凭证     期初+发生=期末   计提折旧等        记录结账时间

8.2 期间服务实现

java 复制代码
package com.accounting.service.service;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.accounting.common.exception.AccountingException;
import com.accounting.common.result.ResultCode;
import com.accounting.service.entity.*;
import com.accounting.service.enums.VoucherStatusEnum;
import com.accounting.service.mapper.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;

/**
 * 期间管理服务
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class PeriodService {

    private final AccPeriodMapper periodMapper;
    private final AccVoucherMapper voucherMapper;
    private final BalanceService balanceService;
    private final CarryoverService carryoverService;
    private final OperationLogService logService;

    /**
     * 根据日期获取期间编码
     */
    public String getPeriodCode(LocalDate date) {
        return String.format("%d%02d", date.getYear(), date.getMonthValue());
    }

    /**
     * 根据编码获取期间
     */
    public AccPeriod getByCode(String periodCode) {
        return periodMapper.selectOne(
            new LambdaQueryWrapper<AccPeriod>()
                .eq(AccPeriod::getPeriodCode, periodCode)
        );
    }

    /**
     * 获取当前期间
     */
    public AccPeriod getCurrentPeriod() {
        return periodMapper.selectOne(
            new LambdaQueryWrapper<AccPeriod>()
                .eq(AccPeriod::getStatus, 1)
                .orderByDesc(AccPeriod::getPeriodCode)
                .last("LIMIT 1")
        );
    }

    /**
     * 期间结账
     */
    @Transactional(rollbackFor = Exception.class)
    public void closePeriod(String operator, String periodCode) {
        AccPeriod period = getByCode(periodCode);
        if (period == null) {
            throw new AccountingException(ResultCode.PERIOD_NOT_EXIST);
        }

        if (period.getStatus() != 1) {
            throw new AccountingException(ResultCode.PERIOD_NOT_OPEN);
        }

        // 1. 检查是否有未审核凭证
        long draftCount = voucherMapper.selectCount(
            new LambdaQueryWrapper<AccVoucher>()
                .eq(AccVoucher::getPeriodCode, periodCode)
                .eq(AccVoucher::getStatus, VoucherStatusEnum.DRAFT.getCode())
        );
        if (draftCount > 0) {
            throw new AccountingException(ResultCode.HAS_UNAUDITED_VOUCHER);
        }

        // 2. 检查是否有未过账凭证
        long unpostedCount = voucherMapper.selectCount(
            new LambdaQueryWrapper<AccVoucher>()
                .eq(AccVoucher::getPeriodCode, periodCode)
                .eq(AccVoucher::getStatus, VoucherStatusEnum.AUDITED.getCode())
        );
        if (unpostedCount > 0) {
            throw new AccountingException(ResultCode.HAS_UNPOSTED_VOUCHER);
        }

        // 3. 试算平衡检查
        TrialBalanceResult trialResult = balanceService.checkTrialBalance(periodCode);
        if (!trialResult.isEndBalanced()) {
            throw new AccountingException(ResultCode.TRIAL_BALANCE_ERROR);
        }

        // 4. 执行期末结转(损益结转)
        if (periodCode.endsWith("12")) {
            // 年末结转
            carryoverService.yearEndCarryover(operator, periodCode);
        } else {
            // 月末结转
            carryoverService.monthEndCarryover(operator, periodCode);
        }

        // 5. 更新期间状态
        LocalDateTime now = LocalDateTime.now();
        periodMapper.update(null,
            new LambdaUpdateWrapper<AccPeriod>()
                .eq(AccPeriod::getPeriodCode, periodCode)
                .set(AccPeriod::getStatus, 2)
                .set(AccPeriod::getCloseTime, now)
                .set(AccPeriod::getCloseUser, operator)
        );

        // 6. 创建下期期间
        createNextPeriod(periodCode);

        logService.log("CLOSE", "PERIOD", period.getId().toString(), periodCode,
            "期间结账", null, null, operator);

        log.info("期间结账成功: periodCode={}, operator={}", periodCode, operator);
    }

    /**
     * 反结账
     */
    @Transactional(rollbackFor = Exception.class)
    public void unclosePeriod(String operator, String periodCode) {
        AccPeriod period = getByCode(periodCode);
        if (period == null) {
            throw new AccountingException(ResultCode.PERIOD_NOT_EXIST);
        }

        if (period.getStatus() != 2) {
            throw new AccountingException(ResultCode.PERIOD_NOT_CLOSED);
        }

        // 检查下期是否有业务
        String nextPeriodCode = getNextPeriodCode(periodCode);
        AccPeriod nextPeriod = getByCode(nextPeriodCode);
        if (nextPeriod != null && nextPeriod.getStatus() != 0) {
            long voucherCount = voucherMapper.selectCount(
                new LambdaQueryWrapper<AccVoucher>()
                    .eq(AccVoucher::getPeriodCode, nextPeriodCode)
            );
            if (voucherCount > 0) {
                throw new AccountingException(ResultCode.NEXT_PERIOD_HAS_VOUCHER);
            }
        }

        // 删除自动生成的结转凭证
        carryoverService.deleteCarryoverVouchers(periodCode);

        // 更新期间状态
        periodMapper.update(null,
            new LambdaUpdateWrapper<AccPeriod>()
                .eq(AccPeriod::getPeriodCode, periodCode)
                .set(AccPeriod::getStatus, 1)
                .set(AccPeriod::getCloseTime, null)
                .set(AccPeriod::getCloseUser, null)
        );

        logService.log("UNCLOSE", "PERIOD", period.getId().toString(), periodCode,
            "期间反结账", null, null, operator);

        log.info("期间反结账成功: periodCode={}", periodCode);
    }

    /**
     * 创建下期期间
     */
    private void createNextPeriod(String currentPeriodCode) {
        String nextPeriodCode = getNextPeriodCode(currentPeriodCode);

        AccPeriod existPeriod = getByCode(nextPeriodCode);
        if (existPeriod != null) {
            // 启用下期
            periodMapper.update(null,
                new LambdaUpdateWrapper<AccPeriod>()
                    .eq(AccPeriod::getPeriodCode, nextPeriodCode)
                    .set(AccPeriod::getStatus, 1)
            );
            return;
        }

        int year = Integer.parseInt(nextPeriodCode.substring(0, 4));
        int month = Integer.parseInt(nextPeriodCode.substring(4, 6));

        LocalDate startDate = LocalDate.of(year, month, 1);
        LocalDate endDate = startDate.plusMonths(1).minusDays(1);

        AccPeriod newPeriod = new AccPeriod();
        newPeriod.setPeriodYear(year);
        newPeriod.setPeriodMonth(month);
        newPeriod.setPeriodCode(nextPeriodCode);
        newPeriod.setStartDate(startDate);
        newPeriod.setEndDate(endDate);
        newPeriod.setStatus(1);

        periodMapper.insert(newPeriod);

        log.info("创建下期期间: periodCode={}", nextPeriodCode);
    }

    /**
     * 获取下期期间编码
     */
    private String getNextPeriodCode(String periodCode) {
        int year = Integer.parseInt(periodCode.substring(0, 4));
        int month = Integer.parseInt(periodCode.substring(4, 6));

        if (month == 12) {
            return String.format("%d%02d", year + 1, 1);
        } else {
            return String.format("%d%02d", year, month + 1);
        }
    }
}

8.3 期末结转服务

java 复制代码
package com.accounting.service.service;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.accounting.service.dto.*;
import com.accounting.service.entity.*;
import com.accounting.service.enums.SubjectTypeEnum;
import com.accounting.service.mapper.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;

/**
 * 期末结转服务
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class CarryoverService {

    private final AccSubjectMapper subjectMapper;
    private final AccSubjectBalanceMapper balanceMapper;
    private final VoucherService voucherService;

    // 本年利润科目编码
    private static final String PROFIT_SUBJECT_CODE = "4103";
    // 利润分配-未分配利润科目编码
    private static final String RETAINED_EARNINGS_CODE = "4104.01";

    /**
     * 月末结转(损益结转本年利润)
     */
    @Transactional(rollbackFor = Exception.class)
    public void monthEndCarryover(String operator, String periodCode) {
        log.info("开始月末结转: periodCode={}", periodCode);

        // 1. 查询所有损益类科目余额
        List<AccSubject> profitLossSubjects = subjectMapper.selectList(
            new LambdaQueryWrapper<AccSubject>()
                .eq(AccSubject::getSubjectType, SubjectTypeEnum.PROFIT_LOSS.getCode())
                .eq(AccSubject::getIsLeaf, 1)
                .eq(AccSubject::getStatus, 1)
        );

        List<VoucherEntryDTO> entries = new ArrayList<>();
        BigDecimal totalIncome = BigDecimal.ZERO;  // 收入类贷方合计
        BigDecimal totalExpense = BigDecimal.ZERO; // 费用类借方合计

        for (AccSubject subject : profitLossSubjects) {
            AccSubjectBalance balance = balanceMapper.selectOne(
                new LambdaQueryWrapper<AccSubjectBalance>()
                    .eq(AccSubjectBalance::getPeriodCode, periodCode)
                    .eq(AccSubjectBalance::getSubjectCode, subject.getSubjectCode())
            );

            if (balance == null) {
                continue;
            }

            BigDecimal amount = balance.getPeriodDebit().subtract(balance.getPeriodCredit());
            if (amount.compareTo(BigDecimal.ZERO) == 0) {
                continue;
            }

            // 创建结转分录
            VoucherEntryDTO entry = new VoucherEntryDTO();
            entry.setSubjectCode(subject.getSubjectCode());
            entry.setSummary("期末结转-" + subject.getSubjectName());

            // 收入类科目(贷方余额):借记收入,贷记本年利润
            // 费用类科目(借方余额):借记本年利润,贷记费用
            if (subject.getSubjectCode().startsWith("6")) {
                // 6开头为收入类
                if (balance.getPeriodCredit().compareTo(balance.getPeriodDebit()) > 0) {
                    entry.setDebitAmount(balance.getPeriodCredit().subtract(balance.getPeriodDebit()));
                    totalIncome = totalIncome.add(entry.getDebitAmount());
                }
            } else {
                // 费用类
                if (balance.getPeriodDebit().compareTo(balance.getPeriodCredit()) > 0) {
                    entry.setCreditAmount(balance.getPeriodDebit().subtract(balance.getPeriodCredit()));
                    totalExpense = totalExpense.add(entry.getCreditAmount());
                }
            }

            if ((entry.getDebitAmount() != null && entry.getDebitAmount().compareTo(BigDecimal.ZERO) > 0) ||
                (entry.getCreditAmount() != null && entry.getCreditAmount().compareTo(BigDecimal.ZERO) > 0)) {
                entries.add(entry);
            }
        }

        if (entries.isEmpty()) {
            log.info("无损益发生额,跳过结转");
            return;
        }

        // 2. 添加本年利润分录
        BigDecimal profit = totalIncome.subtract(totalExpense);
        VoucherEntryDTO profitEntry = new VoucherEntryDTO();
        profitEntry.setSubjectCode(PROFIT_SUBJECT_CODE);
        profitEntry.setSummary("期末结转-本年利润");
        if (profit.compareTo(BigDecimal.ZERO) > 0) {
            profitEntry.setCreditAmount(profit);
        } else {
            profitEntry.setDebitAmount(profit.abs());
        }
        entries.add(profitEntry);

        // 3. 生成结转凭证
        int year = Integer.parseInt(periodCode.substring(0, 4));
        int month = Integer.parseInt(periodCode.substring(4, 6));
        LocalDate lastDay = LocalDate.of(year, month, 1).plusMonths(1).minusDays(1);

        CreateVoucherRequest request = new CreateVoucherRequest();
        request.setVoucherType("转");
        request.setVoucherDate(lastDay);
        request.setSummary("期末损益结转");
        request.setSourceType("AUTO_CARRYOVER");
        request.setEntries(entries);

        voucherService.createVoucher(operator, request);

        log.info("月末结转完成: periodCode={}, profit={}", periodCode, profit);
    }

    /**
     * 年末结转(本年利润结转利润分配)
     */
    @Transactional(rollbackFor = Exception.class)
    public void yearEndCarryover(String operator, String periodCode) {
        // 先执行月末结转
        monthEndCarryover(operator, periodCode);

        log.info("开始年末结转: periodCode={}", periodCode);

        // 查询本年利润余额
        AccSubjectBalance profitBalance = balanceMapper.selectOne(
            new LambdaQueryWrapper<AccSubjectBalance>()
                .eq(AccSubjectBalance::getPeriodCode, periodCode)
                .eq(AccSubjectBalance::getSubjectCode, PROFIT_SUBJECT_CODE)
        );

        if (profitBalance == null) {
            log.info("本年利润无余额,跳过年末结转");
            return;
        }

        BigDecimal yearProfit = profitBalance.getEndCredit().subtract(profitBalance.getEndDebit());
        if (yearProfit.compareTo(BigDecimal.ZERO) == 0) {
            return;
        }

        // 生成结转凭证
        List<VoucherEntryDTO> entries = new ArrayList<>();

        // 本年利润分录
        VoucherEntryDTO profitEntry = new VoucherEntryDTO();
        profitEntry.setSubjectCode(PROFIT_SUBJECT_CODE);
        profitEntry.setSummary("年末结转-本年利润");
        if (yearProfit.compareTo(BigDecimal.ZERO) > 0) {
            profitEntry.setDebitAmount(yearProfit);
        } else {
            profitEntry.setCreditAmount(yearProfit.abs());
        }
        entries.add(profitEntry);

        // 利润分配-未分配利润分录
        VoucherEntryDTO retainedEntry = new VoucherEntryDTO();
        retainedEntry.setSubjectCode(RETAINED_EARNINGS_CODE);
        retainedEntry.setSummary("年末结转-未分配利润");
        if (yearProfit.compareTo(BigDecimal.ZERO) > 0) {
            retainedEntry.setCreditAmount(yearProfit);
        } else {
            retainedEntry.setDebitAmount(yearProfit.abs());
        }
        entries.add(retainedEntry);

        int year = Integer.parseInt(periodCode.substring(0, 4));
        LocalDate lastDay = LocalDate.of(year, 12, 31);

        CreateVoucherRequest request = new CreateVoucherRequest();
        request.setVoucherType("转");
        request.setVoucherDate(lastDay);
        request.setSummary("年末利润结转");
        request.setSourceType("AUTO_CARRYOVER");
        request.setEntries(entries);

        voucherService.createVoucher(operator, request);

        log.info("年末结转完成: periodCode={}, yearProfit={}", periodCode, yearProfit);
    }

    /**
     * 删除结转凭证(反结账时调用)
     */
    public void deleteCarryoverVouchers(String periodCode) {
        // TODO: 删除自动生成的结转凭证
    }
}

九、账簿查询服务

9.1 账簿类型

diff 复制代码
+------------------+------------------+------------------+------------------+
|     总账         |     明细账       |     多栏账       |     序时账       |
+------------------+------------------+------------------+------------------+
| 按科目汇总       | 按科目逐笔       | 费用多栏明细     | 按日期顺序       |
| 显示余额         | 显示每笔分录     | 收入多栏明细     | 所有凭证分录     |
| 期初/本期/期末   | 摘要/借/贷/余额  | 自定义栏目       | 凭证号/摘要/金额 |
+------------------+------------------+------------------+------------------+

9.2 账簿查询服务

java 复制代码
package com.accounting.service.service;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.accounting.service.dto.*;
import com.accounting.service.entity.*;
import com.accounting.service.mapper.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;

/**
 * 账簿查询服务
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class LedgerService {

    private final AccVoucherMapper voucherMapper;
    private final AccVoucherEntryMapper entryMapper;
    private final AccSubjectBalanceMapper balanceMapper;
    private final SubjectService subjectService;

    /**
     * 查询总账
     */
    public List<GeneralLedgerDTO> queryGeneralLedger(String periodCode, String subjectCode) {
        List<GeneralLedgerDTO> result = new ArrayList<>();

        // 获取科目信息
        AccSubject subject = subjectService.getByCode(subjectCode);
        if (subject == null) {
            return result;
        }

        // 查询指定期间及之前的余额
        List<AccSubjectBalance> balances = balanceMapper.selectList(
            new LambdaQueryWrapper<AccSubjectBalance>()
                .eq(AccSubjectBalance::getSubjectCode, subjectCode)
                .le(AccSubjectBalance::getPeriodCode, periodCode)
                .orderByAsc(AccSubjectBalance::getPeriodCode)
        );

        for (AccSubjectBalance balance : balances) {
            GeneralLedgerDTO dto = new GeneralLedgerDTO();
            dto.setPeriodCode(balance.getPeriodCode());
            dto.setSubjectCode(subjectCode);
            dto.setSubjectName(subject.getSubjectName());
            dto.setBeginDebit(balance.getBeginDebit());
            dto.setBeginCredit(balance.getBeginCredit());
            dto.setPeriodDebit(balance.getPeriodDebit());
            dto.setPeriodCredit(balance.getPeriodCredit());
            dto.setEndDebit(balance.getEndDebit());
            dto.setEndCredit(balance.getEndCredit());

            // 计算余额
            dto.setBalance(balance.getEndDebit().subtract(balance.getEndCredit()));
            dto.setBalanceDirection(dto.getBalance().compareTo(BigDecimal.ZERO) >= 0 ? "借" : "贷");

            result.add(dto);
        }

        return result;
    }

    /**
     * 查询明细账
     */
    public List<DetailLedgerDTO> queryDetailLedger(String startPeriod, String endPeriod,
                                                    String subjectCode) {
        List<DetailLedgerDTO> result = new ArrayList<>();

        // 获取期初余额
        AccSubjectBalance beginBalance = balanceMapper.selectOne(
            new LambdaQueryWrapper<AccSubjectBalance>()
                .eq(AccSubjectBalance::getSubjectCode, subjectCode)
                .eq(AccSubjectBalance::getPeriodCode, startPeriod)
        );

        BigDecimal runningBalance = BigDecimal.ZERO;
        if (beginBalance != null) {
            runningBalance = beginBalance.getBeginDebit().subtract(beginBalance.getBeginCredit());

            // 添加期初行
            DetailLedgerDTO beginRow = new DetailLedgerDTO();
            beginRow.setSummary("期初余额");
            beginRow.setBalance(runningBalance);
            beginRow.setBalanceDirection(runningBalance.compareTo(BigDecimal.ZERO) >= 0 ? "借" : "贷");
            result.add(beginRow);
        }

        // 查询分录明细
        List<AccVoucherEntry> entries = entryMapper.selectEntriesBySubjectAndPeriod(
            subjectCode, startPeriod, endPeriod);

        for (AccVoucherEntry entry : entries) {
            DetailLedgerDTO dto = new DetailLedgerDTO();
            dto.setVoucherNo(entry.getVoucherNo());
            dto.setSummary(entry.getSummary());
            dto.setDebitAmount(entry.getDebitAmount());
            dto.setCreditAmount(entry.getCreditAmount());

            // 计算滚动余额
            runningBalance = runningBalance.add(entry.getDebitAmount())
                .subtract(entry.getCreditAmount());
            dto.setBalance(runningBalance.abs());
            dto.setBalanceDirection(runningBalance.compareTo(BigDecimal.ZERO) >= 0 ? "借" : "贷");

            result.add(dto);
        }

        return result;
    }

    /**
     * 查询序时账(日记账)
     */
    public List<JournalDTO> queryJournal(String startDate, String endDate) {
        List<JournalDTO> result = new ArrayList<>();

        // 查询日期范围内的所有已过账凭证
        List<AccVoucher> vouchers = voucherMapper.selectList(
            new LambdaQueryWrapper<AccVoucher>()
                .ge(AccVoucher::getVoucherDate, startDate)
                .le(AccVoucher::getVoucherDate, endDate)
                .eq(AccVoucher::getStatus, 3)  // 已过账
                .orderByAsc(AccVoucher::getVoucherDate)
                .orderByAsc(AccVoucher::getVoucherNo)
        );

        for (AccVoucher voucher : vouchers) {
            List<AccVoucherEntry> entries = entryMapper.selectList(
                new LambdaQueryWrapper<AccVoucherEntry>()
                    .eq(AccVoucherEntry::getVoucherId, voucher.getId())
                    .orderByAsc(AccVoucherEntry::getLineNo)
            );

            for (AccVoucherEntry entry : entries) {
                JournalDTO dto = new JournalDTO();
                dto.setVoucherDate(voucher.getVoucherDate());
                dto.setVoucherNo(voucher.getVoucherNo());
                dto.setSubjectCode(entry.getSubjectCode());
                dto.setSubjectName(entry.getSubjectName());
                dto.setSummary(entry.getSummary());
                dto.setDebitAmount(entry.getDebitAmount());
                dto.setCreditAmount(entry.getCreditAmount());

                result.add(dto);
            }
        }

        return result;
    }
}

十、接口层实现

10.1 Controller

java 复制代码
package com.accounting.service.controller;

import com.accounting.common.result.Result;
import com.accounting.service.dto.*;
import com.accounting.service.entity.*;
import com.accounting.service.service.*;
import com.accounting.service.utils.UserContext;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;

import javax.validation.Valid;
import java.util.List;

/**
 * 凭证接口
 */
@Slf4j
@RestController
@RequestMapping("/voucher")
@RequiredArgsConstructor
public class VoucherController {

    private final VoucherService voucherService;

    /**
     * 创建凭证
     */
    @PostMapping("/create")
    public Result<AccVoucher> createVoucher(@Valid @RequestBody CreateVoucherRequest request) {
        String operator = UserContext.getCurrentUser();
        AccVoucher voucher = voucherService.createVoucher(operator, request);
        return Result.success(voucher);
    }

    /**
     * 审核凭证
     */
    @PostMapping("/audit/{voucherId}")
    public Result<Void> auditVoucher(@PathVariable Long voucherId) {
        String operator = UserContext.getCurrentUser();
        voucherService.auditVoucher(operator, voucherId);
        return Result.success();
    }

    /**
     * 弃审凭证
     */
    @PostMapping("/unaudit/{voucherId}")
    public Result<Void> unauditVoucher(@PathVariable Long voucherId) {
        String operator = UserContext.getCurrentUser();
        voucherService.unauditVoucher(operator, voucherId);
        return Result.success();
    }

    /**
     * 过账凭证
     */
    @PostMapping("/post/{voucherId}")
    public Result<Void> postVoucher(@PathVariable Long voucherId) {
        String operator = UserContext.getCurrentUser();
        voucherService.postVoucher(operator, voucherId);
        return Result.success();
    }

    /**
     * 作废凭证
     */
    @PostMapping("/void/{voucherId}")
    public Result<Void> voidVoucher(@PathVariable Long voucherId,
                                     @RequestParam String reason) {
        String operator = UserContext.getCurrentUser();
        voucherService.voidVoucher(operator, voucherId, reason);
        return Result.success();
    }

    /**
     * 查询凭证详情
     */
    @GetMapping("/{voucherId}")
    public Result<AccVoucher> getVoucherDetail(@PathVariable Long voucherId) {
        AccVoucher voucher = voucherService.getVoucherDetail(voucherId);
        return Result.success(voucher);
    }

    /**
     * 查询凭证列表
     */
    @GetMapping("/list")
    public Result<List<AccVoucher>> getVoucherList(
            @RequestParam String periodCode,
            @RequestParam(required = false) Integer status) {
        // TODO: 实现分页查询
        return Result.success(null);
    }
}
java 复制代码
package com.accounting.service.controller;

import com.accounting.common.result.Result;
import com.accounting.service.dto.*;
import com.accounting.service.service.*;
import com.accounting.service.utils.UserContext;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;

import java.util.List;

/**
 * 账簿查询接口
 */
@Slf4j
@RestController
@RequestMapping("/ledger")
@RequiredArgsConstructor
public class LedgerController {

    private final LedgerService ledgerService;
    private final BalanceService balanceService;

    /**
     * 查询总账
     */
    @GetMapping("/general")
    public Result<List<GeneralLedgerDTO>> queryGeneralLedger(
            @RequestParam String periodCode,
            @RequestParam String subjectCode) {
        List<GeneralLedgerDTO> result = ledgerService.queryGeneralLedger(periodCode, subjectCode);
        return Result.success(result);
    }

    /**
     * 查询明细账
     */
    @GetMapping("/detail")
    public Result<List<DetailLedgerDTO>> queryDetailLedger(
            @RequestParam String startPeriod,
            @RequestParam String endPeriod,
            @RequestParam String subjectCode) {
        List<DetailLedgerDTO> result = ledgerService.queryDetailLedger(
            startPeriod, endPeriod, subjectCode);
        return Result.success(result);
    }

    /**
     * 查询序时账
     */
    @GetMapping("/journal")
    public Result<List<JournalDTO>> queryJournal(
            @RequestParam String startDate,
            @RequestParam String endDate) {
        List<JournalDTO> result = ledgerService.queryJournal(startDate, endDate);
        return Result.success(result);
    }

    /**
     * 试算平衡表
     */
    @GetMapping("/trial-balance")
    public Result<TrialBalanceResult> getTrialBalance(@RequestParam String periodCode) {
        TrialBalanceResult result = balanceService.checkTrialBalance(periodCode);
        return Result.success(result);
    }

    /**
     * 科目余额表
     */
    @GetMapping("/balance")
    public Result<List<AccSubjectBalance>> getSubjectBalance(@RequestParam String periodCode) {
        List<AccSubjectBalance> result = balanceService.getBalancesByPeriod(periodCode);
        return Result.success(result);
    }
}

十一、配置文件

11.1 application.yml

yaml 复制代码
server:
  port: 8083

spring:
  application:
    name: accounting-service

  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/accounting?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
    username: root
    password: root
    hikari:
      minimum-idle: 10
      maximum-pool-size: 50
      connection-timeout: 30000

  redis:
    host: localhost
    port: 6379
    database: 0
    lettuce:
      pool:
        max-active: 50
        max-idle: 20
        min-idle: 5

  cache:
    type: redis
    redis:
      time-to-live: 3600000

mybatis-plus:
  mapper-locations: classpath:mapper/*.xml
  global-config:
    db-config:
      id-type: auto
  configuration:
    map-underscore-to-camel-case: true
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

logging:
  level:
    com.accounting: debug
  pattern:
    console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n"

# 核算配置
accounting:
  # 凭证自动过账
  auto-post: false
  # 是否强制审核
  force-audit: true
  # 科目编码规则(级次长度)
  subject-code-rule: "4-2-2-2"

十二、总结

12.1 核心技术点

diff 复制代码
+------------------+------------------+----------------------------------+
|      模块        |      技术        |             说明                 |
+------------------+------------------+----------------------------------+
|     科目管理     |  树形结构+缓存   |   支持多级科目,Redis缓存        |
+------------------+------------------+----------------------------------+
|     凭证管理     |  状态机+事务     |   严格的状态流转控制              |
+------------------+------------------+----------------------------------+
|     余额管理     |  级联更新        |   分录过账时级联更新上级科目      |
+------------------+------------------+----------------------------------+
|     期间管理     |  结账流程        |   结账检查+自动结转               |
+------------------+------------------+----------------------------------+
|     审计追踪     |  操作日志        |   所有操作记录可追溯              |
+------------------+------------------+----------------------------------+

12.2 关键设计要点

  1. 借贷平衡:凭证录入时强制校验借贷平衡
  2. 状态流转:凭证状态严格按流程流转
  3. 期间控制:只能在已启用期间操作
  4. 权限分离:制单人不能审核自己的凭证
  5. 余额级联:分录过账时自动更新上级科目余额
  6. 审计追踪:所有关键操作记录日志

12.3 扩展方向

  1. 多账套:支持多个独立账套
  2. 多币种:支持外币核算和汇兑损益
  3. 辅助核算:完善部门/项目/往来等核算
  4. 报表中心:资产负债表、利润表、现金流量表
  5. 凭证模板:常用业务凭证模板
  6. 接口对接:与ERP、费控等系统对接

相关推荐
期待のcode1 小时前
Springboot数据层开发
java·spring boot·后端
上78将1 小时前
JVM回收垃圾机制
java·开发语言·jvm
Evan芙1 小时前
shell编程求10个随机数的最大值与最小值
java·linux·前端·javascript·网络
BD_Marathon1 小时前
【IDEA】IDEA的详细设置
java·ide·intellij-idea
未来coding1 小时前
Spring AI ChatModel API 详解【基于官方文档】
java·后端·spring
忘记9261 小时前
重复注解的机制是什么
java
喜欢流萤吖~1 小时前
Servlet 生命周期详解
java·servlet
刘一说1 小时前
JDK 25新纪元:技术革新与老项目迁移的冷思考
java·开发语言
小帅学编程1 小时前
Java基础
java·开发语言