zss
2 天以前 1942fb9f1a4dd6a90daca24e3a093c138e0e48c3
Merge remote-tracking branch 'origin/dev_New_pro' into dev_New_pro
已添加37个文件
已修改9个文件
3111 ■■■■■ 文件已修改
doc/20260512_AccountSubject树形改造前端修改文档.md 185 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/20260512_add_parent_id_to_account_subject.sql 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/20260512_create_financial_management_tables.sql 104 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/20260512_财务管理模块前端联调文档.md 288 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/bean/dto/financial/FinDetailLedgerQueryDto.java 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/bean/dto/financial/FinFixedAssetDto.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/bean/dto/financial/FinIdBatchDto.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/bean/dto/financial/FinIntangibleAssetDto.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/bean/dto/financial/FinLedgerQueryDto.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/bean/dto/financial/FinVoucherDto.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/bean/dto/financial/FinVoucherEntryDto.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/bean/dto/financial/FinVoucherPageDto.java 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/bean/dto/financial/FinVoucherStatusDto.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/bean/vo/AccountSubjectVo.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/bean/vo/financial/FinLedgerEntryRecordVo.java 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/bean/vo/financial/FinLedgerRowVo.java 53 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/bean/vo/financial/FinVoucherDetailVo.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/controller/AccountSubjectController.java 8 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/controller/financial/FinFixedAssetController.java 63 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/controller/financial/FinIntangibleAssetController.java 63 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/controller/financial/FinLedgerController.java 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/controller/financial/FinVoucherController.java 69 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/mapper/AccountSubjectMapper.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/mapper/financial/FinFixedAssetMapper.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/mapper/financial/FinIntangibleAssetMapper.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/mapper/financial/FinVoucherEntryMapper.java 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/mapper/financial/FinVoucherMapper.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/pojo/AccountSubject.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/pojo/financial/FinFixedAsset.java 101 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/pojo/financial/FinIntangibleAsset.java 98 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/pojo/financial/FinVoucher.java 83 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/pojo/financial/FinVoucherEntry.java 85 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/service/AccountSubjectService.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/service/financial/FinFixedAssetService.java 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/service/financial/FinIntangibleAssetService.java 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/service/financial/FinLedgerService.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/service/financial/FinVoucherService.java 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/service/impl/AccountSubjectServiceImpl.java 362 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/service/impl/financial/FinFixedAssetServiceImpl.java 231 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/service/impl/financial/FinIntangibleAssetServiceImpl.java 250 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/service/impl/financial/FinLedgerServiceImpl.java 206 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/service/impl/financial/FinVoucherServiceImpl.java 299 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/technology/service/impl/TechnologyBomServiceImpl.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/application-dev.yml 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/account/AccountSubjectMapper.xml 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/account/financial/FinVoucherEntryMapper.xml 74 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/20260512_AccountSubjectÊ÷ÐθÄÔìǰ¶ËÐÞ¸ÄÎĵµ.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,185 @@
# AccountSubject æ ‘形改造前端修改文档
更新时间:2026-05-12
## 1. å˜æ›´èƒŒæ™¯
`AccountSubjectController` å·²æ”¹ä¸ºçˆ¶å­å±‚级递归模式,`/accountSubject/list` çŽ°åœ¨è¿”å›žæ ‘å½¢ç»“æž„ï¼ˆ`children` é€’归),不再是单纯的平铺列表。
---
## 2. åŽç«¯æŽ¥å£å˜åŒ–
### 2.1 æŸ¥è¯¢æŽ¥å£ï¼ˆå·²å˜æ›´ä¸ºæ ‘)
- URL:`GET /accountSubject/list`
- å…¥å‚:保持不变(`current,size,subjectCode,subjectName,subjectType,status`)
- å‡ºå‚:仍是分页壳(`records,total`),但 `records` å˜ä¸ºæ ‘节点数组(根节点分页,子节点递归内嵌)
示例:
```json
{
  "code": 200,
  "msg": "操作成功",
  "data": {
    "records": [
      {
        "id": 1,
        "parentId": null,
        "subjectCode": "1002",
        "subjectName": "银行存款",
        "subjectType": "资产类",
        "balanceDirection": "借方",
        "status": 0,
        "leaf": false,
        "children": [
          {
            "id": 2,
            "parentId": 1,
            "subjectCode": "100201",
            "subjectName": "工行存款",
            "subjectType": "资产类",
            "balanceDirection": "借方",
            "status": 0,
            "leaf": true,
            "children": []
          }
        ]
      }
    ],
    "total": 1
  }
}
```
### 2.2 æ–°å¢ž/编辑接口字段变化
- `POST /accountSubject/add`
- `PUT /accountSubject/edit`
新增支持字段:
- `parentId`:父节点ID(为空表示根节点)
示例:
```json
{
  "id": 2,
  "parentId": 1,
  "subjectCode": "100201",
  "subjectName": "工行存款",
  "subjectType": "资产类",
  "balanceDirection": "借方",
  "status": 0,
  "remark": ""
}
```
### 2.3 åˆ é™¤æŽ¥å£è¡Œä¸ºå˜åŒ–
- `DELETE /accountSubject/remove/{ids}`
行为:
1. åˆ é™¤çˆ¶èŠ‚ç‚¹æ—¶ä¼šé€’å½’åˆ é™¤æ‰€æœ‰å­å­™èŠ‚ç‚¹ã€‚
2. è‹¥ä»»æ„å¾…删除节点(含子孙)已被 `fin_voucher_entry.subject_code` å¼•用,则整体删除失败。
---
## 3. å‰ç«¯æ”¹é€ æ¸…单
## 3.1 æ€»è´¦ç§‘目管理页
文件:`src/views/financialManagement/generalLedger/index.vue`
### å¿…改项
1. æ–°å¢žâ€œçˆ¶ç§‘目”选择控件(`el-cascader` æˆ– `el-tree-select`),保存时带 `parentId`。
2. åˆ—表改为树表展示(推荐 `el-table` + `row-key` + `tree-props`)。
3. æœç´¢é€»è¾‘保持不变,但要兼容 `records` ä¸ºæ ‘结构。
---
## 3.2 å‡­è¯é¡µç§‘目下拉
文件:`src/views/financialManagement/voucher/index.vue`
当前凭证分录使用 `el-select`(平铺)。
后端已返回树,需要前端扁平化后再绑定下拉。
示例(可复用):
```js
const flattenSubjectTree = (nodes, result = []) => {
  (nodes || []).forEach(node => {
    result.push({
      code: node.subjectCode,
      name: node.subjectName,
      id: node.id,
      parentId: node.parentId
    });
    if (node.children?.length) {
      flattenSubjectTree(node.children, result);
    }
  });
  return result;
};
// list æŽ¥å£è¿”回后:
const treeRecords = response.data?.records || [];
subjectList.value = flattenSubjectTree(treeRecords);
```
---
## 3.3 ç§‘目总账/明细账页级联
文件:
- `src/views/financialManagement/voucher/generalLedger.vue`
- `src/views/financialManagement/voucher/detailLedger.vue`
当前逻辑是把 `records` å¼ºåˆ¶æ˜ å°„成 `children: []`,需要删除这段“平铺改造”,直接使用后端树。
示例(可复用):
```js
const toCascaderTree = (nodes = []) =>
  nodes
    .filter(item => item.subjectCode && item.subjectName)
    .map(item => ({
      code: item.subjectCode,
      name: item.subjectName,
      children: toCascaderTree(item.children || [])
    }));
subjectOptions.value = toCascaderTree(response.data?.records || []);
```
---
## 4. å»ºè®®çš„前端字段约定
建议在前端 `form` å¢žåŠ ï¼š
- `parentId: null`
并在编辑回填时保持 `parentId`。
---
## 5. è”调注意事项
1. `/accountSubject/list` çš„ `total` æ˜¯æ ¹èŠ‚ç‚¹æ•°é‡ï¼Œä¸æ˜¯å…¨é‡èŠ‚ç‚¹æ•°ã€‚
2. è‹¥é¡µé¢ä»æŒ‰å¹³é“º `records.map(...)` å¤„理,会丢失子节点。
3. åˆ é™¤ç§‘目失败时,优先检查是否被凭证分录引用。
4. ä¿å­˜å¤±è´¥å‡ºçŽ°â€œçˆ¶ç§‘ç›®ä¸èƒ½æ˜¯å½“å‰ç§‘ç›®æˆ–å…¶å­ç§‘ç›®â€æ—¶ï¼Œéœ€è¦å‰ç«¯é™åˆ¶çˆ¶èŠ‚ç‚¹å¯é€‰èŒƒå›´ã€‚
---
## 6. æ•°æ®åº“字段要求
`account_subject` éœ€è¦åŒ…含 `parent_id` å­—段(`bigint`,可空)。
若线上库尚未添加,请先执行 DDL å†è”调。
doc/20260512_add_parent_id_to_account_subject.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,5 @@
-- account_subject å¢žåŠ çˆ¶çº§ç§‘ç›®å­—æ®µï¼ˆæ ‘å½¢ç»“æž„ï¼‰
ALTER TABLE `account_subject`
ADD COLUMN `parent_id` bigint NULL COMMENT '父科目ID(为空表示根节点)' AFTER `id`;
CREATE INDEX `idx_account_subject_parent_id` ON `account_subject` (`parent_id`);
doc/20260512_create_financial_management_tables.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,104 @@
-- è´¢åŠ¡ç®¡ç†æ¨¡å—å»ºè¡¨è„šæœ¬ï¼ˆå›ºå®šèµ„äº§/无形资产/凭证/科目账)
-- è¯´æ˜Žï¼š
-- 1) æ€»è´¦ç§‘目继续复用已有表 account_subject,不重复创建 fin_account_subject。
-- 2) é‡‘额字段统一 decimal(18,2)。
CREATE TABLE IF NOT EXISTS `fin_fixed_asset` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  `asset_code` varchar(64) NOT NULL COMMENT '资产编号',
  `asset_name` varchar(128) NOT NULL COMMENT '资产名称',
  `category` varchar(64) NOT NULL COMMENT '资产类别',
  `specification` varchar(255) DEFAULT NULL COMMENT '规格型号',
  `purchase_date` date NOT NULL COMMENT '购置日期',
  `original_value` decimal(18,2) NOT NULL DEFAULT '0.00' COMMENT '资产原值',
  `useful_life` int NOT NULL DEFAULT '1' COMMENT '使用年限(å¹´)',
  `residual_rate` decimal(18,2) NOT NULL DEFAULT '0.00' COMMENT '残值率(%)',
  `accumulated_depreciation` decimal(18,2) NOT NULL DEFAULT '0.00' COMMENT '累计折旧',
  `net_value` decimal(18,2) NOT NULL DEFAULT '0.00' COMMENT '净值',
  `location` varchar(255) DEFAULT NULL COMMENT '存放地点',
  `department` varchar(128) DEFAULT NULL COMMENT '使用部门',
  `keeper` varchar(64) DEFAULT NULL COMMENT '保管人',
  `status` varchar(32) NOT NULL DEFAULT 'in_use' COMMENT '状态: in_use/idle/repair/scrapped',
  `remark` varchar(500) DEFAULT NULL COMMENT '备注',
  `create_user` varchar(64) DEFAULT NULL COMMENT '创建人',
  `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_user` varchar(64) DEFAULT NULL COMMENT '修改人',
  `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
  `dept_id` bigint DEFAULT NULL COMMENT '部门ID',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_fin_fixed_asset_code` (`asset_code`),
  KEY `idx_fin_fixed_asset_status` (`status`),
  KEY `idx_fin_fixed_asset_category` (`category`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='固定资产';
CREATE TABLE IF NOT EXISTS `fin_intangible_asset` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  `asset_code` varchar(64) NOT NULL COMMENT '资产编号',
  `asset_name` varchar(128) NOT NULL COMMENT '资产名称',
  `category` varchar(64) NOT NULL COMMENT '资产类别',
  `certificate_no` varchar(128) DEFAULT NULL COMMENT '证书编号',
  `acquisition_date` date NOT NULL COMMENT '取得日期',
  `original_value` decimal(18,2) NOT NULL DEFAULT '0.00' COMMENT '资产原值',
  `amortization_period` int NOT NULL DEFAULT '1' COMMENT '摊销年限(å¹´)',
  `residual_rate` decimal(18,2) NOT NULL DEFAULT '0.00' COMMENT '残值率(%)',
  `accumulated_amortization` decimal(18,2) NOT NULL DEFAULT '0.00' COMMENT '累计摊销',
  `net_value` decimal(18,2) NOT NULL DEFAULT '0.00' COMMENT '净值',
  `validity_date` date DEFAULT NULL COMMENT '有效期至',
  `status` varchar(32) NOT NULL DEFAULT 'in_use' COMMENT '状态: in_use/expired/amortized',
  `description` varchar(1000) DEFAULT NULL COMMENT '资产描述',
  `remark` varchar(500) DEFAULT NULL COMMENT '备注',
  `create_user` varchar(64) DEFAULT NULL COMMENT '创建人',
  `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_user` varchar(64) DEFAULT NULL COMMENT '修改人',
  `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
  `dept_id` bigint DEFAULT NULL COMMENT '部门ID',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_fin_intangible_asset_code` (`asset_code`),
  KEY `idx_fin_intangible_asset_status` (`status`),
  KEY `idx_fin_intangible_asset_category` (`category`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='无形资产';
CREATE TABLE IF NOT EXISTS `fin_voucher` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  `voucher_no` varchar(64) NOT NULL COMMENT '凭证字号',
  `voucher_date` date NOT NULL COMMENT '凭证日期',
  `summary` varchar(500) DEFAULT NULL COMMENT '摘要',
  `debit` decimal(18,2) NOT NULL DEFAULT '0.00' COMMENT '借方合计',
  `credit` decimal(18,2) NOT NULL DEFAULT '0.00' COMMENT '贷方合计',
  `creator` varchar(64) DEFAULT NULL COMMENT '制单人',
  `status` varchar(32) NOT NULL DEFAULT 'unposted' COMMENT '状态: unposted/posted/cancelled',
  `attachment_count` int NOT NULL DEFAULT '0' COMMENT '附件张数',
  `remark` varchar(500) DEFAULT NULL COMMENT '备注',
  `create_user` varchar(64) DEFAULT NULL COMMENT '创建人',
  `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_user` varchar(64) DEFAULT NULL COMMENT '修改人',
  `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
  `dept_id` bigint DEFAULT NULL COMMENT '部门ID',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_fin_voucher_no` (`voucher_no`),
  KEY `idx_fin_voucher_date` (`voucher_date`),
  KEY `idx_fin_voucher_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='凭证主表';
CREATE TABLE IF NOT EXISTS `fin_voucher_entry` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  `voucher_id` bigint NOT NULL COMMENT '凭证ID',
  `row_no` int NOT NULL DEFAULT '1' COMMENT '行号',
  `subject_code` varchar(64) NOT NULL COMMENT '科目编码',
  `subject_name` varchar(128) DEFAULT NULL COMMENT '科目名称',
  `summary` varchar(500) DEFAULT NULL COMMENT '摘要',
  `debit` decimal(18,2) NOT NULL DEFAULT '0.00' COMMENT '借方金额',
  `credit` decimal(18,2) NOT NULL DEFAULT '0.00' COMMENT '贷方金额',
  `auxiliary_type` varchar(32) DEFAULT NULL COMMENT '辅助核算类型',
  `auxiliary_id` varchar(64) DEFAULT NULL COMMENT '辅助核算对象ID',
  `auxiliary_name` varchar(128) DEFAULT NULL COMMENT '辅助核算对象名称',
  `create_user` varchar(64) DEFAULT NULL COMMENT '创建人',
  `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_user` varchar(64) DEFAULT NULL COMMENT '修改人',
  `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
  `dept_id` bigint DEFAULT NULL COMMENT '部门ID',
  PRIMARY KEY (`id`),
  KEY `idx_fin_voucher_entry_voucher` (`voucher_id`),
  KEY `idx_fin_voucher_entry_subject` (`subject_code`),
  KEY `idx_fin_voucher_entry_aux` (`auxiliary_type`, `auxiliary_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='凭证分录';
doc/20260512_²ÆÎñ¹ÜÀíÄ£¿éǰ¶ËÁªµ÷Îĵµ.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,288 @@
# è´¢åŠ¡ç®¡ç†æ¨¡å—å‰ç«¯è”è°ƒæ–‡æ¡£ï¼ˆaccount æ¨¡å—)
更新时间:2026-05-12
## 1. é€šç”¨è¯´æ˜Ž
### 1.1 å“åº”结构
成功响应:
```json
{
  "code": 200,
  "msg": "操作成功",
  "data": {}
}
```
业务校验失败(例如借贷不平衡、必填缺失)由全局异常返回:
```json
{
  "code": 500,
  "msg": "错误信息",
  "data": null
}
```
### 1.2 åˆ†é¡µç»“æž„
分页接口统一使用 MyBatis-Plus `Page`:
- è¯·æ±‚参数:`current`、`size`
- è¿”回:`data.records`、`data.total`
---
## 2. æ€»è´¦ç§‘目(已在原接口上增强校验)
接口保持不变:
- `GET /accountSubject/list`
- `POST /accountSubject/add`
- `PUT /accountSubject/edit`
- `DELETE /accountSubject/remove/{ids}`
- `POST /accountSubject/export`
新增规则:
1. `subjectCode`、`subjectName`、`subjectType` å¿…填。
2. `subjectCode` å”¯ä¸€æ ¡éªŒã€‚
3. åˆ é™¤å‰åšå¼•用校验:若被凭证分录(`fin_voucher_entry.subject_code`)引用,禁止删除。
---
## 3. å›ºå®šèµ„产
Base URL:`/financial/fixedAsset`
### 3.1 åˆ†é¡µæŸ¥è¯¢
- `GET /page`
- Query:`current,size,assetCode,assetName,category,status`
### 3.2 æ–°å¢ž
- `POST /add`
- Body(JSON):
```json
{
  "assetCode": "GD20260512001",
  "assetName": "办公电脑",
  "category": "electronic",
  "specification": "ThinkPad X1",
  "purchaseDate": "2026-05-01",
  "originalValue": 8000.00,
  "usefulLife": 5,
  "residualRate": 5.00,
  "location": "财务部",
  "department": "财务部",
  "keeper": "张三",
  "status": "in_use",
  "remark": "示例"
}
```
### 3.3 ä¿®æ”¹
- `PUT /update`
- Body:同新增,需包含 `id`
### 3.4 åˆ é™¤
- `DELETE /delete?ids=1&ids=2`
### 3.5 æŠ˜æ—§è®¡æï¼ˆæŒ‰æœˆï¼‰
- `POST /depreciate`
- Body å¯é€‰ï¼š
  - å…¨éƒ¨åœ¨ç”¨èµ„产:`{}`
  - æŒ‡å®šèµ„产:`{"ids":[1,2,3]}`
核心公式:
- `monthlyDepreciation = originalValue * (1 - residualRate/100) / (usefulLife*12)`
- `accumulatedDepreciation += monthlyDepreciation`
- `netValue = originalValue - accumulatedDepreciation`
状态建议值:
- `in_use`、`idle`、`repair`、`scrapped`
---
## 4. æ— å½¢èµ„产
Base URL:`/financial/intangibleAsset`
### 4.1 åˆ†é¡µæŸ¥è¯¢
- `GET /page`
- Query:`current,size,assetCode,assetName,category,status`
### 4.2 æ–°å¢ž
- `POST /add`
```json
{
  "assetCode": "WX20260512001",
  "assetName": "ERP软件许可",
  "category": "software",
  "certificateNo": "SW-001",
  "acquisitionDate": "2026-05-01",
  "originalValue": 50000.00,
  "amortizationPeriod": 10,
  "residualRate": 0.00,
  "validityDate": "2036-05-01",
  "status": "in_use",
  "description": "示例",
  "remark": ""
}
```
### 4.3 ä¿®æ”¹
- `PUT /update`
- Body:同新增,需包含 `id`
### 4.4 åˆ é™¤
- `DELETE /delete?ids=1&ids=2`
### 4.5 æ‘Šé”€è®¡æï¼ˆæŒ‰æœˆï¼‰
- `POST /amortize`
- Body å¯é€‰ï¼š
  - å…¨éƒ¨åœ¨ç”¨èµ„产:`{}`
  - æŒ‡å®šèµ„产:`{"ids":[1,2,3]}`
核心公式:
- `monthlyAmortization = originalValue * (1 - residualRate/100) / (amortizationPeriod*12)`
- `accumulatedAmortization += monthlyAmortization`
- `netValue = originalValue - accumulatedAmortization`
- å½“ `netValue <= 0` æ—¶ï¼š`netValue=0` ä¸” `status=amortized`
状态建议值:
- `in_use`、`expired`、`amortized`
---
## 5. å‡­è¯
Base URL:`/financial/voucher`
### 5.1 åˆ†é¡µæŸ¥è¯¢
- `GET /page`
- Query:`current,size,voucherNo,creator,status,startDate,endDate`
### 5.2 æ–°å¢ž
- `POST /add`
```json
{
  "voucherNo": "è®°-0001",
  "voucherDate": "2026-05-12",
  "summary": "销售收入",
  "creator": "张三",
  "attachmentCount": 0,
  "remark": "",
  "entries": [
    {
      "subjectCode": "1002",
      "subjectName": "银行存款",
      "summary": "收到货款",
      "debit": 1000.00,
      "credit": 0
    },
    {
      "subjectCode": "6001",
      "subjectName": "主营业务收入",
      "summary": "确认收入",
      "debit": 0,
      "credit": 1000.00
    }
  ]
}
```
### 5.3 ä¿®æ”¹
- `PUT /update`
- Body:同新增,需包含 `id`
- ä»… `unposted` çŠ¶æ€å…è®¸ä¿®æ”¹
### 5.4 è¿‡è´¦
- `POST /post`
```json
{
  "id": 1
}
```
### 5.5 ä½œåºŸ
- `POST /cancel`
```json
{
  "id": 1
}
```
### 5.6 è¯¦æƒ…
- `GET /detail/{id}`
关键校验:
1. åˆ†å½•至少一条有效行(科目不空,且借方或贷方 > 0)。
2. æ¯æ¡æœ‰æ•ˆåˆ†å½•不能借贷同时大于 0。
3. å€Ÿè´·å¹³è¡¡ï¼š`sum(debit) == sum(credit)` ä¸”都 > 0。
4. `subjectCode` å¿…须存在于 `account_subject`。
状态流转:
- `unposted -> posted`
- `unposted -> cancelled`
---
## 6. ç§‘目总账
### 6.1 æŸ¥è¯¢æŽ¥å£
- `GET /financial/ledger/general`
### 6.2 Query å‚æ•°
- `subjectCode`(必填)
- `startMonth`(YYYY-MM)
- `endMonth`(YYYY-MM)
### 6.3 è¿”回字段
- `rowType`:`opening` / `entry` / `monthly_total` / `yearly_total`
- `date`
- `voucherNo`
- `summary`
- `debit`
- `credit`
- `direction`(借/贷)
- `balance`(借正贷负)
说明:
- ç§‘目支持“指定科目及其下级(前缀匹配)”查询。
- è‡ªåŠ¨è¾“å‡ºâ€œæœŸåˆä½™é¢ / æœ¬æœˆåˆè®¡ / æœ¬å¹´ç´¯è®¡â€ã€‚
---
## 7. ç§‘目明细账
### 7.1 æŸ¥è¯¢æŽ¥å£
- `GET /financial/ledger/detail`
### 7.2 Query å‚æ•°
- `subjectCode`(必填)
- `auxiliaryType`(可选:`customer/supplier/department/employee/project`)
- `auxiliaryId`(可选)
- `startMonth`(YYYY-MM)
- `endMonth`(YYYY-MM)
### 7.3 è¿”回字段
同科目总账:
- `rowType,date,voucherNo,summary,debit,credit,direction,balance`
---
## 8. æ•°æ®åº“脚本
已提供脚本:
- `doc/20260512_create_financial_management_tables.sql`
包含:
- `fin_fixed_asset`
- `fin_intangible_asset`
- `fin_voucher`
- `fin_voucher_entry`
总账科目复用现有 `account_subject`。
src/main/java/com/ruoyi/account/bean/dto/financial/FinDetailLedgerQueryDto.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,22 @@
package com.ruoyi.account.bean.dto.financial;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
 * ç§‘目明细账查询参数(含辅助核算条件)。
 */
@EqualsAndHashCode(callSuper = true)
@Data
public class FinDetailLedgerQueryDto extends FinLedgerQueryDto {
    /**
     * è¾…助核算类型:customer/supplier/department/employee/project。
     */
    private String auxiliaryType;
    /**
     * è¾…助核算对象ID。
     */
    private String auxiliaryId;
}
src/main/java/com/ruoyi/account/bean/dto/financial/FinFixedAssetDto.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,13 @@
package com.ruoyi.account.bean.dto.financial;
import com.ruoyi.account.pojo.financial.FinFixedAsset;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
 * å›ºå®šèµ„产查询与保存 DTO。
 */
@EqualsAndHashCode(callSuper = true)
@Data
public class FinFixedAssetDto extends FinFixedAsset {
}
src/main/java/com/ruoyi/account/bean/dto/financial/FinIdBatchDto.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,17 @@
package com.ruoyi.account.bean.dto.financial;
import lombok.Data;
import java.util.List;
/**
 * æ‰¹é‡ID请求参数。
 */
@Data
public class FinIdBatchDto {
    /**
     * ID集合。
     */
    private List<Long> ids;
}
src/main/java/com/ruoyi/account/bean/dto/financial/FinIntangibleAssetDto.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,13 @@
package com.ruoyi.account.bean.dto.financial;
import com.ruoyi.account.pojo.financial.FinIntangibleAsset;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
 * æ— å½¢èµ„产查询与保存 DTO。
 */
@EqualsAndHashCode(callSuper = true)
@Data
public class FinIntangibleAssetDto extends FinIntangibleAsset {
}
src/main/java/com/ruoyi/account/bean/dto/financial/FinLedgerQueryDto.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,25 @@
package com.ruoyi.account.bean.dto.financial;
import lombok.Data;
/**
 * ç§‘目账查询参数。
 */
@Data
public class FinLedgerQueryDto {
    /**
     * ç§‘目编码(支持末级或指定科目)。
     */
    private String subjectCode;
    /**
     * å¼€å§‹æœˆä»½ï¼Œæ ¼å¼ï¼šYYYY-MM。
     */
    private String startMonth;
    /**
     * ç»“束月份,格式:YYYY-MM。
     */
    private String endMonth;
}
src/main/java/com/ruoyi/account/bean/dto/financial/FinVoucherDto.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,20 @@
package com.ruoyi.account.bean.dto.financial;
import com.ruoyi.account.pojo.financial.FinVoucher;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.util.List;
/**
 * å‡­è¯ä¿å­˜ DTO(主表 + åˆ†å½•)。
 */
@EqualsAndHashCode(callSuper = true)
@Data
public class FinVoucherDto extends FinVoucher {
    /**
     * å‡­è¯æ˜Žç»†åˆ†å½•。
     */
    private List<FinVoucherEntryDto> entries;
}
src/main/java/com/ruoyi/account/bean/dto/financial/FinVoucherEntryDto.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,13 @@
package com.ruoyi.account.bean.dto.financial;
import com.ruoyi.account.pojo.financial.FinVoucherEntry;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
 * å‡­è¯åˆ†å½• DTO。
 */
@EqualsAndHashCode(callSuper = true)
@Data
public class FinVoucherEntryDto extends FinVoucherEntry {
}
src/main/java/com/ruoyi/account/bean/dto/financial/FinVoucherPageDto.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,37 @@
package com.ruoyi.account.bean.dto.financial;
import lombok.Data;
import java.time.LocalDate;
/**
 * å‡­è¯åˆ†é¡µæŸ¥è¯¢å‚数。
 */
@Data
public class FinVoucherPageDto {
    /**
     * å‡­è¯å­—号(模糊匹配)。
     */
    private String voucherNo;
    /**
     * åˆ¶å•人。
     */
    private String creator;
    /**
     * å‡­è¯çŠ¶æ€ã€‚
     */
    private String status;
    /**
     * å¼€å§‹æ—¥æœŸï¼ˆå«ï¼‰ã€‚
     */
    private LocalDate startDate;
    /**
     * ç»“束日期(含)。
     */
    private LocalDate endDate;
}
src/main/java/com/ruoyi/account/bean/dto/financial/FinVoucherStatusDto.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,15 @@
package com.ruoyi.account.bean.dto.financial;
import lombok.Data;
/**
 * å‡­è¯çŠ¶æ€å˜æ›´å‚æ•°ã€‚
 */
@Data
public class FinVoucherStatusDto {
    /**
     * å‡­è¯ID。
     */
    private Long id;
}
src/main/java/com/ruoyi/account/bean/vo/AccountSubjectVo.java
@@ -3,6 +3,19 @@
import com.ruoyi.account.pojo.AccountSubject;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
public class AccountSubjectVo extends AccountSubject {
    /**
     * å­ç§‘目列表(递归结构)。
     */
    private List<AccountSubjectVo> children = new ArrayList<>();
    /**
     * æ˜¯å¦å¶å­èŠ‚ç‚¹ã€‚
     */
    private Boolean leaf;
}
src/main/java/com/ruoyi/account/bean/vo/financial/FinLedgerEntryRecordVo.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,43 @@
package com.ruoyi.account.bean.vo.financial;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
/**
 * ç§‘目账基础分录查询对象(SQL映射使用)。
 */
@Data
public class FinLedgerEntryRecordVo {
    /**
     * å‡­è¯æ—¥æœŸã€‚
     */
    private LocalDate voucherDate;
    /**
     * å‡­è¯å­—号。
     */
    private String voucherNo;
    /**
     * æ‘˜è¦ã€‚
     */
    private String summary;
    /**
     * å€Ÿæ–¹é‡‘额。
     */
    private BigDecimal debit;
    /**
     * è´·æ–¹é‡‘额。
     */
    private BigDecimal credit;
    /**
     * è¡Œå·ï¼ˆæŽ’序字段)。
     */
    private Integer rowNo;
}
src/main/java/com/ruoyi/account/bean/vo/financial/FinLedgerRowVo.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,53 @@
package com.ruoyi.account.bean.vo.financial;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
/**
 * ç§‘目账行数据返回对象。
 */
@Data
public class FinLedgerRowVo {
    /**
     * è¡Œç±»åž‹ï¼šopening/entry/monthly_total/yearly_total。
     */
    private String rowType;
    /**
     * æ—¥æœŸã€‚
     */
    private LocalDate date;
    /**
     * å‡­è¯å­—号。
     */
    private String voucherNo;
    /**
     * æ‘˜è¦ã€‚
     */
    private String summary;
    /**
     * å€Ÿæ–¹é‡‘额。
     */
    private BigDecimal debit;
    /**
     * è´·æ–¹é‡‘额。
     */
    private BigDecimal credit;
    /**
     * ä½™é¢æ–¹å‘:借/贷。
     */
    private String direction;
    /**
     * ä½™é¢ï¼ˆå€Ÿæ­£è´·è´Ÿï¼‰ã€‚
     */
    private BigDecimal balance;
}
src/main/java/com/ruoyi/account/bean/vo/financial/FinVoucherDetailVo.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,21 @@
package com.ruoyi.account.bean.vo.financial;
import com.ruoyi.account.pojo.financial.FinVoucher;
import com.ruoyi.account.pojo.financial.FinVoucherEntry;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.util.List;
/**
 * å‡­è¯è¯¦æƒ…返回对象。
 */
@EqualsAndHashCode(callSuper = true)
@Data
public class FinVoucherDetailVo extends FinVoucher {
    /**
     * å‡­è¯åˆ†å½•列表。
     */
    private List<FinVoucherEntry> entries;
}
src/main/java/com/ruoyi/account/controller/AccountSubjectController.java
@@ -33,7 +33,7 @@
    @GetMapping("/list")
    @Log(title = "总账科目数据集合", businessType = BusinessType.OTHER)
    @Operation(summary = "总账科目分页查询")
    @Operation(summary = "总账科目树形查询(递归)")
    public R<IPage<AccountSubjectVo>> AccountSubjectDtoList(Page<AccountSubjectDto> page, AccountSubjectDto accountSubjectDto) {
        IPage<AccountSubjectVo> paramList = accountSubjectService.baseList(page, accountSubjectDto);
        return R.ok(paramList);
@@ -43,21 +43,21 @@
    @Log(title = "新增总账科目", businessType = BusinessType.INSERT)
    @Operation(summary = "新增总账科目")
    public R AccountSubjectDtoAdd(@RequestBody AccountSubjectDto accountSubjectDto) {
        return R.ok(accountSubjectService.save(accountSubjectDto));
        return R.ok(accountSubjectService.saveAccountSubject(accountSubjectDto));
    }
    @PutMapping("/edit")
    @Log(title = "修改总账科目", businessType = BusinessType.UPDATE)
    @Operation(summary = "修改总账科目")
    public R AccountSubjectDtoEdit(@RequestBody AccountSubjectDto accountSubjectDto) {
        return R.ok(accountSubjectService.updateById(accountSubjectDto));
        return R.ok(accountSubjectService.updateAccountSubject(accountSubjectDto));
    }
    @DeleteMapping("/remove/{ids}")
    @Log(title = "删除总账科目", businessType = BusinessType.DELETE)
    @Operation(summary = "删除总账科目")
    public R AccountSubjectDtooRemove(@PathVariable Long[] ids) {
        return R.ok(accountSubjectService.removeBatchByIds(Arrays.asList(ids)));
        return R.ok(accountSubjectService.removeAccountSubjectByIds(Arrays.asList(ids)));
    }
    @PostMapping("/export")
src/main/java/com/ruoyi/account/controller/financial/FinFixedAssetController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,63 @@
package com.ruoyi.account.controller.financial;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.account.bean.dto.financial.FinFixedAssetDto;
import com.ruoyi.account.bean.dto.financial.FinIdBatchDto;
import com.ruoyi.account.pojo.financial.FinFixedAsset;
import com.ruoyi.account.service.financial.FinFixedAssetService;
import com.ruoyi.framework.aspectj.lang.annotation.Log;
import com.ruoyi.framework.aspectj.lang.enums.BusinessType;
import com.ruoyi.framework.web.domain.R;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.Arrays;
/**
 * å›ºå®šèµ„产控制器。
 */
@RestController
@RequestMapping("/financial/fixedAsset")
@RequiredArgsConstructor
@Tag(name = "财务管理-固定资产")
public class FinFixedAssetController {
    private final FinFixedAssetService finFixedAssetService;
    @GetMapping("/page")
    @Operation(summary = "固定资产分页查询")
    public R<IPage<FinFixedAsset>> page(Page<FinFixedAsset> page, FinFixedAssetDto queryDto) {
        return R.ok(finFixedAssetService.pageList(page, queryDto));
    }
    @PostMapping("/add")
    @Log(title = "固定资产", businessType = BusinessType.INSERT)
    @Operation(summary = "新增固定资产")
    public R<Boolean> add(@RequestBody FinFixedAssetDto dto) {
        return R.ok(finFixedAssetService.add(dto));
    }
    @PutMapping("/update")
    @Log(title = "固定资产", businessType = BusinessType.UPDATE)
    @Operation(summary = "修改固定资产")
    public R<Boolean> update(@RequestBody FinFixedAssetDto dto) {
        return R.ok(finFixedAssetService.update(dto));
    }
    @DeleteMapping("/delete")
    @Log(title = "固定资产", businessType = BusinessType.DELETE)
    @Operation(summary = "删除固定资产")
    public R<Boolean> delete(@RequestParam("ids") Long[] ids) {
        return R.ok(finFixedAssetService.deleteByIds(Arrays.asList(ids)));
    }
    @PostMapping("/depreciate")
    @Log(title = "固定资产折旧计提", businessType = BusinessType.UPDATE)
    @Operation(summary = "固定资产按月计提折旧")
    public R depreciate(@RequestBody(required = false) FinIdBatchDto dto) {
        return R.ok(finFixedAssetService.depreciate(dto == null ? null : dto.getIds()));
    }
}
src/main/java/com/ruoyi/account/controller/financial/FinIntangibleAssetController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,63 @@
package com.ruoyi.account.controller.financial;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.account.bean.dto.financial.FinIdBatchDto;
import com.ruoyi.account.bean.dto.financial.FinIntangibleAssetDto;
import com.ruoyi.account.pojo.financial.FinIntangibleAsset;
import com.ruoyi.account.service.financial.FinIntangibleAssetService;
import com.ruoyi.framework.aspectj.lang.annotation.Log;
import com.ruoyi.framework.aspectj.lang.enums.BusinessType;
import com.ruoyi.framework.web.domain.R;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.Arrays;
/**
 * æ— å½¢èµ„产控制器。
 */
@RestController
@RequestMapping("/financial/intangibleAsset")
@RequiredArgsConstructor
@Tag(name = "财务管理-无形资产")
public class FinIntangibleAssetController {
    private final FinIntangibleAssetService finIntangibleAssetService;
    @GetMapping("/page")
    @Operation(summary = "无形资产分页查询")
    public R<IPage<FinIntangibleAsset>> page(Page<FinIntangibleAsset> page, FinIntangibleAssetDto queryDto) {
        return R.ok(finIntangibleAssetService.pageList(page, queryDto));
    }
    @PostMapping("/add")
    @Log(title = "无形资产", businessType = BusinessType.INSERT)
    @Operation(summary = "新增无形资产")
    public R<Boolean> add(@RequestBody FinIntangibleAssetDto dto) {
        return R.ok(finIntangibleAssetService.add(dto));
    }
    @PutMapping("/update")
    @Log(title = "无形资产", businessType = BusinessType.UPDATE)
    @Operation(summary = "修改无形资产")
    public R<Boolean> update(@RequestBody FinIntangibleAssetDto dto) {
        return R.ok(finIntangibleAssetService.update(dto));
    }
    @DeleteMapping("/delete")
    @Log(title = "无形资产", businessType = BusinessType.DELETE)
    @Operation(summary = "删除无形资产")
    public R<Boolean> delete(@RequestParam("ids") Long[] ids) {
        return R.ok(finIntangibleAssetService.deleteByIds(Arrays.asList(ids)));
    }
    @PostMapping("/amortize")
    @Log(title = "无形资产摊销计提", businessType = BusinessType.UPDATE)
    @Operation(summary = "无形资产按月计提摊销")
    public R amortize(@RequestBody(required = false) FinIdBatchDto dto) {
        return R.ok(finIntangibleAssetService.amortize(dto == null ? null : dto.getIds()));
    }
}
src/main/java/com/ruoyi/account/controller/financial/FinLedgerController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,39 @@
package com.ruoyi.account.controller.financial;
import com.ruoyi.account.bean.dto.financial.FinDetailLedgerQueryDto;
import com.ruoyi.account.bean.dto.financial.FinLedgerQueryDto;
import com.ruoyi.account.bean.vo.financial.FinLedgerRowVo;
import com.ruoyi.account.service.financial.FinLedgerService;
import com.ruoyi.framework.web.domain.R;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
 * ç§‘目总账/明细账控制器。
 */
@RestController
@RequestMapping("/financial/ledger")
@RequiredArgsConstructor
@Tag(name = "财务管理-科目账")
public class FinLedgerController {
    private final FinLedgerService finLedgerService;
    @GetMapping("/general")
    @Operation(summary = "科目总账查询")
    public R<List<FinLedgerRowVo>> general(FinLedgerQueryDto queryDto) {
        return R.ok(finLedgerService.queryGeneralLedger(queryDto));
    }
    @GetMapping("/detail")
    @Operation(summary = "科目明细账查询")
    public R<List<FinLedgerRowVo>> detail(FinDetailLedgerQueryDto queryDto) {
        return R.ok(finLedgerService.queryDetailLedger(queryDto));
    }
}
src/main/java/com/ruoyi/account/controller/financial/FinVoucherController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,69 @@
package com.ruoyi.account.controller.financial;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.account.bean.dto.financial.FinVoucherDto;
import com.ruoyi.account.bean.dto.financial.FinVoucherPageDto;
import com.ruoyi.account.bean.dto.financial.FinVoucherStatusDto;
import com.ruoyi.account.bean.vo.financial.FinVoucherDetailVo;
import com.ruoyi.account.pojo.financial.FinVoucher;
import com.ruoyi.account.service.financial.FinVoucherService;
import com.ruoyi.framework.aspectj.lang.annotation.Log;
import com.ruoyi.framework.aspectj.lang.enums.BusinessType;
import com.ruoyi.framework.web.domain.R;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
/**
 * å‡­è¯æŽ§åˆ¶å™¨ã€‚
 */
@RestController
@RequestMapping("/financial/voucher")
@RequiredArgsConstructor
@Tag(name = "财务管理-凭证")
public class FinVoucherController {
    private final FinVoucherService finVoucherService;
    @GetMapping("/page")
    @Operation(summary = "凭证分页查询")
    public R<IPage<FinVoucher>> page(Page<FinVoucher> page, FinVoucherPageDto queryDto) {
        return R.ok(finVoucherService.pageList(page, queryDto));
    }
    @PostMapping("/add")
    @Log(title = "凭证", businessType = BusinessType.INSERT)
    @Operation(summary = "新增凭证")
    public R<Boolean> add(@RequestBody FinVoucherDto dto) {
        return R.ok(finVoucherService.addVoucher(dto));
    }
    @PutMapping("/update")
    @Log(title = "凭证", businessType = BusinessType.UPDATE)
    @Operation(summary = "修改凭证")
    public R<Boolean> update(@RequestBody FinVoucherDto dto) {
        return R.ok(finVoucherService.updateVoucher(dto));
    }
    @PostMapping("/post")
    @Log(title = "凭证过账", businessType = BusinessType.UPDATE)
    @Operation(summary = "凭证过账")
    public R<Boolean> post(@RequestBody FinVoucherStatusDto dto) {
        return R.ok(finVoucherService.postVoucher(dto.getId()));
    }
    @PostMapping("/cancel")
    @Log(title = "凭证作废", businessType = BusinessType.UPDATE)
    @Operation(summary = "凭证作废")
    public R<Boolean> cancel(@RequestBody FinVoucherStatusDto dto) {
        return R.ok(finVoucherService.cancelVoucher(dto.getId()));
    }
    @GetMapping("/detail/{id}")
    @Operation(summary = "凭证详情")
    public R<FinVoucherDetailVo> detail(@PathVariable("id") Long id) {
        return R.ok(finVoucherService.detail(id));
    }
}
src/main/java/com/ruoyi/account/mapper/AccountSubjectMapper.java
@@ -3,6 +3,9 @@
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.account.pojo.AccountSubject;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
 * <p>
@@ -15,4 +18,6 @@
@Mapper
public interface AccountSubjectMapper extends BaseMapper<AccountSubject> {
    Long countReferencedBySubjectCodes(@Param("subjectCodes") List<String> subjectCodes);
}
src/main/java/com/ruoyi/account/mapper/financial/FinFixedAssetMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,12 @@
package com.ruoyi.account.mapper.financial;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.account.pojo.financial.FinFixedAsset;
import org.apache.ibatis.annotations.Mapper;
/**
 * å›ºå®šèµ„产 Mapper。
 */
@Mapper
public interface FinFixedAssetMapper extends BaseMapper<FinFixedAsset> {
}
src/main/java/com/ruoyi/account/mapper/financial/FinIntangibleAssetMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,12 @@
package com.ruoyi.account.mapper.financial;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.account.pojo.financial.FinIntangibleAsset;
import org.apache.ibatis.annotations.Mapper;
/**
 * æ— å½¢èµ„产 Mapper。
 */
@Mapper
public interface FinIntangibleAssetMapper extends BaseMapper<FinIntangibleAsset> {
}
src/main/java/com/ruoyi/account/mapper/financial/FinVoucherEntryMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,28 @@
package com.ruoyi.account.mapper.financial;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.account.bean.vo.financial.FinLedgerEntryRecordVo;
import com.ruoyi.account.pojo.financial.FinVoucherEntry;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.time.LocalDate;
import java.util.List;
/**
 * å‡­è¯åˆ†å½• Mapper。
 */
@Mapper
public interface FinVoucherEntryMapper extends BaseMapper<FinVoucherEntry> {
    List<FinLedgerEntryRecordVo> listPostedEntries(@Param("subjectCode") String subjectCode,
                                                   @Param("startDate") LocalDate startDate,
                                                   @Param("endDate") LocalDate endDate,
                                                   @Param("auxiliaryType") String auxiliaryType,
                                                   @Param("auxiliaryId") String auxiliaryId);
    List<FinLedgerEntryRecordVo> listPostedEntriesBefore(@Param("subjectCode") String subjectCode,
                                                         @Param("beforeDate") LocalDate beforeDate,
                                                         @Param("auxiliaryType") String auxiliaryType,
                                                         @Param("auxiliaryId") String auxiliaryId);
}
src/main/java/com/ruoyi/account/mapper/financial/FinVoucherMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,12 @@
package com.ruoyi.account.mapper.financial;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.account.pojo.financial.FinVoucher;
import org.apache.ibatis.annotations.Mapper;
/**
 * å‡­è¯ä¸»è¡¨ Mapper。
 */
@Mapper
public interface FinVoucherMapper extends BaseMapper<FinVoucher> {
}
src/main/java/com/ruoyi/account/pojo/AccountSubject.java
@@ -39,6 +39,12 @@
    private Long id;
    /**
     * çˆ¶ç§‘ç›®ID(为空表示根节点)
     */
    @ApiModelProperty("父科目ID(为空表示根节点)")
    private Long parentId;
    /**
     * ç§‘目编码(唯一标识)
     */
    @ApiModelProperty("科目编码(唯一标识)")
src/main/java/com/ruoyi/account/pojo/financial/FinFixedAsset.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,101 @@
package com.ruoyi.account.pojo.financial;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
 * å›ºå®šèµ„产实体。
 */
@Getter
@Setter
@ToString
@TableName("fin_fixed_asset")
@ApiModel(value = "FinFixedAsset对象", description = "固定资产")
public class FinFixedAsset implements Serializable {
    @Serial
    private static final long serialVersionUID = 1L;
    @ApiModelProperty("主键ID")
    @TableId(value = "id", type = IdType.AUTO)
    private Long id;
    @ApiModelProperty("资产编号")
    private String assetCode;
    @ApiModelProperty("资产名称")
    private String assetName;
    @ApiModelProperty("资产类别")
    private String category;
    @ApiModelProperty("规格型号")
    private String specification;
    @ApiModelProperty("购置日期")
    private LocalDate purchaseDate;
    @ApiModelProperty("资产原值")
    private BigDecimal originalValue;
    @ApiModelProperty("使用年限(å¹´)")
    private Integer usefulLife;
    @ApiModelProperty("残值率(%)")
    private BigDecimal residualRate;
    @ApiModelProperty("累计折旧")
    private BigDecimal accumulatedDepreciation;
    @ApiModelProperty("净值")
    private BigDecimal netValue;
    @ApiModelProperty("存放地点")
    private String location;
    @ApiModelProperty("使用部门")
    private String department;
    @ApiModelProperty("保管人")
    private String keeper;
    @ApiModelProperty("状态")
    private String status;
    @ApiModelProperty("备注")
    private String remark;
    @ApiModelProperty("创建人")
    @TableField(fill = FieldFill.INSERT)
    private String createUser;
    @ApiModelProperty("创建时间")
    @TableField(fill = FieldFill.INSERT)
    private LocalDateTime createTime;
    @ApiModelProperty("修改人")
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private String updateUser;
    @ApiModelProperty("修改时间")
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private LocalDateTime updateTime;
    @ApiModelProperty("部门ID")
    @TableField(fill = FieldFill.INSERT)
    private Long deptId;
}
src/main/java/com/ruoyi/account/pojo/financial/FinIntangibleAsset.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,98 @@
package com.ruoyi.account.pojo.financial;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
 * æ— å½¢èµ„产实体。
 */
@Getter
@Setter
@ToString
@TableName("fin_intangible_asset")
@ApiModel(value = "FinIntangibleAsset对象", description = "无形资产")
public class FinIntangibleAsset implements Serializable {
    @Serial
    private static final long serialVersionUID = 1L;
    @ApiModelProperty("主键ID")
    @TableId(value = "id", type = IdType.AUTO)
    private Long id;
    @ApiModelProperty("资产编号")
    private String assetCode;
    @ApiModelProperty("资产名称")
    private String assetName;
    @ApiModelProperty("资产类别")
    private String category;
    @ApiModelProperty("证书编号")
    private String certificateNo;
    @ApiModelProperty("取得日期")
    private LocalDate acquisitionDate;
    @ApiModelProperty("资产原值")
    private BigDecimal originalValue;
    @ApiModelProperty("摊销年限(å¹´)")
    private Integer amortizationPeriod;
    @ApiModelProperty("残值率(%)")
    private BigDecimal residualRate;
    @ApiModelProperty("累计摊销")
    private BigDecimal accumulatedAmortization;
    @ApiModelProperty("净值")
    private BigDecimal netValue;
    @ApiModelProperty("有效期至")
    private LocalDate validityDate;
    @ApiModelProperty("状态")
    private String status;
    @ApiModelProperty("资产描述")
    private String description;
    @ApiModelProperty("备注")
    private String remark;
    @ApiModelProperty("创建人")
    @TableField(fill = FieldFill.INSERT)
    private String createUser;
    @ApiModelProperty("创建时间")
    @TableField(fill = FieldFill.INSERT)
    private LocalDateTime createTime;
    @ApiModelProperty("修改人")
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private String updateUser;
    @ApiModelProperty("修改时间")
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private LocalDateTime updateTime;
    @ApiModelProperty("部门ID")
    @TableField(fill = FieldFill.INSERT)
    private Long deptId;
}
src/main/java/com/ruoyi/account/pojo/financial/FinVoucher.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,83 @@
package com.ruoyi.account.pojo.financial;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
 * å‡­è¯ä¸»è¡¨å®žä½“。
 */
@Getter
@Setter
@ToString
@TableName("fin_voucher")
@ApiModel(value = "FinVoucher对象", description = "凭证主表")
public class FinVoucher implements Serializable {
    @Serial
    private static final long serialVersionUID = 1L;
    @ApiModelProperty("主键ID")
    @TableId(value = "id", type = IdType.AUTO)
    private Long id;
    @ApiModelProperty("凭证字号")
    private String voucherNo;
    @ApiModelProperty("凭证日期")
    private LocalDate voucherDate;
    @ApiModelProperty("摘要")
    private String summary;
    @ApiModelProperty("借方合计")
    private BigDecimal debit;
    @ApiModelProperty("贷方合计")
    private BigDecimal credit;
    @ApiModelProperty("制单人")
    private String creator;
    @ApiModelProperty("状态: unposted/posted/cancelled")
    private String status;
    @ApiModelProperty("附件数量")
    private Integer attachmentCount;
    @ApiModelProperty("备注")
    private String remark;
    @ApiModelProperty("创建人")
    @TableField(fill = FieldFill.INSERT)
    private String createUser;
    @ApiModelProperty("创建时间")
    @TableField(fill = FieldFill.INSERT)
    private LocalDateTime createTime;
    @ApiModelProperty("修改人")
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private String updateUser;
    @ApiModelProperty("修改时间")
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private LocalDateTime updateTime;
    @ApiModelProperty("部门ID")
    @TableField(fill = FieldFill.INSERT)
    private Long deptId;
}
src/main/java/com/ruoyi/account/pojo/financial/FinVoucherEntry.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,85 @@
package com.ruoyi.account.pojo.financial;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
 * å‡­è¯åˆ†å½•实体。
 */
@Getter
@Setter
@ToString
@TableName("fin_voucher_entry")
@ApiModel(value = "FinVoucherEntry对象", description = "凭证分录")
public class FinVoucherEntry implements Serializable {
    @Serial
    private static final long serialVersionUID = 1L;
    @ApiModelProperty("主键ID")
    @TableId(value = "id", type = IdType.AUTO)
    private Long id;
    @ApiModelProperty("凭证ID")
    private Long voucherId;
    @ApiModelProperty("行号")
    private Integer rowNo;
    @ApiModelProperty("科目编码")
    private String subjectCode;
    @ApiModelProperty("科目名称")
    private String subjectName;
    @ApiModelProperty("摘要")
    private String summary;
    @ApiModelProperty("借方金额")
    private BigDecimal debit;
    @ApiModelProperty("贷方金额")
    private BigDecimal credit;
    @ApiModelProperty("辅助核算类型")
    private String auxiliaryType;
    @ApiModelProperty("辅助核算对象ID")
    private String auxiliaryId;
    @ApiModelProperty("辅助核算对象名称")
    private String auxiliaryName;
    @ApiModelProperty("创建人")
    @TableField(fill = FieldFill.INSERT)
    private String createUser;
    @ApiModelProperty("创建时间")
    @TableField(fill = FieldFill.INSERT)
    private LocalDateTime createTime;
    @ApiModelProperty("修改人")
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private String updateUser;
    @ApiModelProperty("修改时间")
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private LocalDateTime updateTime;
    @ApiModelProperty("部门ID")
    @TableField(fill = FieldFill.INSERT)
    private Long deptId;
}
src/main/java/com/ruoyi/account/service/AccountSubjectService.java
@@ -8,6 +8,8 @@
import com.baomidou.mybatisplus.extension.service.IService;
import jakarta.servlet.http.HttpServletResponse;
import java.util.List;
/**
 * <p>
 * æ€»è´¦ç§‘目表 æœåŠ¡ç±»
@@ -20,5 +22,11 @@
    IPage<AccountSubjectVo> baseList(Page<AccountSubjectDto> page, AccountSubjectDto accountSubjectDto);
    Boolean saveAccountSubject(AccountSubjectDto accountSubjectDto);
    Boolean updateAccountSubject(AccountSubjectDto accountSubjectDto);
    Boolean removeAccountSubjectByIds(List<Long> ids);
    void exportAccountSubject(HttpServletResponse response);
}
src/main/java/com/ruoyi/account/service/financial/FinFixedAssetService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,26 @@
package com.ruoyi.account.service.financial;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService;
import com.ruoyi.account.bean.dto.financial.FinFixedAssetDto;
import com.ruoyi.account.pojo.financial.FinFixedAsset;
import java.util.List;
import java.util.Map;
/**
 * å›ºå®šèµ„产服务。
 */
public interface FinFixedAssetService extends IService<FinFixedAsset> {
    IPage<FinFixedAsset> pageList(Page<FinFixedAsset> page, FinFixedAssetDto queryDto);
    Boolean add(FinFixedAssetDto dto);
    Boolean update(FinFixedAssetDto dto);
    Boolean deleteByIds(List<Long> ids);
    Map<String, Object> depreciate(List<Long> ids);
}
src/main/java/com/ruoyi/account/service/financial/FinIntangibleAssetService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,26 @@
package com.ruoyi.account.service.financial;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService;
import com.ruoyi.account.bean.dto.financial.FinIntangibleAssetDto;
import com.ruoyi.account.pojo.financial.FinIntangibleAsset;
import java.util.List;
import java.util.Map;
/**
 * æ— å½¢èµ„产服务。
 */
public interface FinIntangibleAssetService extends IService<FinIntangibleAsset> {
    IPage<FinIntangibleAsset> pageList(Page<FinIntangibleAsset> page, FinIntangibleAssetDto queryDto);
    Boolean add(FinIntangibleAssetDto dto);
    Boolean update(FinIntangibleAssetDto dto);
    Boolean deleteByIds(List<Long> ids);
    Map<String, Object> amortize(List<Long> ids);
}
src/main/java/com/ruoyi/account/service/financial/FinLedgerService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,17 @@
package com.ruoyi.account.service.financial;
import com.ruoyi.account.bean.dto.financial.FinDetailLedgerQueryDto;
import com.ruoyi.account.bean.dto.financial.FinLedgerQueryDto;
import com.ruoyi.account.bean.vo.financial.FinLedgerRowVo;
import java.util.List;
/**
 * ç§‘目账服务。
 */
public interface FinLedgerService {
    List<FinLedgerRowVo> queryGeneralLedger(FinLedgerQueryDto queryDto);
    List<FinLedgerRowVo> queryDetailLedger(FinDetailLedgerQueryDto queryDto);
}
src/main/java/com/ruoyi/account/service/financial/FinVoucherService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,27 @@
package com.ruoyi.account.service.financial;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService;
import com.ruoyi.account.bean.dto.financial.FinVoucherDto;
import com.ruoyi.account.bean.dto.financial.FinVoucherPageDto;
import com.ruoyi.account.bean.vo.financial.FinVoucherDetailVo;
import com.ruoyi.account.pojo.financial.FinVoucher;
/**
 * å‡­è¯æœåŠ¡ã€‚
 */
public interface FinVoucherService extends IService<FinVoucher> {
    IPage<FinVoucher> pageList(Page<FinVoucher> page, FinVoucherPageDto queryDto);
    Boolean addVoucher(FinVoucherDto dto);
    Boolean updateVoucher(FinVoucherDto dto);
    Boolean postVoucher(Long id);
    Boolean cancelVoucher(Long id);
    FinVoucherDetailVo detail(Long id);
}
src/main/java/com/ruoyi/account/service/impl/AccountSubjectServiceImpl.java
@@ -10,6 +10,7 @@
import com.ruoyi.account.mapper.AccountSubjectMapper;
import com.ruoyi.account.pojo.AccountSubject;
import com.ruoyi.account.service.AccountSubjectService;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.poi.ExcelUtil;
import jakarta.servlet.http.HttpServletResponse;
@@ -18,7 +19,16 @@
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
/**
@@ -37,30 +47,78 @@
    @Override
    public IPage<AccountSubjectVo> baseList(Page<AccountSubjectDto> page, AccountSubjectDto accountSubjectDto) {
        LambdaQueryWrapper<AccountSubject> queryWrapper = new LambdaQueryWrapper<>();
        if (accountSubjectDto != null && StringUtils.isNotEmpty(accountSubjectDto.getSubjectCode())) {
            queryWrapper.like(AccountSubject::getSubjectCode, accountSubjectDto.getSubjectCode());
        }
        if (accountSubjectDto != null && StringUtils.isNotEmpty(accountSubjectDto.getSubjectName())) {
            queryWrapper.like(AccountSubject::getSubjectName, accountSubjectDto.getSubjectName());
        }
        if (accountSubjectDto != null && StringUtils.isNotEmpty(accountSubjectDto.getSubjectType())) {
            queryWrapper.eq(AccountSubject::getSubjectType, accountSubjectDto.getSubjectType());
        }
        queryWrapper.orderByDesc(AccountSubject::getId);
        Page<AccountSubjectDto> requestPage = page == null ? new Page<>(1, 10) : page;
        List<AccountSubject> allSubjects = list(loadBaseQueryWrapper(accountSubjectDto));
        List<AccountSubject> filteredSubjects = applyTreeFilter(allSubjects, accountSubjectDto);
        List<AccountSubjectVo> fullTree = buildTree(filteredSubjects);
        Page<AccountSubject> entityPage = new Page<>(page.getCurrent(), page.getSize(), page.getTotal());
        Page<AccountSubject> paramPage = page(entityPage, queryWrapper);
        long current = requestPage.getCurrent() <= 0 ? 1 : requestPage.getCurrent();
        long size = requestPage.getSize() <= 0 ? 10 : requestPage.getSize();
        int fromIndex = (int) Math.min((current - 1) * size, fullTree.size());
        int toIndex = (int) Math.min(fromIndex + size, fullTree.size());
        List<AccountSubjectVo> pagedRoots = fromIndex >= toIndex
                ? Collections.emptyList()
                : fullTree.subList(fromIndex, toIndex);
        Page<AccountSubjectVo> resultPage = new Page<>(paramPage.getCurrent(), paramPage.getSize(), paramPage.getTotal());
        List<AccountSubjectVo> records = new ArrayList<>(paramPage.getRecords().size());
        for (AccountSubject item : paramPage.getRecords()) {
            AccountSubjectVo vo = new AccountSubjectVo();
            BeanUtils.copyProperties(item, vo);
            records.add(vo);
        }
        resultPage.setRecords(records);
        Page<AccountSubjectVo> resultPage = new Page<>(current, size, fullTree.size());
        resultPage.setRecords(pagedRoots);
        return resultPage;
    }
    @Override
    public Boolean saveAccountSubject(AccountSubjectDto accountSubjectDto) {
        validateSubjectRequiredFields(accountSubjectDto);
        validateSubjectCodeUnique(accountSubjectDto, false);
        validateParent(accountSubjectDto.getParentId(), null);
        if (accountSubjectDto.getStatus() == null) {
            accountSubjectDto.setStatus(0);
        }
        return save(accountSubjectDto);
    }
    @Override
    public Boolean updateAccountSubject(AccountSubjectDto accountSubjectDto) {
        if (accountSubjectDto == null || accountSubjectDto.getId() == null) {
            throw new ServiceException("修改失败,科目ID不能为空");
        }
        if (getById(accountSubjectDto.getId()) == null) {
            throw new ServiceException("修改失败,未找到对应科目");
        }
        validateParent(accountSubjectDto.getParentId(), accountSubjectDto.getId());
        validateSubjectRequiredFields(accountSubjectDto);
        validateSubjectCodeUnique(accountSubjectDto, true);
        return updateById(accountSubjectDto);
    }
    @Override
    public Boolean removeAccountSubjectByIds(List<Long> ids) {
        if (ids == null || ids.isEmpty()) {
            return true;
        }
        List<AccountSubject> allSubjects = list();
        if (allSubjects == null || allSubjects.isEmpty()) {
            return true;
        }
        Map<Long, List<Long>> childrenIdMap = buildChildrenIdMap(allSubjects);
        Set<Long> removeIds = new LinkedHashSet<>();
        for (Long id : ids) {
            collectDescendantIds(id, childrenIdMap, removeIds);
        }
        if (removeIds.isEmpty()) {
            return true;
        }
        List<String> subjectCodes = allSubjects.stream()
                .filter(subject -> removeIds.contains(subject.getId()))
                .map(AccountSubject::getSubjectCode)
                .filter(StringUtils::isNotEmpty)
                .collect(Collectors.toList());
        if (!subjectCodes.isEmpty()) {
            Long referencedCount = accountSubjectMapper.countReferencedBySubjectCodes(subjectCodes);
            if (referencedCount != null && referencedCount > 0) {
                throw new ServiceException("删除失败,科目已被凭证分录引用");
            }
        }
        return removeByIds(removeIds);
    }
    @Override
@@ -74,4 +132,266 @@
        ExcelUtil<AccountSubjectImportDto> util = new ExcelUtil<>(AccountSubjectImportDto.class);
        util.exportExcel(response, importDtos , "总账科目");
    }
    /**
     * æ ¡éªŒç§‘目必填字段,避免脏数据写入。
     */
    private void validateSubjectRequiredFields(AccountSubjectDto accountSubjectDto) {
        if (accountSubjectDto == null) {
            throw new ServiceException("总账科目数据不能为空");
        }
        if (StringUtils.isEmpty(accountSubjectDto.getSubjectCode())) {
            throw new ServiceException("科目编码不能为空");
        }
        if (StringUtils.isEmpty(accountSubjectDto.getSubjectName())) {
            throw new ServiceException("科目名称不能为空");
        }
        if (StringUtils.isEmpty(accountSubjectDto.getSubjectType())) {
            throw new ServiceException("科目类型不能为空");
        }
    }
    /**
     * æ ¡éªŒç§‘目编码唯一,新增和修改都要执行。
     */
    private void validateSubjectCodeUnique(AccountSubjectDto accountSubjectDto, boolean isUpdate) {
        LambdaQueryWrapper<AccountSubject> codeQueryWrapper = new LambdaQueryWrapper<>();
        codeQueryWrapper.eq(AccountSubject::getSubjectCode, accountSubjectDto.getSubjectCode());
        if (isUpdate) {
            codeQueryWrapper.ne(AccountSubject::getId, accountSubjectDto.getId());
        }
        AccountSubject exists = getOne(codeQueryWrapper, false);
        if (Objects.nonNull(exists)) {
            throw new ServiceException("科目编码已存在,请勿重复提交");
        }
    }
    /**
     * ä»…按通用过滤条件查询基础数据(树形过滤后续再做)。
     */
    private LambdaQueryWrapper<AccountSubject> loadBaseQueryWrapper(AccountSubjectDto accountSubjectDto) {
        LambdaQueryWrapper<AccountSubject> queryWrapper = new LambdaQueryWrapper<>();
        if (accountSubjectDto != null && accountSubjectDto.getStatus() != null) {
            queryWrapper.eq(AccountSubject::getStatus, accountSubjectDto.getStatus());
        }
        queryWrapper.orderByAsc(AccountSubject::getSubjectCode).orderByAsc(AccountSubject::getId);
        return queryWrapper;
    }
    /**
     * æ ‘形过滤:命中节点后保留其父链与子树,保证递归结构完整。
     */
    private List<AccountSubject> applyTreeFilter(List<AccountSubject> allSubjects, AccountSubjectDto queryDto) {
        if (allSubjects == null || allSubjects.isEmpty()) {
            return Collections.emptyList();
        }
        boolean hasFilter = queryDto != null && (
                StringUtils.isNotEmpty(queryDto.getSubjectCode())
                        || StringUtils.isNotEmpty(queryDto.getSubjectName())
                        || StringUtils.isNotEmpty(queryDto.getSubjectType())
        );
        if (!hasFilter) {
            return allSubjects;
        }
        Map<Long, AccountSubject> subjectMap = allSubjects.stream()
                .filter(item -> item.getId() != null)
                .collect(Collectors.toMap(AccountSubject::getId, item -> item, (a, b) -> a, LinkedHashMap::new));
        Map<Long, List<AccountSubject>> childrenMap = buildChildrenMap(allSubjects);
        Set<Long> matchedIds = new LinkedHashSet<>();
        for (AccountSubject subject : allSubjects) {
            if (subject.getId() == null) {
                continue;
            }
            if (matchesFilter(subject, queryDto)) {
                matchedIds.add(subject.getId());
            }
        }
        if (matchedIds.isEmpty()) {
            return Collections.emptyList();
        }
        Set<Long> resultIds = new LinkedHashSet<>(matchedIds);
        for (Long matchedId : matchedIds) {
            addAncestors(matchedId, subjectMap, resultIds);
            addDescendants(matchedId, childrenMap, resultIds);
        }
        return allSubjects.stream()
                .filter(item -> item.getId() != null && resultIds.contains(item.getId()))
                .collect(Collectors.toList());
    }
    private boolean matchesFilter(AccountSubject subject, AccountSubjectDto queryDto) {
        if (queryDto == null) {
            return true;
        }
        if (StringUtils.isNotEmpty(queryDto.getSubjectCode())
                && (subject.getSubjectCode() == null || !subject.getSubjectCode().contains(queryDto.getSubjectCode()))) {
            return false;
        }
        if (StringUtils.isNotEmpty(queryDto.getSubjectName())
                && (subject.getSubjectName() == null || !subject.getSubjectName().contains(queryDto.getSubjectName()))) {
            return false;
        }
        if (StringUtils.isNotEmpty(queryDto.getSubjectType())
                && !queryDto.getSubjectType().equals(subject.getSubjectType())) {
            return false;
        }
        return true;
    }
    private void addAncestors(Long subjectId, Map<Long, AccountSubject> subjectMap, Set<Long> resultIds) {
        AccountSubject current = subjectMap.get(subjectId);
        if (current == null) {
            return;
        }
        Long parentId = current.getParentId();
        while (parentId != null && parentId > 0) {
            AccountSubject parent = subjectMap.get(parentId);
            if (parent == null) {
                break;
            }
            if (!resultIds.add(parent.getId())) {
                break;
            }
            parentId = parent.getParentId();
        }
    }
    private void addDescendants(Long subjectId, Map<Long, List<AccountSubject>> childrenMap, Set<Long> resultIds) {
        List<AccountSubject> children = childrenMap.getOrDefault(subjectId, Collections.emptyList());
        for (AccountSubject child : children) {
            if (child.getId() == null) {
                continue;
            }
            if (resultIds.add(child.getId())) {
                addDescendants(child.getId(), childrenMap, resultIds);
            }
        }
    }
    private Map<Long, List<AccountSubject>> buildChildrenMap(List<AccountSubject> subjects) {
        Map<Long, List<AccountSubject>> childrenMap = new HashMap<>();
        for (AccountSubject subject : subjects) {
            if (subject.getId() == null) {
                continue;
            }
            Long parentId = subject.getParentId();
            if (parentId == null || parentId <= 0) {
                continue;
            }
            childrenMap.computeIfAbsent(parentId, key -> new ArrayList<>()).add(subject);
        }
        return childrenMap;
    }
    /**
     * åŸºäºŽ parentId é€’归构建科目树。
     */
    private List<AccountSubjectVo> buildTree(List<AccountSubject> subjects) {
        if (subjects == null || subjects.isEmpty()) {
            return Collections.emptyList();
        }
        List<AccountSubject> sortedSubjects = new ArrayList<>(subjects);
        sortedSubjects.sort(Comparator
                .comparing(AccountSubject::getSubjectCode, Comparator.nullsLast(String::compareTo))
                .thenComparing(AccountSubject::getId, Comparator.nullsLast(Long::compareTo)));
        Map<Long, AccountSubjectVo> subjectVoMap = new LinkedHashMap<>();
        for (AccountSubject subject : sortedSubjects) {
            if (subject.getId() == null) {
                continue;
            }
            AccountSubjectVo vo = new AccountSubjectVo();
            BeanUtils.copyProperties(subject, vo);
            subjectVoMap.put(subject.getId(), vo);
        }
        List<AccountSubjectVo> roots = new ArrayList<>();
        for (AccountSubject subject : sortedSubjects) {
            if (subject.getId() == null) {
                continue;
            }
            AccountSubjectVo current = subjectVoMap.get(subject.getId());
            Long parentId = subject.getParentId();
            if (parentId != null && parentId > 0 && subjectVoMap.containsKey(parentId)) {
                subjectVoMap.get(parentId).getChildren().add(current);
            } else {
                roots.add(current);
            }
        }
        markLeafRecursively(roots);
        return roots;
    }
    private void markLeafRecursively(List<AccountSubjectVo> nodes) {
        for (AccountSubjectVo node : nodes) {
            List<AccountSubjectVo> children = node.getChildren();
            node.setLeaf(children == null || children.isEmpty());
            if (children != null && !children.isEmpty()) {
                markLeafRecursively(children);
            }
        }
    }
    /**
     * æ ¡éªŒçˆ¶å­å…³ç³»ï¼šçˆ¶èŠ‚ç‚¹å¿…é¡»å­˜åœ¨ï¼Œä¸”ä¸èƒ½å½¢æˆå¾ªçŽ¯å¼•ç”¨ã€‚
     */
    private void validateParent(Long parentId, Long currentId) {
        if (parentId == null || parentId <= 0) {
            return;
        }
        if (currentId != null && parentId.equals(currentId)) {
            throw new ServiceException("父科目不能选择自身");
        }
        AccountSubject parent = getById(parentId);
        if (parent == null) {
            throw new ServiceException("父科目不存在,请重新选择");
        }
        // é˜²æ­¢å½¢æˆçŽ¯ï¼šæ›´æ–°æ—¶ï¼Œçˆ¶èŠ‚ç‚¹ä¸èƒ½æ˜¯å½“å‰èŠ‚ç‚¹çš„ä»»æ„å­å­™èŠ‚ç‚¹ã€‚
        if (currentId != null) {
            Set<Long> visited = new HashSet<>();
            Long traceParentId = parentId;
            while (traceParentId != null && traceParentId > 0) {
                if (!visited.add(traceParentId)) {
                    throw new ServiceException("科目层级存在循环引用,请检查父科目设置");
                }
                if (traceParentId.equals(currentId)) {
                    throw new ServiceException("父科目不能是当前科目或其子科目");
                }
                AccountSubject traceNode = getById(traceParentId);
                if (traceNode == null) {
                    break;
                }
                traceParentId = traceNode.getParentId();
            }
        }
    }
    private Map<Long, List<Long>> buildChildrenIdMap(List<AccountSubject> subjects) {
        Map<Long, List<Long>> map = new HashMap<>();
        for (AccountSubject subject : subjects) {
            if (subject.getId() == null || subject.getParentId() == null || subject.getParentId() <= 0) {
                continue;
            }
            map.computeIfAbsent(subject.getParentId(), key -> new ArrayList<>()).add(subject.getId());
        }
        return map;
    }
    /**
     * æ”¶é›†å¾…删除节点及其所有子孙节点。
     */
    private void collectDescendantIds(Long id, Map<Long, List<Long>> childrenIdMap, Set<Long> result) {
        if (id == null || !result.add(id)) {
            return;
        }
        List<Long> children = childrenIdMap.getOrDefault(id, Collections.emptyList());
        for (Long childId : children) {
            collectDescendantIds(childId, childrenIdMap, result);
        }
    }
}
src/main/java/com/ruoyi/account/service/impl/financial/FinFixedAssetServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,231 @@
package com.ruoyi.account.service.impl.financial;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.ruoyi.account.bean.dto.financial.FinFixedAssetDto;
import com.ruoyi.account.mapper.financial.FinFixedAssetMapper;
import com.ruoyi.account.pojo.financial.FinFixedAsset;
import com.ruoyi.account.service.financial.FinFixedAssetService;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.StringUtils;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
/**
 * å›ºå®šèµ„产服务实现。
 */
@Service
@RequiredArgsConstructor
public class FinFixedAssetServiceImpl extends ServiceImpl<FinFixedAssetMapper, FinFixedAsset> implements FinFixedAssetService {
    private static final BigDecimal ONE_HUNDRED = new BigDecimal("100");
    private static final BigDecimal ZERO = BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
    private static final DateTimeFormatter CODE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
    @Override
    public IPage<FinFixedAsset> pageList(Page<FinFixedAsset> page, FinFixedAssetDto queryDto) {
        LambdaQueryWrapper<FinFixedAsset> wrapper = new LambdaQueryWrapper<>();
        if (queryDto != null && StringUtils.isNotEmpty(queryDto.getAssetCode())) {
            wrapper.like(FinFixedAsset::getAssetCode, queryDto.getAssetCode());
        }
        if (queryDto != null && StringUtils.isNotEmpty(queryDto.getAssetName())) {
            wrapper.like(FinFixedAsset::getAssetName, queryDto.getAssetName());
        }
        if (queryDto != null && StringUtils.isNotEmpty(queryDto.getCategory())) {
            wrapper.eq(FinFixedAsset::getCategory, queryDto.getCategory());
        }
        if (queryDto != null && StringUtils.isNotEmpty(queryDto.getStatus())) {
            wrapper.eq(FinFixedAsset::getStatus, queryDto.getStatus());
        }
        wrapper.orderByDesc(FinFixedAsset::getId);
        return page(page, wrapper);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Boolean add(FinFixedAssetDto dto) {
        validateForSave(dto, false);
        if (StringUtils.isEmpty(dto.getAssetCode())) {
            dto.setAssetCode(generateAssetCode());
        }
        BigDecimal residualRate = normalizeResidualRate(dto.getResidualRate());
        dto.setResidualRate(residualRate);
        BigDecimal accumulatedDepreciation = defaultMoney(dto.getAccumulatedDepreciation());
        dto.setAccumulatedDepreciation(accumulatedDepreciation);
        dto.setNetValue(calculateNetValue(dto.getOriginalValue(), accumulatedDepreciation));
        if (StringUtils.isEmpty(dto.getStatus())) {
            dto.setStatus("in_use");
        }
        return save(dto);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Boolean update(FinFixedAssetDto dto) {
        if (dto == null || dto.getId() == null) {
            throw new ServiceException("修改失败,资产ID不能为空");
        }
        FinFixedAsset existed = getById(dto.getId());
        if (existed == null) {
            throw new ServiceException("修改失败,固定资产不存在");
        }
        if (StringUtils.isEmpty(dto.getAssetCode())) {
            dto.setAssetCode(existed.getAssetCode());
        }
        if (StringUtils.isEmpty(dto.getStatus())) {
            dto.setStatus(existed.getStatus());
        }
        validateForSave(dto, true);
        BigDecimal residualRate = normalizeResidualRate(dto.getResidualRate());
        dto.setResidualRate(residualRate);
        if (dto.getAccumulatedDepreciation() == null) {
            dto.setAccumulatedDepreciation(defaultMoney(existed.getAccumulatedDepreciation()));
        }
        dto.setNetValue(calculateNetValue(dto.getOriginalValue(), dto.getAccumulatedDepreciation()));
        return updateById(dto);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Boolean deleteByIds(List<Long> ids) {
        if (ids == null || ids.isEmpty()) {
            throw new ServiceException("删除失败,请选择要删除的数据");
        }
        return removeByIds(ids);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Map<String, Object> depreciate(List<Long> ids) {
        LambdaQueryWrapper<FinFixedAsset> wrapper = new LambdaQueryWrapper<>();
        if (ids != null && !ids.isEmpty()) {
            wrapper.in(FinFixedAsset::getId, ids);
        } else {
            wrapper.eq(FinFixedAsset::getStatus, "in_use");
        }
        List<FinFixedAsset> assets = list(wrapper);
        BigDecimal totalMonthlyDepreciation = ZERO;
        int processedCount = 0;
        for (FinFixedAsset asset : assets) {
            if (!"in_use".equals(asset.getStatus())) {
                continue;
            }
            BigDecimal monthlyDepreciation = calculateMonthlyDepreciation(
                    asset.getOriginalValue(),
                    asset.getResidualRate(),
                    asset.getUsefulLife()
            );
            BigDecimal accumulatedDepreciation = defaultMoney(asset.getAccumulatedDepreciation()).add(monthlyDepreciation);
            if (accumulatedDepreciation.compareTo(defaultMoney(asset.getOriginalValue())) > 0) {
                accumulatedDepreciation = defaultMoney(asset.getOriginalValue());
            }
            asset.setAccumulatedDepreciation(roundMoney(accumulatedDepreciation));
            asset.setNetValue(calculateNetValue(asset.getOriginalValue(), asset.getAccumulatedDepreciation()));
            updateById(asset);
            processedCount++;
            totalMonthlyDepreciation = totalMonthlyDepreciation.add(monthlyDepreciation);
        }
        Map<String, Object> result = new HashMap<>(4);
        result.put("processedCount", processedCount);
        result.put("totalMonthlyDepreciation", roundMoney(totalMonthlyDepreciation));
        result.put("executionTime", LocalDateTime.now());
        return result;
    }
    /**
     * æŒ‰æ–‡æ¡£è§„则校验固定资产数据。
     */
    private void validateForSave(FinFixedAssetDto dto, boolean isUpdate) {
        if (dto == null) {
            throw new ServiceException("固定资产数据不能为空");
        }
        if (isUpdate && dto.getId() == null) {
            throw new ServiceException("修改失败,资产ID不能为空");
        }
        if (StringUtils.isEmpty(dto.getAssetName())) {
            throw new ServiceException("资产名称不能为空");
        }
        if (StringUtils.isEmpty(dto.getCategory())) {
            throw new ServiceException("资产类别不能为空");
        }
        if (dto.getPurchaseDate() == null) {
            throw new ServiceException("购置日期不能为空");
        }
        if (dto.getOriginalValue() == null || dto.getOriginalValue().compareTo(BigDecimal.ZERO) < 0) {
            throw new ServiceException("资产原值不能为空且不能小于0");
        }
        if (dto.getUsefulLife() == null || dto.getUsefulLife() <= 0) {
            throw new ServiceException("使用年限必须大于0");
        }
        if (dto.getResidualRate() != null && dto.getResidualRate().compareTo(BigDecimal.ZERO) < 0) {
            throw new ServiceException("残值率不能小于0");
        }
        if (dto.getResidualRate() != null && dto.getResidualRate().compareTo(ONE_HUNDRED) > 0) {
            throw new ServiceException("残值率不能大于100%");
        }
        if (StringUtils.isNotEmpty(dto.getAssetCode())) {
            LambdaQueryWrapper<FinFixedAsset> wrapper = new LambdaQueryWrapper<>();
            wrapper.eq(FinFixedAsset::getAssetCode, dto.getAssetCode());
            if (isUpdate) {
                wrapper.ne(FinFixedAsset::getId, dto.getId());
            }
            if (count(wrapper) > 0) {
                throw new ServiceException("资产编号已存在,请勿重复提交");
            }
        }
    }
    /**
     * å›ºå®šèµ„产折旧公式:
     * monthlyDepreciation = originalValue * (1 - residualRate/100) / (usefulLife*12)
     */
    private BigDecimal calculateMonthlyDepreciation(BigDecimal originalValue, BigDecimal residualRate, Integer usefulLife) {
        BigDecimal normalizedOriginalValue = defaultMoney(originalValue);
        BigDecimal normalizedResidualRate = normalizeResidualRate(residualRate);
        BigDecimal depreciableRatio = BigDecimal.ONE.subtract(normalizedResidualRate.divide(ONE_HUNDRED, 8, RoundingMode.HALF_UP));
        BigDecimal months = BigDecimal.valueOf((long) usefulLife * 12L);
        if (months.compareTo(BigDecimal.ZERO) <= 0) {
            throw new ServiceException("使用年限无效,无法计提折旧");
        }
        return roundMoney(normalizedOriginalValue.multiply(depreciableRatio).divide(months, 8, RoundingMode.HALF_UP));
    }
    /**
     * å‡€å€¼ = åŽŸå€¼ - ç´¯è®¡æŠ˜æ—§ã€‚
     */
    private BigDecimal calculateNetValue(BigDecimal originalValue, BigDecimal accumulatedDepreciation) {
        BigDecimal value = defaultMoney(originalValue).subtract(defaultMoney(accumulatedDepreciation));
        if (value.compareTo(BigDecimal.ZERO) < 0) {
            value = BigDecimal.ZERO;
        }
        return roundMoney(value);
    }
    private BigDecimal normalizeResidualRate(BigDecimal residualRate) {
        return residualRate == null ? BigDecimal.ZERO : residualRate;
    }
    private BigDecimal defaultMoney(BigDecimal value) {
        return value == null ? ZERO : roundMoney(value);
    }
    private BigDecimal roundMoney(BigDecimal value) {
        if (value == null) {
            return ZERO;
        }
        return value.setScale(2, RoundingMode.HALF_UP);
    }
    private String generateAssetCode() {
        return "GD" + LocalDateTime.now().format(CODE_TIME_FORMATTER) + new Random().nextInt(10);
    }
}
src/main/java/com/ruoyi/account/service/impl/financial/FinIntangibleAssetServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,250 @@
package com.ruoyi.account.service.impl.financial;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.ruoyi.account.bean.dto.financial.FinIntangibleAssetDto;
import com.ruoyi.account.mapper.financial.FinIntangibleAssetMapper;
import com.ruoyi.account.pojo.financial.FinIntangibleAsset;
import com.ruoyi.account.service.financial.FinIntangibleAssetService;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.StringUtils;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
/**
 * æ— å½¢èµ„产服务实现。
 */
@Service
@RequiredArgsConstructor
public class FinIntangibleAssetServiceImpl extends ServiceImpl<FinIntangibleAssetMapper, FinIntangibleAsset> implements FinIntangibleAssetService {
    private static final BigDecimal ONE_HUNDRED = new BigDecimal("100");
    private static final BigDecimal ZERO = BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
    private static final DateTimeFormatter CODE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
    @Override
    public IPage<FinIntangibleAsset> pageList(Page<FinIntangibleAsset> page, FinIntangibleAssetDto queryDto) {
        LambdaQueryWrapper<FinIntangibleAsset> wrapper = new LambdaQueryWrapper<>();
        if (queryDto != null && StringUtils.isNotEmpty(queryDto.getAssetCode())) {
            wrapper.like(FinIntangibleAsset::getAssetCode, queryDto.getAssetCode());
        }
        if (queryDto != null && StringUtils.isNotEmpty(queryDto.getAssetName())) {
            wrapper.like(FinIntangibleAsset::getAssetName, queryDto.getAssetName());
        }
        if (queryDto != null && StringUtils.isNotEmpty(queryDto.getCategory())) {
            wrapper.eq(FinIntangibleAsset::getCategory, queryDto.getCategory());
        }
        if (queryDto != null && StringUtils.isNotEmpty(queryDto.getStatus())) {
            wrapper.eq(FinIntangibleAsset::getStatus, queryDto.getStatus());
        }
        wrapper.orderByDesc(FinIntangibleAsset::getId);
        return page(page, wrapper);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Boolean add(FinIntangibleAssetDto dto) {
        validateForSave(dto, false);
        if (StringUtils.isEmpty(dto.getAssetCode())) {
            dto.setAssetCode(generateAssetCode());
        }
        BigDecimal residualRate = normalizeResidualRate(dto.getResidualRate());
        dto.setResidualRate(residualRate);
        BigDecimal accumulatedAmortization = defaultMoney(dto.getAccumulatedAmortization());
        dto.setAccumulatedAmortization(accumulatedAmortization);
        dto.setNetValue(calculateNetValue(dto.getOriginalValue(), accumulatedAmortization));
        if (StringUtils.isEmpty(dto.getStatus())) {
            dto.setStatus("in_use");
        }
        return save(dto);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Boolean update(FinIntangibleAssetDto dto) {
        if (dto == null || dto.getId() == null) {
            throw new ServiceException("修改失败,资产ID不能为空");
        }
        FinIntangibleAsset existed = getById(dto.getId());
        if (existed == null) {
            throw new ServiceException("修改失败,无形资产不存在");
        }
        if (StringUtils.isEmpty(dto.getAssetCode())) {
            dto.setAssetCode(existed.getAssetCode());
        }
        if (StringUtils.isEmpty(dto.getStatus())) {
            dto.setStatus(existed.getStatus());
        }
        validateForSave(dto, true);
        BigDecimal residualRate = normalizeResidualRate(dto.getResidualRate());
        dto.setResidualRate(residualRate);
        if (dto.getAccumulatedAmortization() == null) {
            dto.setAccumulatedAmortization(defaultMoney(existed.getAccumulatedAmortization()));
        }
        dto.setNetValue(calculateNetValue(dto.getOriginalValue(), dto.getAccumulatedAmortization()));
        if (dto.getNetValue().compareTo(BigDecimal.ZERO) <= 0) {
            dto.setStatus("amortized");
        } else if ("amortized".equals(dto.getStatus())) {
            dto.setStatus("in_use");
        }
        if (dto.getValidityDate() != null
                && dto.getValidityDate().isBefore(LocalDate.now())
                && !"amortized".equals(dto.getStatus())) {
            dto.setStatus("expired");
        }
        return updateById(dto);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Boolean deleteByIds(List<Long> ids) {
        if (ids == null || ids.isEmpty()) {
            throw new ServiceException("删除失败,请选择要删除的数据");
        }
        return removeByIds(ids);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Map<String, Object> amortize(List<Long> ids) {
        LambdaQueryWrapper<FinIntangibleAsset> wrapper = new LambdaQueryWrapper<>();
        if (ids != null && !ids.isEmpty()) {
            wrapper.in(FinIntangibleAsset::getId, ids);
        } else {
            wrapper.eq(FinIntangibleAsset::getStatus, "in_use");
        }
        List<FinIntangibleAsset> assets = list(wrapper);
        BigDecimal totalMonthlyAmortization = ZERO;
        int processedCount = 0;
        for (FinIntangibleAsset asset : assets) {
            if (!"in_use".equals(asset.getStatus())) {
                continue;
            }
            BigDecimal monthlyAmortization = calculateMonthlyAmortization(
                    asset.getOriginalValue(),
                    asset.getResidualRate(),
                    asset.getAmortizationPeriod()
            );
            BigDecimal accumulatedAmortization = defaultMoney(asset.getAccumulatedAmortization()).add(monthlyAmortization);
            if (accumulatedAmortization.compareTo(defaultMoney(asset.getOriginalValue())) > 0) {
                accumulatedAmortization = defaultMoney(asset.getOriginalValue());
            }
            asset.setAccumulatedAmortization(roundMoney(accumulatedAmortization));
            asset.setNetValue(calculateNetValue(asset.getOriginalValue(), asset.getAccumulatedAmortization()));
            // è§„则:当净值 <= 0 æ—¶ï¼Œå‡€å€¼å½’零并标记为已摊销完。
            if (asset.getNetValue().compareTo(BigDecimal.ZERO) <= 0) {
                asset.setNetValue(BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP));
                asset.setStatus("amortized");
            } else if (asset.getValidityDate() != null && asset.getValidityDate().isBefore(LocalDate.now())) {
                asset.setStatus("expired");
            }
            updateById(asset);
            processedCount++;
            totalMonthlyAmortization = totalMonthlyAmortization.add(monthlyAmortization);
        }
        Map<String, Object> result = new HashMap<>(4);
        result.put("processedCount", processedCount);
        result.put("totalMonthlyAmortization", roundMoney(totalMonthlyAmortization));
        result.put("executionTime", LocalDateTime.now());
        return result;
    }
    /**
     * æŒ‰æ–‡æ¡£è§„则校验无形资产数据。
     */
    private void validateForSave(FinIntangibleAssetDto dto, boolean isUpdate) {
        if (dto == null) {
            throw new ServiceException("无形资产数据不能为空");
        }
        if (isUpdate && dto.getId() == null) {
            throw new ServiceException("修改失败,资产ID不能为空");
        }
        if (StringUtils.isEmpty(dto.getAssetName())) {
            throw new ServiceException("资产名称不能为空");
        }
        if (StringUtils.isEmpty(dto.getCategory())) {
            throw new ServiceException("资产类别不能为空");
        }
        if (dto.getAcquisitionDate() == null) {
            throw new ServiceException("取得日期不能为空");
        }
        if (dto.getOriginalValue() == null || dto.getOriginalValue().compareTo(BigDecimal.ZERO) < 0) {
            throw new ServiceException("资产原值不能为空且不能小于0");
        }
        if (dto.getAmortizationPeriod() == null || dto.getAmortizationPeriod() <= 0) {
            throw new ServiceException("摊销年限必须大于0");
        }
        if (dto.getResidualRate() != null && dto.getResidualRate().compareTo(BigDecimal.ZERO) < 0) {
            throw new ServiceException("残值率不能小于0");
        }
        if (dto.getResidualRate() != null && dto.getResidualRate().compareTo(ONE_HUNDRED) > 0) {
            throw new ServiceException("残值率不能大于100%");
        }
        if (StringUtils.isNotEmpty(dto.getAssetCode())) {
            LambdaQueryWrapper<FinIntangibleAsset> wrapper = new LambdaQueryWrapper<>();
            wrapper.eq(FinIntangibleAsset::getAssetCode, dto.getAssetCode());
            if (isUpdate) {
                wrapper.ne(FinIntangibleAsset::getId, dto.getId());
            }
            if (count(wrapper) > 0) {
                throw new ServiceException("资产编号已存在,请勿重复提交");
            }
        }
    }
    /**
     * æ— å½¢èµ„产摊销公式:
     * monthlyAmortization = originalValue * (1 - residualRate/100) / (amortizationPeriod*12)
     */
    private BigDecimal calculateMonthlyAmortization(BigDecimal originalValue, BigDecimal residualRate, Integer amortizationPeriod) {
        BigDecimal normalizedOriginalValue = defaultMoney(originalValue);
        BigDecimal normalizedResidualRate = normalizeResidualRate(residualRate);
        BigDecimal amortizableRatio = BigDecimal.ONE.subtract(normalizedResidualRate.divide(ONE_HUNDRED, 8, RoundingMode.HALF_UP));
        BigDecimal months = BigDecimal.valueOf((long) amortizationPeriod * 12L);
        if (months.compareTo(BigDecimal.ZERO) <= 0) {
            throw new ServiceException("摊销年限无效,无法计提摊销");
        }
        return roundMoney(normalizedOriginalValue.multiply(amortizableRatio).divide(months, 8, RoundingMode.HALF_UP));
    }
    /**
     * å‡€å€¼ = åŽŸå€¼ - ç´¯è®¡æ‘Šé”€ã€‚
     */
    private BigDecimal calculateNetValue(BigDecimal originalValue, BigDecimal accumulatedAmortization) {
        BigDecimal value = defaultMoney(originalValue).subtract(defaultMoney(accumulatedAmortization));
        if (value.compareTo(BigDecimal.ZERO) < 0) {
            value = BigDecimal.ZERO;
        }
        return roundMoney(value);
    }
    private BigDecimal normalizeResidualRate(BigDecimal residualRate) {
        return residualRate == null ? BigDecimal.ZERO : residualRate;
    }
    private BigDecimal defaultMoney(BigDecimal value) {
        return value == null ? ZERO : roundMoney(value);
    }
    private BigDecimal roundMoney(BigDecimal value) {
        if (value == null) {
            return ZERO;
        }
        return value.setScale(2, RoundingMode.HALF_UP);
    }
    private String generateAssetCode() {
        return "WX" + LocalDateTime.now().format(CODE_TIME_FORMATTER) + new Random().nextInt(10);
    }
}
src/main/java/com/ruoyi/account/service/impl/financial/FinLedgerServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,206 @@
package com.ruoyi.account.service.impl.financial;
import com.ruoyi.account.bean.dto.financial.FinDetailLedgerQueryDto;
import com.ruoyi.account.bean.dto.financial.FinLedgerQueryDto;
import com.ruoyi.account.bean.vo.financial.FinLedgerEntryRecordVo;
import com.ruoyi.account.bean.vo.financial.FinLedgerRowVo;
import com.ruoyi.account.mapper.financial.FinVoucherEntryMapper;
import com.ruoyi.account.service.financial.FinLedgerService;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.StringUtils;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.YearMonth;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.*;
/**
 * ç§‘目总账/明细账服务实现。
 */
@Service
@RequiredArgsConstructor
public class FinLedgerServiceImpl implements FinLedgerService {
    private static final DateTimeFormatter MONTH_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM");
    private static final BigDecimal ZERO = BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
    private final FinVoucherEntryMapper finVoucherEntryMapper;
    @Override
    public List<FinLedgerRowVo> queryGeneralLedger(FinLedgerQueryDto queryDto) {
        if (queryDto == null || StringUtils.isEmpty(queryDto.getSubjectCode())) {
            return Collections.emptyList();
        }
        YearMonth startMonth = parseMonth(queryDto.getStartMonth(), "开始月份");
        YearMonth endMonth = parseMonth(queryDto.getEndMonth(), "结束月份");
        if (startMonth.isAfter(endMonth)) {
            throw new ServiceException("开始月份不能大于结束月份");
        }
        return buildLedgerRows(queryDto.getSubjectCode(), startMonth, endMonth, null, null);
    }
    @Override
    public List<FinLedgerRowVo> queryDetailLedger(FinDetailLedgerQueryDto queryDto) {
        if (queryDto == null || StringUtils.isEmpty(queryDto.getSubjectCode())) {
            return Collections.emptyList();
        }
        YearMonth startMonth = parseMonth(queryDto.getStartMonth(), "开始月份");
        YearMonth endMonth = parseMonth(queryDto.getEndMonth(), "结束月份");
        if (startMonth.isAfter(endMonth)) {
            throw new ServiceException("开始月份不能大于结束月份");
        }
        return buildLedgerRows(queryDto.getSubjectCode(), startMonth, endMonth, queryDto.getAuxiliaryType(), queryDto.getAuxiliaryId());
    }
    /**
     * æž„建账簿行数据,输出期初、分录、本月合计、本年累计。
     */
    private List<FinLedgerRowVo> buildLedgerRows(String subjectCode,
                                                 YearMonth startMonth,
                                                 YearMonth endMonth,
                                                 String auxiliaryType,
                                                 String auxiliaryId) {
        LocalDate startDate = startMonth.atDay(1);
        LocalDate endDate = endMonth.atEndOfMonth();
        List<FinLedgerEntryRecordVo> openingEntries = finVoucherEntryMapper.listPostedEntriesBefore(
                subjectCode, startDate, auxiliaryType, auxiliaryId
        );
        BigDecimal openingBalance = calculateBalance(openingEntries);
        List<FinLedgerEntryRecordVo> currentPeriodEntries = finVoucherEntryMapper.listPostedEntries(
                subjectCode, startDate, endDate, auxiliaryType, auxiliaryId
        );
        Map<YearMonth, List<FinLedgerEntryRecordVo>> monthEntriesMap = groupEntriesByMonth(currentPeriodEntries);
        List<FinLedgerRowVo> rows = new ArrayList<>();
        BigDecimal runningBalance = openingBalance;
        BigDecimal yearDebit = ZERO;
        BigDecimal yearCredit = ZERO;
        for (YearMonth month = startMonth; !month.isAfter(endMonth); month = month.plusMonths(1)) {
            rows.add(buildOpeningRow(month.atDay(1), runningBalance));
            List<FinLedgerEntryRecordVo> monthEntries = monthEntriesMap.getOrDefault(month, Collections.emptyList());
            BigDecimal monthDebit = ZERO;
            BigDecimal monthCredit = ZERO;
            for (FinLedgerEntryRecordVo entry : monthEntries) {
                BigDecimal debit = money(entry.getDebit());
                BigDecimal credit = money(entry.getCredit());
                runningBalance = runningBalance.add(debit).subtract(credit);
                monthDebit = monthDebit.add(debit);
                monthCredit = monthCredit.add(credit);
                FinLedgerRowVo row = new FinLedgerRowVo();
                row.setRowType("entry");
                row.setDate(entry.getVoucherDate());
                row.setVoucherNo(entry.getVoucherNo());
                row.setSummary(StringUtils.isNotEmpty(entry.getSummary()) ? entry.getSummary() : "");
                row.setDebit(debit);
                row.setCredit(credit);
                row.setBalance(money(runningBalance));
                row.setDirection(resolveDirection(runningBalance));
                rows.add(row);
            }
            rows.add(buildMonthlyTotalRow(month.atEndOfMonth(), monthDebit, monthCredit, runningBalance));
            yearDebit = yearDebit.add(monthDebit);
            yearCredit = yearCredit.add(monthCredit);
        }
        rows.add(buildYearlyTotalRow(endMonth.atEndOfMonth(), yearDebit, yearCredit, runningBalance));
        return rows;
    }
    private Map<YearMonth, List<FinLedgerEntryRecordVo>> groupEntriesByMonth(List<FinLedgerEntryRecordVo> entries) {
        Map<YearMonth, List<FinLedgerEntryRecordVo>> map = new LinkedHashMap<>();
        for (FinLedgerEntryRecordVo entry : entries) {
            if (entry.getVoucherDate() == null) {
                continue;
            }
            YearMonth month = YearMonth.from(entry.getVoucherDate());
            map.computeIfAbsent(month, key -> new ArrayList<>()).add(entry);
        }
        return map;
    }
    private FinLedgerRowVo buildOpeningRow(LocalDate date, BigDecimal openingBalance) {
        FinLedgerRowVo row = new FinLedgerRowVo();
        row.setRowType("opening");
        row.setDate(date);
        row.setVoucherNo("-");
        row.setSummary("期初余额");
        row.setDebit(ZERO);
        row.setCredit(ZERO);
        row.setBalance(money(openingBalance));
        row.setDirection(resolveDirection(openingBalance));
        return row;
    }
    private FinLedgerRowVo buildMonthlyTotalRow(LocalDate date,
                                                BigDecimal monthDebit,
                                                BigDecimal monthCredit,
                                                BigDecimal monthBalance) {
        FinLedgerRowVo row = new FinLedgerRowVo();
        row.setRowType("monthly_total");
        row.setDate(date);
        row.setVoucherNo("-");
        row.setSummary("本月合计");
        row.setDebit(money(monthDebit));
        row.setCredit(money(monthCredit));
        row.setBalance(money(monthBalance));
        row.setDirection(resolveDirection(monthBalance));
        return row;
    }
    private FinLedgerRowVo buildYearlyTotalRow(LocalDate date,
                                               BigDecimal yearDebit,
                                               BigDecimal yearCredit,
                                               BigDecimal yearBalance) {
        FinLedgerRowVo row = new FinLedgerRowVo();
        row.setRowType("yearly_total");
        row.setDate(date);
        row.setVoucherNo("-");
        row.setSummary("本年累计");
        row.setDebit(money(yearDebit));
        row.setCredit(money(yearCredit));
        row.setBalance(money(yearBalance));
        row.setDirection(resolveDirection(yearBalance));
        return row;
    }
    private BigDecimal calculateBalance(List<FinLedgerEntryRecordVo> entries) {
        BigDecimal balance = ZERO;
        for (FinLedgerEntryRecordVo entry : entries) {
            balance = balance.add(money(entry.getDebit())).subtract(money(entry.getCredit()));
        }
        return money(balance);
    }
    private String resolveDirection(BigDecimal balance) {
        return money(balance).compareTo(BigDecimal.ZERO) >= 0 ? "借" : "è´·";
    }
    private YearMonth parseMonth(String value, String fieldLabel) {
        if (StringUtils.isEmpty(value)) {
            throw new ServiceException(fieldLabel + "不能为空,格式应为YYYY-MM");
        }
        try {
            return YearMonth.parse(value, MONTH_FORMATTER);
        } catch (DateTimeParseException ex) {
            throw new ServiceException(fieldLabel + "格式错误,格式应为YYYY-MM");
        }
    }
    private BigDecimal money(BigDecimal value) {
        if (value == null) {
            return ZERO;
        }
        return value.setScale(2, RoundingMode.HALF_UP);
    }
}
src/main/java/com/ruoyi/account/service/impl/financial/FinVoucherServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,299 @@
package com.ruoyi.account.service.impl.financial;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.ruoyi.account.bean.dto.financial.FinVoucherDto;
import com.ruoyi.account.bean.dto.financial.FinVoucherEntryDto;
import com.ruoyi.account.bean.dto.financial.FinVoucherPageDto;
import com.ruoyi.account.bean.vo.financial.FinVoucherDetailVo;
import com.ruoyi.account.mapper.AccountSubjectMapper;
import com.ruoyi.account.mapper.financial.FinVoucherEntryMapper;
import com.ruoyi.account.mapper.financial.FinVoucherMapper;
import com.ruoyi.account.pojo.AccountSubject;
import com.ruoyi.account.pojo.financial.FinVoucher;
import com.ruoyi.account.pojo.financial.FinVoucherEntry;
import com.ruoyi.account.service.financial.FinVoucherService;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.StringUtils;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.*;
import java.util.stream.Collectors;
/**
 * å‡­è¯æœåŠ¡å®žçŽ°ã€‚
 */
@Service
@RequiredArgsConstructor
public class FinVoucherServiceImpl extends ServiceImpl<FinVoucherMapper, FinVoucher> implements FinVoucherService {
    private static final BigDecimal ZERO = BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
    private final FinVoucherEntryMapper finVoucherEntryMapper;
    private final AccountSubjectMapper accountSubjectMapper;
    @Override
    public IPage<FinVoucher> pageList(Page<FinVoucher> page, FinVoucherPageDto queryDto) {
        LambdaQueryWrapper<FinVoucher> wrapper = new LambdaQueryWrapper<>();
        if (queryDto != null && StringUtils.isNotEmpty(queryDto.getVoucherNo())) {
            wrapper.like(FinVoucher::getVoucherNo, queryDto.getVoucherNo());
        }
        if (queryDto != null && StringUtils.isNotEmpty(queryDto.getCreator())) {
            wrapper.eq(FinVoucher::getCreator, queryDto.getCreator());
        }
        if (queryDto != null && StringUtils.isNotEmpty(queryDto.getStatus())) {
            wrapper.eq(FinVoucher::getStatus, queryDto.getStatus());
        }
        if (queryDto != null && queryDto.getStartDate() != null) {
            wrapper.ge(FinVoucher::getVoucherDate, queryDto.getStartDate());
        }
        if (queryDto != null && queryDto.getEndDate() != null) {
            wrapper.le(FinVoucher::getVoucherDate, queryDto.getEndDate());
        }
        wrapper.orderByDesc(FinVoucher::getVoucherDate).orderByDesc(FinVoucher::getId);
        return page(page, wrapper);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Boolean addVoucher(FinVoucherDto dto) {
        validateVoucherBasicInfo(dto, false);
        List<FinVoucherEntry> validEntries = buildAndValidateEntries(dto);
        FinVoucher voucher = new FinVoucher();
        BeanUtils.copyProperties(dto, voucher);
        voucher.setStatus("unposted");
        voucher.setAttachmentCount(voucher.getAttachmentCount() == null ? 0 : voucher.getAttachmentCount());
        BigDecimal totalDebit = calculateTotalDebit(validEntries);
        BigDecimal totalCredit = calculateTotalCredit(validEntries);
        voucher.setDebit(totalDebit);
        voucher.setCredit(totalCredit);
        if (StringUtils.isEmpty(voucher.getSummary())) {
            voucher.setSummary(findDefaultSummary(validEntries));
        }
        save(voucher);
        saveEntries(voucher.getId(), validEntries);
        return true;
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Boolean updateVoucher(FinVoucherDto dto) {
        validateVoucherBasicInfo(dto, true);
        FinVoucher existed = getById(dto.getId());
        if (existed == null) {
            throw new ServiceException("修改失败,凭证不存在");
        }
        if (!"unposted".equals(existed.getStatus())) {
            throw new ServiceException("仅未过账凭证允许修改");
        }
        List<FinVoucherEntry> validEntries = buildAndValidateEntries(dto);
        FinVoucher voucher = new FinVoucher();
        BeanUtils.copyProperties(dto, voucher);
        voucher.setStatus(existed.getStatus());
        voucher.setAttachmentCount(voucher.getAttachmentCount() == null ? 0 : voucher.getAttachmentCount());
        BigDecimal totalDebit = calculateTotalDebit(validEntries);
        BigDecimal totalCredit = calculateTotalCredit(validEntries);
        voucher.setDebit(totalDebit);
        voucher.setCredit(totalCredit);
        if (StringUtils.isEmpty(voucher.getSummary())) {
            voucher.setSummary(findDefaultSummary(validEntries));
        }
        updateById(voucher);
        LambdaQueryWrapper<FinVoucherEntry> deleteWrapper = new LambdaQueryWrapper<>();
        deleteWrapper.eq(FinVoucherEntry::getVoucherId, voucher.getId());
        finVoucherEntryMapper.delete(deleteWrapper);
        saveEntries(voucher.getId(), validEntries);
        return true;
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Boolean postVoucher(Long id) {
        FinVoucher voucher = getById(id);
        if (voucher == null) {
            throw new ServiceException("过账失败,凭证不存在");
        }
        if (!"unposted".equals(voucher.getStatus())) {
            throw new ServiceException("仅未过账凭证允许过账");
        }
        voucher.setStatus("posted");
        return updateById(voucher);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Boolean cancelVoucher(Long id) {
        FinVoucher voucher = getById(id);
        if (voucher == null) {
            throw new ServiceException("作废失败,凭证不存在");
        }
        if (!"unposted".equals(voucher.getStatus())) {
            throw new ServiceException("仅未过账凭证允许作废");
        }
        voucher.setStatus("cancelled");
        return updateById(voucher);
    }
    @Override
    public FinVoucherDetailVo detail(Long id) {
        FinVoucher voucher = getById(id);
        if (voucher == null) {
            throw new ServiceException("查询失败,凭证不存在");
        }
        LambdaQueryWrapper<FinVoucherEntry> wrapper = new LambdaQueryWrapper<>();
        wrapper.eq(FinVoucherEntry::getVoucherId, id)
                .orderByAsc(FinVoucherEntry::getRowNo)
                .orderByAsc(FinVoucherEntry::getId);
        List<FinVoucherEntry> entries = finVoucherEntryMapper.selectList(wrapper);
        FinVoucherDetailVo vo = new FinVoucherDetailVo();
        BeanUtils.copyProperties(voucher, vo);
        vo.setEntries(entries);
        return vo;
    }
    /**
     * æ ¡éªŒå‡­è¯ä¸»è¡¨å­—段、状态字段与唯一性。
     */
    private void validateVoucherBasicInfo(FinVoucherDto dto, boolean isUpdate) {
        if (dto == null) {
            throw new ServiceException("凭证数据不能为空");
        }
        if (isUpdate && dto.getId() == null) {
            throw new ServiceException("修改失败,凭证ID不能为空");
        }
        if (StringUtils.isEmpty(dto.getVoucherNo())) {
            throw new ServiceException("凭证字号不能为空");
        }
        if (dto.getVoucherDate() == null) {
            throw new ServiceException("凭证日期不能为空");
        }
        LambdaQueryWrapper<FinVoucher> uniqueWrapper = new LambdaQueryWrapper<>();
        uniqueWrapper.eq(FinVoucher::getVoucherNo, dto.getVoucherNo());
        if (isUpdate) {
            uniqueWrapper.ne(FinVoucher::getId, dto.getId());
        }
        if (count(uniqueWrapper) > 0) {
            throw new ServiceException("凭证字号已存在,请勿重复提交");
        }
    }
    /**
     * è¿‡æ»¤æœ‰æ•ˆåˆ†å½•并执行借贷平衡校验。
     */
    private List<FinVoucherEntry> buildAndValidateEntries(FinVoucherDto dto) {
        List<FinVoucherEntryDto> rawEntries = dto.getEntries();
        if (rawEntries == null || rawEntries.isEmpty()) {
            throw new ServiceException("分录不能为空,至少需要一条有效分录");
        }
        List<FinVoucherEntry> validEntries = new ArrayList<>();
        int rowNo = 1;
        for (FinVoucherEntryDto entryDto : rawEntries) {
            if (entryDto == null || StringUtils.isEmpty(entryDto.getSubjectCode())) {
                continue;
            }
            BigDecimal debit = defaultMoney(entryDto.getDebit());
            BigDecimal credit = defaultMoney(entryDto.getCredit());
            if (debit.compareTo(BigDecimal.ZERO) <= 0 && credit.compareTo(BigDecimal.ZERO) <= 0) {
                continue;
            }
            if (debit.compareTo(BigDecimal.ZERO) > 0 && credit.compareTo(BigDecimal.ZERO) > 0) {
                throw new ServiceException("分录借方和贷方不能同时大于0");
            }
            FinVoucherEntry entry = new FinVoucherEntry();
            BeanUtils.copyProperties(entryDto, entry);
            entry.setDebit(debit);
            entry.setCredit(credit);
            entry.setRowNo(rowNo++);
            validEntries.add(entry);
        }
        if (validEntries.isEmpty()) {
            throw new ServiceException("分录至少需要一条有效行(科目不空,且借方或贷方大于0)");
        }
        // åˆ†å½•科目必须存在,避免脏科目编码入账。
        Set<String> subjectCodes = validEntries.stream()
                .map(FinVoucherEntry::getSubjectCode)
                .filter(StringUtils::isNotEmpty)
                .collect(Collectors.toSet());
        if (subjectCodes.isEmpty()) {
            throw new ServiceException("分录科目不能为空");
        }
        LambdaQueryWrapper<AccountSubject> subjectWrapper = new LambdaQueryWrapper<>();
        subjectWrapper.in(AccountSubject::getSubjectCode, subjectCodes);
        List<AccountSubject> subjects = accountSubjectMapper.selectList(subjectWrapper);
        Map<String, AccountSubject> subjectMap = subjects.stream()
                .collect(Collectors.toMap(AccountSubject::getSubjectCode, it -> it, (a, b) -> a));
        for (FinVoucherEntry entry : validEntries) {
            AccountSubject accountSubject = subjectMap.get(entry.getSubjectCode());
            if (accountSubject == null) {
                throw new ServiceException("科目编码不存在:" + entry.getSubjectCode());
            }
            if (StringUtils.isEmpty(entry.getSubjectName())) {
                entry.setSubjectName(accountSubject.getSubjectName());
            }
        }
        BigDecimal totalDebit = calculateTotalDebit(validEntries);
        BigDecimal totalCredit = calculateTotalCredit(validEntries);
        if (totalDebit.compareTo(BigDecimal.ZERO) <= 0 || totalCredit.compareTo(BigDecimal.ZERO) <= 0) {
            throw new ServiceException("借贷金额必须大于0");
        }
        if (totalDebit.compareTo(totalCredit) != 0) {
            throw new ServiceException("借贷不平衡,禁止保存");
        }
        return validEntries;
    }
    private void saveEntries(Long voucherId, List<FinVoucherEntry> entries) {
        if (voucherId == null) {
            throw new ServiceException("凭证ID不能为空");
        }
        for (FinVoucherEntry entry : entries) {
            entry.setVoucherId(voucherId);
            finVoucherEntryMapper.insert(entry);
        }
    }
    private String findDefaultSummary(List<FinVoucherEntry> entries) {
        for (FinVoucherEntry entry : entries) {
            if (StringUtils.isNotEmpty(entry.getSummary())) {
                return entry.getSummary();
            }
        }
        return "";
    }
    private BigDecimal calculateTotalDebit(List<FinVoucherEntry> entries) {
        BigDecimal total = BigDecimal.ZERO;
        for (FinVoucherEntry entry : entries) {
            total = total.add(defaultMoney(entry.getDebit()));
        }
        return total.setScale(2, RoundingMode.HALF_UP);
    }
    private BigDecimal calculateTotalCredit(List<FinVoucherEntry> entries) {
        BigDecimal total = BigDecimal.ZERO;
        for (FinVoucherEntry entry : entries) {
            total = total.add(defaultMoney(entry.getCredit()));
        }
        return total.setScale(2, RoundingMode.HALF_UP);
    }
    private BigDecimal defaultMoney(BigDecimal value) {
        if (value == null) {
            return ZERO;
        }
        return value.setScale(2, RoundingMode.HALF_UP);
    }
}
src/main/java/com/ruoyi/technology/service/impl/TechnologyBomServiceImpl.java
@@ -15,8 +15,8 @@
import com.ruoyi.common.utils.bean.BeanUtils;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.framework.web.domain.R;
import com.ruoyi.production.bean.dto.BomImportDto;
import com.ruoyi.production.bean.dto.ProductStructureDto;
import com.ruoyi.technology.bean.dto.BomImportDto;
import com.ruoyi.technology.bean.dto.TechnologyBomDto;
import com.ruoyi.technology.bean.dto.TechnologyBomStructureDto;
import com.ruoyi.technology.bean.vo.TechnologyBomStructureVo;
src/main/resources/application-dev.yml
@@ -28,7 +28,7 @@
# å¼€å‘环境配置
server:
  # æœåŠ¡å™¨çš„HTTP端口,默认为8080
  port: 7005
  port: 7006
  servlet:
    # åº”用的访问路径
    context-path: /
src/main/resources/mapper/account/AccountSubjectMapper.xml
@@ -5,6 +5,7 @@
    <!-- é€šç”¨æŸ¥è¯¢æ˜ å°„结果 -->
    <resultMap id="BaseResultMap" type="com.ruoyi.account.pojo.AccountSubject">
        <id column="id" property="id" />
        <result column="parent_id" property="parentId" />
        <result column="subject_code" property="subjectCode" />
        <result column="subject_name" property="subjectName" />
        <result column="subject_type" property="subjectType" />
@@ -18,4 +19,13 @@
        <result column="dept_id" property="deptId" />
    </resultMap>
    <select id="countReferencedBySubjectCodes" resultType="java.lang.Long">
        SELECT COUNT(1)
        FROM fin_voucher_entry
        WHERE subject_code IN
        <foreach collection="subjectCodes" item="item" open="(" separator="," close=")">
            #{item}
        </foreach>
    </select>
</mapper>
src/main/resources/mapper/account/financial/FinVoucherEntryMapper.xml
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.account.mapper.financial.FinVoucherEntryMapper">
    <resultMap id="BaseResultMap" type="com.ruoyi.account.pojo.financial.FinVoucherEntry">
        <id column="id" property="id"/>
        <result column="voucher_id" property="voucherId"/>
        <result column="row_no" property="rowNo"/>
        <result column="subject_code" property="subjectCode"/>
        <result column="subject_name" property="subjectName"/>
        <result column="summary" property="summary"/>
        <result column="debit" property="debit"/>
        <result column="credit" property="credit"/>
        <result column="auxiliary_type" property="auxiliaryType"/>
        <result column="auxiliary_id" property="auxiliaryId"/>
        <result column="auxiliary_name" property="auxiliaryName"/>
        <result column="create_user" property="createUser"/>
        <result column="create_time" property="createTime"/>
        <result column="update_user" property="updateUser"/>
        <result column="update_time" property="updateTime"/>
        <result column="dept_id" property="deptId"/>
    </resultMap>
    <select id="listPostedEntries" resultType="com.ruoyi.account.bean.vo.financial.FinLedgerEntryRecordVo">
        SELECT
            v.voucher_date AS voucherDate,
            v.voucher_no AS voucherNo,
            CASE
                WHEN e.summary IS NOT NULL AND e.summary != '' THEN e.summary
                ELSE v.summary
            END AS summary,
            e.debit AS debit,
            e.credit AS credit,
            e.row_no AS rowNo
        FROM fin_voucher_entry e
        INNER JOIN fin_voucher v ON e.voucher_id = v.id
        WHERE v.status = 'posted'
          AND (e.subject_code = #{subjectCode} OR e.subject_code LIKE CONCAT(#{subjectCode}, '%'))
          AND v.voucher_date <![CDATA[>=]]> #{startDate}
          AND v.voucher_date <![CDATA[<=]]> #{endDate}
        <if test="auxiliaryType != null and auxiliaryType != ''">
          AND e.auxiliary_type = #{auxiliaryType}
        </if>
        <if test="auxiliaryId != null and auxiliaryId != ''">
          AND e.auxiliary_id = #{auxiliaryId}
        </if>
        ORDER BY v.voucher_date ASC, v.id ASC, e.row_no ASC, e.id ASC
    </select>
    <select id="listPostedEntriesBefore" resultType="com.ruoyi.account.bean.vo.financial.FinLedgerEntryRecordVo">
        SELECT
            v.voucher_date AS voucherDate,
            v.voucher_no AS voucherNo,
            CASE
                WHEN e.summary IS NOT NULL AND e.summary != '' THEN e.summary
                ELSE v.summary
            END AS summary,
            e.debit AS debit,
            e.credit AS credit,
            e.row_no AS rowNo
        FROM fin_voucher_entry e
        INNER JOIN fin_voucher v ON e.voucher_id = v.id
        WHERE v.status = 'posted'
          AND (e.subject_code = #{subjectCode} OR e.subject_code LIKE CONCAT(#{subjectCode}, '%'))
          AND v.voucher_date <![CDATA[<]]> #{beforeDate}
        <if test="auxiliaryType != null and auxiliaryType != ''">
          AND e.auxiliary_type = #{auxiliaryType}
        </if>
        <if test="auxiliaryId != null and auxiliaryId != ''">
          AND e.auxiliary_id = #{auxiliaryId}
        </if>
    </select>
</mapper>