Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Contribute to GitLab
Sign in
Toggle navigation
A
an-plus-hr-ai
Project
Project
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
anjia-hr
an-plus-hr-ai
Commits
6ceb318f
Commit
6ceb318f
authored
Sep 18, 2026
by
刘斌
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fix: 重构消费记录为父子表结构
parent
5e5f9568
Hide whitespace changes
Inline
Side-by-side
Showing
21 changed files
with
1352 additions
and
474 deletions
+1352
-474
AiConsumeProjectCtrl.java
...ava/com/anplus/hr/ai/controller/AiConsumeProjectCtrl.java
+150
-0
AiConsumeProject.java
.../java/com/anplus/hr/ai/domain/model/AiConsumeProject.java
+72
-0
AiConsumeRecord.java
...n/java/com/anplus/hr/ai/domain/model/AiConsumeRecord.java
+9
-31
AiConsumeProjectListParam.java
...anplus/hr/ai/domain/params/AiConsumeProjectListParam.java
+50
-0
AiConsumeProjectParam.java
...com/anplus/hr/ai/domain/params/AiConsumeProjectParam.java
+17
-9
AiConsumeDetailVo.java
...in/java/com/anplus/hr/ai/domain/vo/AiConsumeDetailVo.java
+57
-0
AiConsumeExportVo.java
...in/java/com/anplus/hr/ai/domain/vo/AiConsumeExportVo.java
+114
-0
AiConsumeProjectVo.java
...n/java/com/anplus/hr/ai/domain/vo/AiConsumeProjectVo.java
+95
-0
AiConsumeProjectMapper.java
.../java/com/anplus/hr/ai/mapper/AiConsumeProjectMapper.java
+17
-0
AiConsumeRecordMapper.java
...n/java/com/anplus/hr/ai/mapper/AiConsumeRecordMapper.java
+8
-10
AiConsumeProjectServ.java
...n/java/com/anplus/hr/ai/service/AiConsumeProjectServ.java
+82
-0
AiConsumeRecordServ.java
...in/java/com/anplus/hr/ai/service/AiConsumeRecordServ.java
+0
-85
AiConsumeProjectServImpl.java
...m/anplus/hr/ai/service/impl/AiConsumeProjectServImpl.java
+416
-0
AiConsumeRecordServImpl.java
...om/anplus/hr/ai/service/impl/AiConsumeRecordServImpl.java
+0
-254
AiConsumeProjectMapper.xml
admin/src/main/resources/mapper/AiConsumeProjectMapper.xml
+7
-0
AiConsumeRecordMapper.xml
admin/src/main/resources/mapper/AiConsumeRecordMapper.xml
+4
-5
index.ts
frontend/src/api/ai/consume/index.ts
+6
-15
model.d.ts
frontend/src/api/ai/consume/model.d.ts
+53
-32
consume-drawer.vue
frontend/src/views/ai/consume/consume-drawer.vue
+53
-19
data.ts
frontend/src/views/ai/consume/data.ts
+82
-10
index.vue
frontend/src/views/ai/consume/index.vue
+60
-4
No files found.
admin/src/main/java/com/anplus/hr/ai/controller/AiConsume
Record
Ctrl.java
→
admin/src/main/java/com/anplus/hr/ai/controller/AiConsume
Project
Ctrl.java
View file @
6ceb318f
...
@@ -11,11 +11,12 @@ import jakarta.validation.constraints.NotEmpty;
...
@@ -11,11 +11,12 @@ import jakarta.validation.constraints.NotEmpty;
import
lombok.RequiredArgsConstructor
;
import
lombok.RequiredArgsConstructor
;
import
org.springframework.validation.annotation.Validated
;
import
org.springframework.validation.annotation.Validated
;
import
org.springframework.web.bind.annotation.*
;
import
org.springframework.web.bind.annotation.*
;
import
com.anplus.hr.ai.domain.params.AiConsumeRecordListParam
;
import
com.anplus.hr.ai.domain.params.AiConsumeProjectListParam
;
import
com.anplus.hr.ai.domain.params.AiConsumeRecordParam
;
import
com.anplus.hr.ai.domain.params.AiConsumeProjectParam
;
import
com.anplus.hr.ai.domain.vo.AiConsumeExportVo
;
import
com.anplus.hr.ai.domain.vo.AiConsumeProjectVo
;
import
com.anplus.hr.ai.domain.vo.AiConsumeRecordSummaryVo
;
import
com.anplus.hr.ai.domain.vo.AiConsumeRecordSummaryVo
;
import
com.anplus.hr.ai.domain.vo.AiConsumeRecordVo
;
import
com.anplus.hr.ai.service.AiConsumeProjectServ
;
import
com.anplus.hr.ai.service.AiConsumeRecordServ
;
import
top.binfast.app.biz.sysapi.bean.params.sysDept.SysDeptParam
;
import
top.binfast.app.biz.sysapi.bean.params.sysDept.SysDeptParam
;
import
top.binfast.app.biz.sysbiz.service.SysDeptServ
;
import
top.binfast.app.biz.sysbiz.service.SysDeptServ
;
import
top.binfast.common.core.constant.BusinessType
;
import
top.binfast.common.core.constant.BusinessType
;
...
@@ -26,122 +27,116 @@ import top.binfast.common.log.annotation.PinSysLog;
...
@@ -26,122 +27,116 @@ import top.binfast.common.log.annotation.PinSysLog;
import
java.util.List
;
import
java.util.List
;
/**
/**
* AI工具消费记录
* AI工具消费记录(父子表树形聚合)
*
* <p>父表 {@code ai_consume_project} + 子表 {@code ai_consume_record} 模型下的消费记录接口层:树形分页、
* 底部合计、项目详情、主从新增/编辑、级联删除、扁平导出、部门树。注解/端点约定对齐
* {@code docs/reference-code/controller/SysPostCtrl.java} 与已删旧 {@code AiConsumeRecordCtrl};
* 原 {@code GET /ai/consume/batch/{batchNo}} 随 batch_no 模型废弃移除(由 {@code GET /ai/consume/{id}} 取代)。</p>
*
*
* @author LiuBin
* @author LiuBin
* @date 2026-09-
04
* @date 2026-09-
18
*/
*/
@Validated
@Validated
@RequiredArgsConstructor
@RequiredArgsConstructor
@RestController
@RestController
@RequestMapping
(
"/ai/consume"
)
@RequestMapping
(
"/ai/consume"
)
public
class
AiConsume
Record
Ctrl
{
public
class
AiConsume
Project
Ctrl
{
private
final
AiConsume
RecordServ
aiConsumeRecord
Serv
;
private
final
AiConsume
ProjectServ
aiConsumeProject
Serv
;
private
final
SysDeptServ
sysDeptServ
;
private
final
SysDeptServ
sysDeptServ
;
/**
/**
*
分页查询AI工具消费记录列表
。
*
树形分页查询消费项目列表(按父行计数,每父行内嵌明细 children)
。
*
*
* @param param 查询条件
* @param param 查询条件
* @return
AI工具消费记录
分页结果
* @return
消费项目树形
分页结果
*/
*/
@SaCheckPermission
(
"ai:consume:list"
)
@SaCheckPermission
(
"ai:consume:list"
)
@GetMapping
(
"/list"
)
@GetMapping
(
"/list"
)
public
PageResponse
<
AiConsume
RecordVo
>
pageList
(
AiConsumeRecord
ListParam
param
)
{
public
PageResponse
<
AiConsume
ProjectVo
>
pageList
(
AiConsumeProject
ListParam
param
)
{
return
aiConsume
Record
Serv
.
queryPageList
(
param
);
return
aiConsume
Project
Serv
.
queryPageList
(
param
);
}
}
/**
/**
* 查询当前筛选条件下的金额合计(跨所有分页的全量结果,口径与列表完全一致)。
* 查询当前筛选条件下的
项目总
金额合计(跨所有分页的全量结果,口径与列表完全一致)。
*
*
* @param param 查询条件(与分页查询共用同一条件对象)
* @param param 查询条件(与分页查询共用同一条件对象)
* @return 合计载体(amountCny 保留两位小数;无匹配数据时为 0.00)
* @return 合计载体(amountCny 保留两位小数;无匹配数据时为 0.00)
*/
*/
@SaCheckPermission
(
"ai:consume:list"
)
@SaCheckPermission
(
"ai:consume:list"
)
@GetMapping
(
"/summary"
)
@GetMapping
(
"/summary"
)
public
SingleResponse
<
AiConsumeRecordSummaryVo
>
summary
(
AiConsume
Record
ListParam
param
)
{
public
SingleResponse
<
AiConsumeRecordSummaryVo
>
summary
(
AiConsume
Project
ListParam
param
)
{
return
SingleResponse
.
of
(
aiConsume
Record
Serv
.
querySummary
(
param
));
return
SingleResponse
.
of
(
aiConsume
Project
Serv
.
querySummary
(
param
));
}
}
/**
/**
* 导出
AI工具消费记录列表
。
* 导出
消费记录(扁平明细行 + 父级汇总列重复 + 末尾合计行)
。
*
*
* @param param 查询条件
* @param param 查询条件
* @return 扁平导出行列表
*/
*/
@ExcelExport
@ExcelExport
@SaCheckPermission
(
"ai:consume:export"
)
@SaCheckPermission
(
"ai:consume:export"
)
@PinSysLog
(
value
=
"AI工具消费记录"
,
businessType
=
BusinessType
.
EXPORT
)
@PinSysLog
(
value
=
"AI工具消费记录"
,
businessType
=
BusinessType
.
EXPORT
)
@PostMapping
(
"/export"
)
@PostMapping
(
"/export"
)
public
List
<
AiConsume
RecordVo
>
export
(
AiConsumeRecord
ListParam
param
)
{
public
List
<
AiConsume
ExportVo
>
export
(
AiConsumeProject
ListParam
param
)
{
return
aiConsume
Record
Serv
.
queryList
(
param
);
return
aiConsume
Project
Serv
.
queryList
(
param
);
}
}
/**
/**
* 获取
AI工具消费记录详细信息
* 获取
消费项目详细信息(父行公共字段 + 全部明细子行,line_sort 升序)。
*
*
* @param id 主键
* @param id 项目id(project_id)
* @return 整项目详情
*/
*/
@SaCheckPermission
(
"ai:consume:query"
)
@SaCheckPermission
(
"ai:consume:query"
)
@GetMapping
(
"/{id}"
)
@GetMapping
(
"/{id}"
)
public
SingleResponse
<
AiConsumeRecordVo
>
getDetail
(
@PathVariable
@Min
(
1
)
public
SingleResponse
<
AiConsumeProjectVo
>
getDetail
(
@PathVariable
@Min
(
1
)
Long
id
)
{
Long
id
)
{
return
SingleResponse
.
of
(
aiConsumeProjectServ
.
queryById
(
id
));
return
SingleResponse
.
of
(
aiConsumeRecordServ
.
queryById
(
id
));
}
}
/**
/**
*
按批次号获取整批消费记录详情(主从批量编辑回填用
)。
*
新增消费项目(主从批量:项目级公共字段 + 明细数组,单一事务建父插子重算
)。
*
*
* @param batchNo 批次号
* @param param 消费项目参数
* @return 整批详情(公共字段 + details 明细列表,line_sort 升序)
*/
@SaCheckPermission
(
"ai:consume:query"
)
@GetMapping
(
"/batch/{batchNo}"
)
public
SingleResponse
<
AiConsumeRecordVo
>
getBatchDetail
(
@PathVariable
String
batchNo
)
{
return
SingleResponse
.
of
(
aiConsumeRecordServ
.
queryByBatchNo
(
batchNo
));
}
/**
* 新增AI工具消费记录。
*
* @param param AI工具消费记录参数
* @return 操作结果
* @return 操作结果
*/
*/
@SaCheckPermission
(
"ai:consume:add"
)
@SaCheckPermission
(
"ai:consume:add"
)
@PinSysLog
(
value
=
"AI工具消费记录"
,
businessType
=
BusinessType
.
INSERT
)
@PinSysLog
(
value
=
"AI工具消费记录"
,
businessType
=
BusinessType
.
INSERT
)
@PostMapping
()
@PostMapping
()
public
Response
add
(
@Validated
@RequestBody
AiConsume
Record
Param
param
)
{
public
Response
add
(
@Validated
@RequestBody
AiConsume
Project
Param
param
)
{
return
ResponseUtils
.
ofResult
(
aiConsume
Record
Serv
.
insertByParam
(
param
));
return
ResponseUtils
.
ofResult
(
aiConsume
Project
Serv
.
insertByParam
(
param
));
}
}
/**
/**
* 修改
AI工具消费记录
。
* 修改
消费项目(整批编辑:更新父行 + 明细全删重插 + 重算汇总,project_id 不变)
。
*
*
* @param param
AI工具消费记录
参数
* @param param
消费项目
参数
* @return 操作结果
* @return 操作结果
*/
*/
@SaCheckPermission
(
"ai:consume:edit"
)
@SaCheckPermission
(
"ai:consume:edit"
)
@PinSysLog
(
value
=
"AI工具消费记录"
,
businessType
=
BusinessType
.
UPDATE
)
@PinSysLog
(
value
=
"AI工具消费记录"
,
businessType
=
BusinessType
.
UPDATE
)
@PutMapping
()
@PutMapping
()
public
Response
edit
(
@Validated
@RequestBody
AiConsume
Record
Param
param
)
{
public
Response
edit
(
@Validated
@RequestBody
AiConsume
Project
Param
param
)
{
return
ResponseUtils
.
ofResult
(
aiConsume
Record
Serv
.
updateByParam
(
param
));
return
ResponseUtils
.
ofResult
(
aiConsume
Project
Serv
.
updateByParam
(
param
));
}
}
/**
/**
* 删除
AI工具消费记录
* 删除
消费项目(级联逻辑删除父行 + 其全部明细子行)。
*
*
* @param ids 主键串
* @param ids 项目id(project_id)串
* @return 操作结果
*/
*/
@SaCheckPermission
(
"ai:consume:remove"
)
@SaCheckPermission
(
"ai:consume:remove"
)
@PinSysLog
(
value
=
"AI工具消费记录"
,
businessType
=
BusinessType
.
DELETE
)
@PinSysLog
(
value
=
"AI工具消费记录"
,
businessType
=
BusinessType
.
DELETE
)
@DeleteMapping
(
"/{ids}"
)
@DeleteMapping
(
"/{ids}"
)
public
Response
remove
(
@NotEmpty
(
message
=
"主键不能为空"
)
public
Response
remove
(
@NotEmpty
(
message
=
"主键不能为空"
)
@PathVariable
Long
[]
ids
)
{
@PathVariable
Long
[]
ids
)
{
return
ResponseUtils
.
ofResult
(
aiConsumeProjectServ
.
delByIds
(
List
.
of
(
ids
)));
return
ResponseUtils
.
ofResult
(
aiConsumeRecordServ
.
delByIds
(
List
.
of
(
ids
)));
}
}
/**
/**
* 获取消费记录筛选用的部门树。
* 获取消费记录筛选用的部门树
(当前租户,含子部门子树)
。
*
*
* @param dept 部门查询条件
* @param dept 部门查询条件
* @return 部门树列表
* @return 部门树列表
...
@@ -151,4 +146,5 @@ public class AiConsumeRecordCtrl {
...
@@ -151,4 +146,5 @@ public class AiConsumeRecordCtrl {
public
MultiResponse
<
Tree
<
Long
>>
deptTree
(
SysDeptParam
dept
)
{
public
MultiResponse
<
Tree
<
Long
>>
deptTree
(
SysDeptParam
dept
)
{
return
MultiResponse
.
of
(
sysDeptServ
.
selectDeptTreeList
(
dept
));
return
MultiResponse
.
of
(
sysDeptServ
.
selectDeptTreeList
(
dept
));
}
}
}
}
admin/src/main/java/com/anplus/hr/ai/domain/model/AiConsumeProject.java
0 → 100644
View file @
6ceb318f
package
com
.
anplus
.
hr
.
ai
.
domain
.
model
;
import
top.binfast.common.mybatis.bean.model.TenantModel
;
import
com.baomidou.mybatisplus.annotation.TableName
;
import
lombok.Getter
;
import
lombok.Setter
;
import
lombok.EqualsAndHashCode
;
import
java.io.Serial
;
import
java.math.BigDecimal
;
import
java.time.LocalDate
;
/**
* AI工具消费项目对象(父表) ai_consume_project
*
* <p>承载一次录入提交的项目级公共字段(部门/项目名/姓名/消费时间/备注)与
* 派生汇总字段(成片时长/项目总金额/每分钟金额);明细工具消费存于子表
* {@link AiConsumeRecord},以 project_id 外键构成一对多。</p>
*
* @author LiuBin
* @date 2026-09-17
*/
@Getter
@Setter
@EqualsAndHashCode
(
callSuper
=
true
)
@TableName
(
"ai_consume_project"
)
public
class
AiConsumeProject
extends
TenantModel
{
@Serial
private
static
final
long
serialVersionUID
=
1L
;
/**
* 所属部门ID(关联sys_dept.id)
*/
private
Long
deptId
;
/**
* 项目名称(用户直接输入的自由文本)
*/
private
String
projectName
;
/**
* 使用人姓名(支持模糊查询)
*/
private
String
userName
;
/**
* 消费发生日期(父级,纯日期)
*/
private
LocalDate
consumeTime
;
/**
* 成片时长(秒),非负整数,无视频产出默认0(项目级单一录入)
*/
private
Integer
durationSeconds
;
/**
* 项目总金额(= Σ 子表明细金额,人民币;聚合重算逻辑属 C2)
*/
private
BigDecimal
amountCny
;
/**
* 每分钟金额(= 项目总金额×60÷成片时长,2位小数HALF_UP;成片时长为0时为NULL;派生计算属 C2)
*/
private
BigDecimal
amountPerMinute
;
/**
* 备注(父级)
*/
private
String
remark
;
}
admin/src/main/java/com/anplus/hr/ai/domain/model/AiConsumeRecord.java
View file @
6ceb318f
...
@@ -8,10 +8,13 @@ import lombok.EqualsAndHashCode;
...
@@ -8,10 +8,13 @@ import lombok.EqualsAndHashCode;
import
java.io.Serial
;
import
java.io.Serial
;
import
java.math.BigDecimal
;
import
java.math.BigDecimal
;
import
java.time.LocalDate
;
/**
/**
* AI工具消费记录对象 ai_consume_record
* AI工具消费记录明细对象(子表) ai_consume_record
*
* <p>父子表模型下的明细级记录:仅保留工具明细字段,项目级公共字段
* (部门/项目名/姓名/消费时间/备注)上提至父表 {@link AiConsumeProject},
* 以 projectId 外键关联。</p>
*
*
* @author LiuBin
* @author LiuBin
* @date 2026-09-04
* @date 2026-09-04
...
@@ -26,19 +29,9 @@ public class AiConsumeRecord extends TenantModel {
...
@@ -26,19 +29,9 @@ public class AiConsumeRecord extends TenantModel {
private
static
final
long
serialVersionUID
=
1L
;
private
static
final
long
serialVersionUID
=
1L
;
/**
/**
* 所属部门ID(关联sys_dept.id)
* 所属项目ID(关联 ai_consume_project.id)
*/
private
Long
deptId
;
/**
* 项目名称(用户直接输入的自由文本)
*/
*/
private
String
projectName
;
private
Long
projectId
;
/**
* 使用人姓名(支持模糊查询)
*/
private
String
userName
;
/**
/**
* AI工具/平台名称(用户直接输入的自由文本)
* AI工具/平台名称(用户直接输入的自由文本)
...
@@ -56,27 +49,12 @@ public class AiConsumeRecord extends TenantModel {
...
@@ -56,27 +49,12 @@ public class AiConsumeRecord extends TenantModel {
private
BigDecimal
consumePoints
;
private
BigDecimal
consumePoints
;
/**
/**
*
金额(人民币
)
*
单条明细金额(人民币;区别于父表 ai_consume_project.amount_cny 项目总金额,R6
)
*/
*/
private
BigDecimal
amountCny
;
private
BigDecimal
amountCny
;
/**
/**
* 消费发生时间
* 行序号(项目内明细从 1 连续排序)
*/
private
LocalDate
consumeTime
;
/**
* 备注
*/
private
String
remark
;
/**
* 批次号(主从批量录入:同批多行共享同一 batch_no,全局唯一)
*/
private
String
batchNo
;
/**
* 行序号(批次内从 1 连续自增)
*/
*/
private
Integer
lineSort
;
private
Integer
lineSort
;
...
...
admin/src/main/java/com/anplus/hr/ai/domain/params/AiConsume
Record
ListParam.java
→
admin/src/main/java/com/anplus/hr/ai/domain/params/AiConsume
Project
ListParam.java
View file @
6ceb318f
...
@@ -8,35 +8,43 @@ import java.util.HashMap;
...
@@ -8,35 +8,43 @@ import java.util.HashMap;
import
java.util.Map
;
import
java.util.Map
;
/**
/**
* AI工具消费记录分页对象 ai_consume_record
* AI工具消费项目分页对象 ai_consume_project
*
* <p>树形分页查询({@code GET /ai/consume/list})与合计查询({@code GET /ai/consume/summary})
* 共用本条件对象,确保两者筛选口径完全一致;分页作用于父表 ai_consume_project(按项目父行计数,
* 非按明细行计数)。搜索字段分父级(项目名/姓名/部门子树/消费时间范围)与明细(工具名,以 EXISTS
* 子查询定位命中父行)两类。</p>
*
*
* @author LiuBin
* @author LiuBin
* @date 2026-09-
04
* @date 2026-09-
18
*/
*/
@Getter
@Getter
@Setter
@Setter
public
class
AiConsume
Record
ListParam
extends
PageQueryParam
{
public
class
AiConsume
Project
ListParam
extends
PageQueryParam
{
/**
/**
* 项目名称(
用户直接输入的自由文本
)
* 项目名称(
父级,eq 精确匹配
)
*/
*/
private
String
projectName
;
private
String
projectName
;
/**
/**
* 使用人姓名(
支持
模糊查询)
* 使用人姓名(
父级,like
模糊查询)
*/
*/
private
String
userName
;
private
String
userName
;
/**
/**
* AI工具/平台名称(
用户直接输入的自由文本
)
* AI工具/平台名称(
明细字段,以 EXISTS 子查询 like 定位含匹配明细的父行
)
*/
*/
private
String
toolName
;
private
String
toolName
;
/**
/**
* 归属部门id(部门树,含子部门)
* 归属部门id(部门树,含子部门
子树
)
*/
*/
private
Long
belongDeptId
;
private
Long
belongDeptId
;
/**
* 其它查询参数(承载 beginTime/endTime 消费时间范围,纯日期边界)
*/
private
Map
<
String
,
Object
>
params
=
new
HashMap
<>();
private
Map
<
String
,
Object
>
params
=
new
HashMap
<>();
}
}
admin/src/main/java/com/anplus/hr/ai/domain/params/AiConsume
Record
Param.java
→
admin/src/main/java/com/anplus/hr/ai/domain/params/AiConsume
Project
Param.java
View file @
6ceb318f
...
@@ -4,6 +4,7 @@ import jakarta.validation.Valid;
...
@@ -4,6 +4,7 @@ import jakarta.validation.Valid;
import
jakarta.validation.constraints.NotBlank
;
import
jakarta.validation.constraints.NotBlank
;
import
jakarta.validation.constraints.NotEmpty
;
import
jakarta.validation.constraints.NotEmpty
;
import
jakarta.validation.constraints.NotNull
;
import
jakarta.validation.constraints.NotNull
;
import
jakarta.validation.constraints.PositiveOrZero
;
import
jakarta.validation.constraints.Size
;
import
jakarta.validation.constraints.Size
;
import
lombok.Data
;
import
lombok.Data
;
...
@@ -11,22 +12,23 @@ import java.time.LocalDate;
...
@@ -11,22 +12,23 @@ import java.time.LocalDate;
import
java.util.List
;
import
java.util.List
;
/**
/**
* AI工具消费
记录业务对象(主从批量录入)ai_consume_record
* AI工具消费
项目业务对象(父子表项目级录入)ai_consume_project
*
*
* <p>上部公共字段对整批生效,下部 {@code details} 为明细行列表;新增时 {@code batchNo}
* <p>上部项目级公共字段对整个项目生效,下部 {@code details} 为明细行列表;新增时
* 为空由服务端生成,编辑时携带 {@code batchNo} 定位原批(整批全删重插)。校验注解均不带
* {@code projectId} 为空、由数据库自增生成,编辑时携带 {@code projectId} 定位原项目
* groups,配合 Controller 的 {@code @Validated} 默认组生效(对齐既有 Param 风格)。</p>
* (父行公共字段更新 + 明细整批全删重插)。校验注解均不带 groups,配合 Controller 的
* {@code @Validated} 默认组生效(对齐既有 Param 风格)。</p>
*
*
* @author LiuBin
* @author LiuBin
* @date 2026-09-
04
* @date 2026-09-
18
*/
*/
@Data
@Data
public
class
AiConsume
Record
Param
{
public
class
AiConsume
Project
Param
{
/**
/**
*
批次号(编辑时定位原批;新增时为空、由服务端
生成)
*
项目ID(父行主键;编辑时定位原项目,新增时为空由数据库自增
生成)
*/
*/
private
String
batchNo
;
private
Long
projectId
;
/**
/**
* 所属部门ID(关联sys_dept.id)
* 所属部门ID(关联sys_dept.id)
...
@@ -47,11 +49,17 @@ public class AiConsumeRecordParam {
...
@@ -47,11 +49,17 @@ public class AiConsumeRecordParam {
private
String
userName
;
private
String
userName
;
/**
/**
* 消费发生
时间
* 消费发生
日期(父级,纯日期)
*/
*/
@NotNull
(
message
=
"消费发生时间不能为空"
)
@NotNull
(
message
=
"消费发生时间不能为空"
)
private
LocalDate
consumeTime
;
private
LocalDate
consumeTime
;
/**
* 成片时长(秒):项目级单一录入,非负整数,无视频产出默认0
*/
@PositiveOrZero
(
message
=
"成片时长不能为负数"
)
private
Integer
durationSeconds
;
/**
/**
* 备注
* 备注
*/
*/
...
...
admin/src/main/java/com/anplus/hr/ai/domain/vo/AiConsumeDetailVo.java
0 → 100644
View file @
6ceb318f
package
com
.
anplus
.
hr
.
ai
.
domain
.
vo
;
import
io.github.linpeilie.annotations.AutoMapper
;
import
lombok.Data
;
import
com.anplus.hr.ai.domain.model.AiConsumeRecord
;
import
java.io.Serial
;
import
java.io.Serializable
;
import
java.math.BigDecimal
;
/**
* AI工具消费记录明细子行视图对象 ai_consume_record
*
* <p>树形分页({@code GET /ai/consume/list})与项目详情({@code GET /ai/consume/{id}})响应中,
* 内嵌于父行 {@code AiConsumeProjectVo.children} 的明细子行;按 line_sort 升序。</p>
*
* @author LiuBin
* @date 2026-09-18
*/
@Data
@AutoMapper
(
target
=
AiConsumeRecord
.
class
)
public
class
AiConsumeDetailVo
implements
Serializable
{
@Serial
private
static
final
long
serialVersionUID
=
1L
;
/**
* 明细主键ID
*/
private
Long
id
;
/**
* AI工具/平台名称(用户直接输入的自由文本)
*/
private
String
toolName
;
/**
* 国内/国外(中文原文,固定二值)
*/
private
String
region
;
/**
* 消耗积分
*/
private
BigDecimal
consumePoints
;
/**
* 单条明细金额(人民币;区别于父表项目总金额)
*/
private
BigDecimal
amountCny
;
/**
* 行序号(项目内明细从 1 连续排序)
*/
private
Integer
lineSort
;
}
admin/src/main/java/com/anplus/hr/ai/domain/vo/AiConsumeExportVo.java
0 → 100644
View file @
6ceb318f
package
com
.
anplus
.
hr
.
ai
.
domain
.
vo
;
import
lombok.Data
;
import
org.apache.fesod.sheet.annotation.ExcelIgnoreUnannotated
;
import
org.apache.fesod.sheet.annotation.ExcelProperty
;
import
java.io.Serial
;
import
java.io.Serializable
;
import
java.math.BigDecimal
;
import
java.time.LocalDate
;
/**
* AI工具消费记录扁平导出行视图对象(导出专用,D8)
*
* <p>导出({@code POST /ai/consume/export})将父子表 JOIN 展开为扁平明细行:每行 = 一条明细子行
* + 该项目父级汇总列(所属部门 / 项目名称 / 姓名 / 消费时间 / 项目总金额 / 成片时长 / 每分钟金额 /
* 备注)重复展示,其后接明细列(AI工具名称 / 国内外 / 消耗积分 / 金额)。列序与标签对齐 spec
* 「导出末尾合计行」,并保持与既有导出列口径稳定;部门名由 ServiceImpl 批量回填(导出链不触发
* JSON 翻译)。末尾合计行:所属部门 = 「合计」、项目总金额 = 全量命中明细金额合计,其余列(含「金额(人民币)」
* 明细列)留空。成片时长列以分秒文本输出(spec「导出成片时长格式化」)。</p>
*
* <p>非导出辅助字段 {@code deptId} 无 {@code @ExcelProperty},被 {@code @ExcelIgnoreUnannotated}
* 忽略,仅用于批量查询部门名回填 {@code deptName}。两个金额列语义不同:{@code amountCny} 为父级
* 项目总金额(每明细行重复),{@code detailAmountCny} 为单条明细金额。</p>
*
* @author LiuBin
* @date 2026-09-18
*/
@Data
@ExcelIgnoreUnannotated
public
class
AiConsumeExportVo
implements
Serializable
{
@Serial
private
static
final
long
serialVersionUID
=
1L
;
/**
* 所属部门ID(非导出列,仅用于批量回填部门名)
*/
private
Long
deptId
;
/**
* 所属部门名称(父级汇总列,导出链批量回填;合计行固定填「合计」)
*/
@ExcelProperty
(
value
=
"所属部门"
)
private
String
deptName
;
/**
* 项目名称(父级汇总列,每明细行重复)
*/
@ExcelProperty
(
value
=
"项目名称"
)
private
String
projectName
;
/**
* 使用人姓名(父级汇总列,每明细行重复)
*/
@ExcelProperty
(
value
=
"姓名"
)
private
String
userName
;
/**
* 消费时间(父级汇总列,纯日期 YYYY-MM-DD,每明细行重复)
*/
@ExcelProperty
(
value
=
"消费时间"
)
private
LocalDate
consumeTime
;
/**
* 项目总金额(父级汇总列,= Σ 子表明细金额,每明细行重复;末尾合计行此列填全量命中明细金额合计)
*/
@ExcelProperty
(
value
=
"项目总金额"
)
private
BigDecimal
amountCny
;
/**
* 成片时长(父级汇总列,每明细行重复):导出层格式化为分秒文本({@code >60 秒→「X分Y秒」、
* 1..60 秒→「X秒」、0→「0秒」、NULL→留白}),对齐前端列表 durationText 约定(spec「导出成片时长格式化」)
*/
@ExcelProperty
(
value
=
"成片时长"
)
private
String
durationText
;
/**
* 每分钟金额(父级汇总列,= 项目总金额×60÷成片时长,每明细行重复)
*/
@ExcelProperty
(
value
=
"每分钟金额"
)
private
BigDecimal
amountPerMinute
;
/**
* 备注(父级汇总列,每明细行重复)
*/
@ExcelProperty
(
value
=
"备注"
)
private
String
remark
;
/**
* AI工具/平台名称(明细列)
*/
@ExcelProperty
(
value
=
"AI工具/平台名称"
)
private
String
toolName
;
/**
* 国内/国外(明细列,中文原文)
*/
@ExcelProperty
(
value
=
"国内/国外"
)
private
String
region
;
/**
* 消耗积分(明细列)
*/
@ExcelProperty
(
value
=
"消耗积分"
)
private
BigDecimal
consumePoints
;
/**
* 金额(人民币)(明细列,单条明细金额;末尾合计行此列留空)
*/
@ExcelProperty
(
value
=
"金额(人民币)"
)
private
BigDecimal
detailAmountCny
;
}
admin/src/main/java/com/anplus/hr/ai/domain/vo/AiConsume
Record
Vo.java
→
admin/src/main/java/com/anplus/hr/ai/domain/vo/AiConsume
Project
Vo.java
View file @
6ceb318f
...
@@ -4,8 +4,7 @@ import com.fasterxml.jackson.annotation.JsonInclude;
...
@@ -4,8 +4,7 @@ import com.fasterxml.jackson.annotation.JsonInclude;
import
io.github.linpeilie.annotations.AutoMapper
;
import
io.github.linpeilie.annotations.AutoMapper
;
import
lombok.Data
;
import
lombok.Data
;
import
org.apache.fesod.sheet.annotation.ExcelIgnoreUnannotated
;
import
org.apache.fesod.sheet.annotation.ExcelIgnoreUnannotated
;
import
org.apache.fesod.sheet.annotation.ExcelProperty
;
import
com.anplus.hr.ai.domain.model.AiConsumeProject
;
import
com.anplus.hr.ai.domain.model.AiConsumeRecord
;
import
top.binfast.common.translation.annotation.Translation
;
import
top.binfast.common.translation.annotation.Translation
;
import
top.binfast.common.translation.constant.TransConstant
;
import
top.binfast.common.translation.constant.TransConstant
;
...
@@ -16,36 +15,29 @@ import java.time.LocalDate;
...
@@ -16,36 +15,29 @@ import java.time.LocalDate;
import
java.time.LocalDateTime
;
import
java.time.LocalDateTime
;
import
java.util.List
;
import
java.util.List
;
/**
/**
* AI工具消费记录视图对象 ai_consume_record
* AI工具消费项目父行视图对象 ai_consume_project
*
* <p>树形分页/项目详情响应的父行:项目级公共字段 + 派生汇总(项目总金额/成片时长/每分钟金额)
* + 部门名翻译 + 内嵌明细子行 {@code children}。{@code children} 与 {@code deptName} 由 Service
* 手动组装/翻译(非 MapStruct 自动嵌套),列表与详情之外为 null 且不参与 JSON 序列化。</p>
*
*
* @author LiuBin
* @author LiuBin
* @date 2026-09-
04
* @date 2026-09-
18
*/
*/
@Data
@Data
@ExcelIgnoreUnannotated
@ExcelIgnoreUnannotated
@AutoMapper
(
target
=
AiConsume
Record
.
class
)
@AutoMapper
(
target
=
AiConsume
Project
.
class
)
public
class
AiConsume
Record
Vo
implements
Serializable
{
public
class
AiConsume
Project
Vo
implements
Serializable
{
@Serial
@Serial
private
static
final
long
serialVersionUID
=
1L
;
private
static
final
long
serialVersionUID
=
1L
;
/**
/**
*
主键ID
*
项目ID(父行主键;详情/编辑/删除以此定位项目)
*/
*/
private
Long
id
;
private
Long
id
;
/**
* 批次号(主从批量录入元数据;非展示、非导出,前端编辑流按此定位整批)
*/
private
String
batchNo
;
/**
* 行序号(批次内从 1 连续;非展示、非导出)
*/
private
Integer
lineSort
;
/**
/**
* 所属部门ID(关联sys_dept.id)
* 所属部门ID(关联sys_dept.id)
*/
*/
...
@@ -55,64 +47,49 @@ public class AiConsumeRecordVo implements Serializable {
...
@@ -55,64 +47,49 @@ public class AiConsumeRecordVo implements Serializable {
* 所属部门名称(@Translation 列表自动翻译;导出由 ServiceImpl 批量回填)
* 所属部门名称(@Translation 列表自动翻译;导出由 ServiceImpl 批量回填)
*/
*/
@Translation
(
type
=
TransConstant
.
DEPT_ID_TO_NAME
,
mapper
=
"deptId"
)
@Translation
(
type
=
TransConstant
.
DEPT_ID_TO_NAME
,
mapper
=
"deptId"
)
@ExcelProperty
(
value
=
"所属部门"
)
private
String
deptName
;
private
String
deptName
;
/**
/**
* 项目名称(用户直接输入的自由文本)
* 项目名称(用户直接输入的自由文本)
*/
*/
@ExcelProperty
(
value
=
"项目名称"
)
private
String
projectName
;
private
String
projectName
;
/**
/**
* 使用人姓名
* 使用人姓名
*/
*/
@ExcelProperty
(
value
=
"姓名"
)
private
String
userName
;
private
String
userName
;
/**
/**
*
AI工具/平台名称(用户直接输入的自由文本
)
*
消费发生日期(父级,纯日期
)
*/
*/
@ExcelProperty
(
value
=
"AI工具/平台名称"
)
private
LocalDate
consumeTime
;
private
String
toolName
;
/**
* 国内/国外(中文原文,固定二值)
*/
@ExcelProperty
(
value
=
"国内/国外"
)
private
String
region
;
/**
/**
*
消耗积分
*
项目总金额(= Σ 子表明细金额,事务重算冗余存储,人民币)
*/
*/
@ExcelProperty
(
value
=
"消耗积分"
)
private
BigDecimal
amountCny
;
private
BigDecimal
consumePoints
;
/**
/**
*
金额(人民币
)
*
成片时长(秒
)
*/
*/
@ExcelProperty
(
value
=
"金额(人民币)"
)
private
Integer
durationSeconds
;
private
BigDecimal
amountCny
;
/**
/**
*
消费发生时间
*
每分钟金额(= 项目总金额×60÷成片时长,2位小数HALF_UP;成片时长为0时NULL)
*/
*/
@ExcelProperty
(
value
=
"消费时间"
)
private
BigDecimal
amountPerMinute
;
private
LocalDate
consumeTime
;
/**
/**
* 备注
* 备注
(父级)
*/
*/
@ExcelProperty
(
value
=
"备注"
)
private
String
remark
;
private
String
remark
;
private
LocalDateTime
createTime
;
private
LocalDateTime
createTime
;
/**
/**
* 明细行列表(仅整批详情接口 GET /ai/consume/batch/{batchNo} 返回;
* 明细子行列表(树形分页/详情内嵌返回,line_sort 升序;为 null 时不参与 JSON 序列化)
* 列表与单条详情为 null 且不参与 JSON 序列化;非展示、非导出)
*/
*/
@JsonInclude
(
JsonInclude
.
Include
.
NON_NULL
)
@JsonInclude
(
JsonInclude
.
Include
.
NON_NULL
)
private
List
<
AiConsume
RecordVo
>
details
;
private
List
<
AiConsume
DetailVo
>
children
;
}
}
admin/src/main/java/com/anplus/hr/ai/mapper/AiConsumeProjectMapper.java
0 → 100644
View file @
6ceb318f
package
com
.
anplus
.
hr
.
ai
.
mapper
;
import
com.github.yulichang.base.MPJBaseMapper
;
import
org.apache.ibatis.annotations.Mapper
;
import
com.anplus.hr.ai.domain.model.AiConsumeProject
;
import
top.binfast.common.mybatis.mapper.BinBaseMapper
;
/**
* AI工具消费项目Mapper接口(父表)
*
* @author LiuBin
* @date 2026-09-17
*/
@Mapper
public
interface
AiConsumeProjectMapper
extends
BinBaseMapper
<
AiConsumeProject
>,
MPJBaseMapper
<
AiConsumeProject
>
{
}
admin/src/main/java/com/anplus/hr/ai/mapper/AiConsumeRecordMapper.java
View file @
6ceb318f
...
@@ -19,28 +19,26 @@ import java.util.List;
...
@@ -19,28 +19,26 @@ import java.util.List;
public
interface
AiConsumeRecordMapper
extends
BinBaseMapper
<
AiConsumeRecord
>,
MPJBaseMapper
<
AiConsumeRecord
>
{
public
interface
AiConsumeRecordMapper
extends
BinBaseMapper
<
AiConsumeRecord
>,
MPJBaseMapper
<
AiConsumeRecord
>
{
/**
/**
* 按
批次号查询整批
记录(line_sort 升序)。
* 按
项目ID查询明细
记录(line_sort 升序)。
*
*
* <p>租户过滤由全局 TenantLineInnerInterceptor 自动注入,逻辑删除行由 @TableLogic 自动排除。</p>
* <p>租户过滤由全局 TenantLineInnerInterceptor 自动注入,逻辑删除行由 @TableLogic 自动排除。</p>
*
*
* @param
batchNo 批次号
* @param
projectId 所属项目ID
* @return 该
批次下的全部有效记录
,按行序号升序
* @return 该
项目下的全部有效明细
,按行序号升序
*/
*/
default
List
<
AiConsumeRecord
>
selectBy
BatchNo
(
String
batchNo
)
{
default
List
<
AiConsumeRecord
>
selectBy
ProjectId
(
Long
projectId
)
{
return
this
.
selectList
(
new
LambdaQueryWrapper
<
AiConsumeRecord
>()
return
this
.
selectList
(
new
LambdaQueryWrapper
<
AiConsumeRecord
>()
.
eq
(
AiConsumeRecord:
:
get
BatchNo
,
batchNo
)
.
eq
(
AiConsumeRecord:
:
get
ProjectId
,
projectId
)
.
orderByAsc
(
AiConsumeRecord:
:
getLineSort
));
.
orderByAsc
(
AiConsumeRecord:
:
getLineSort
));
}
}
/**
/**
* 按
批次号物理删除整批记录(编辑「整批全删重插」
用,绕过 @TableLogic 逻辑删除)。
* 按
项目ID物理删除该项目全部明细(编辑「整项目全删重插」/级联删除
用,绕过 @TableLogic 逻辑删除)。
*
*
* <p>SQL 显式带 tenant_id 条件(design.md D3 / R1),杜绝跨租户误删同 batch_no 行。</p>
*
*
* @param batchNo 批次号
* @param projectId 所属项目ID
* @param tenantId 当前租户ID
* @return 删除的行数
* @return 删除的行数
*/
*/
int
deleteBy
BatchNo
(
@Param
(
"batchNo"
)
String
batchNo
,
@Param
(
"tenantId"
)
Long
tenan
tId
);
int
deleteBy
ProjectId
(
@Param
(
"projectId"
)
Long
projec
tId
);
}
}
admin/src/main/java/com/anplus/hr/ai/service/AiConsumeProjectServ.java
0 → 100644
View file @
6ceb318f
package
com
.
anplus
.
hr
.
ai
.
service
;
import
com.alibaba.cola.dto.PageResponse
;
import
com.baomidou.mybatisplus.spring.service.IService
;
import
com.anplus.hr.ai.domain.model.AiConsumeProject
;
import
com.anplus.hr.ai.domain.params.AiConsumeProjectListParam
;
import
com.anplus.hr.ai.domain.params.AiConsumeProjectParam
;
import
com.anplus.hr.ai.domain.vo.AiConsumeExportVo
;
import
com.anplus.hr.ai.domain.vo.AiConsumeProjectVo
;
import
com.anplus.hr.ai.domain.vo.AiConsumeRecordSummaryVo
;
import
java.util.List
;
/**
* AI工具消费项目Service接口
*
* <p>父子表模型(父 {@code ai_consume_project} + 子 {@code ai_consume_record})下的消费记录应用层:
* 树形分页(父行内嵌明细 children)、项目总金额事务重算、每分钟金额派生、级联逻辑删除、扁平导出与
* 底部合计。原基于单表 {@code batch_no} 的 {@code AiConsumeRecordServ} 已随 C2 重构废弃删除。</p>
*
* @author LiuBin
* @date 2026-09-18
*/
public
interface
AiConsumeProjectServ
extends
IService
<
AiConsumeProject
>
{
/**
* 树形分页查询消费项目列表(按父行计数,每父行内嵌明细 children,line_sort 升序)
*
* @param param 查询条件(父级字段 + 明细 toolName 的 EXISTS 命中)
* @return 消费项目树形分页列表
*/
PageResponse
<
AiConsumeProjectVo
>
queryPageList
(
AiConsumeProjectListParam
param
);
/**
* 查询符合条件的消费记录扁平导出行(父子表 JOIN 展开,每明细一行 + 父级汇总列重复,末尾追加合计行)
*
* @param param 查询条件(与分页/合计共用同一条件对象)
* @return 扁平导出行列表(含末尾合计行)
*/
List
<
AiConsumeExportVo
>
queryList
(
AiConsumeProjectListParam
param
);
/**
* 查询当前条件下的项目总金额合计(跨所有分页的全量筛选结果,口径与列表完全一致)
*
* @param param 查询条件(与分页查询共用同一条件对象 AiConsumeProjectListParam)
* @return 合计载体(amountCny 保留两位小数;无匹配数据时为 0.00)
*/
AiConsumeRecordSummaryVo
querySummary
(
AiConsumeProjectListParam
param
);
/**
* 按项目id查询整项目详情(父行公共字段 + 全部明细子行,line_sort 升序)
*
* @param id 项目id(project_id)
* @return 整项目详情;项目不存在或跨租户返回 null
*/
AiConsumeProjectVo
queryById
(
Long
id
);
/**
* 新增消费项目(单一事务:建父 + 插子 line_sort 从 1 连续 + 重算汇总回写父表)
*
* @param param 消费项目(项目级公共字段 + 明细列表 details)
* @return 是否新增成功
*/
Boolean
insertByParam
(
AiConsumeProjectParam
param
);
/**
* 修改消费项目(单一事务:更新父行 + 明细全删重插 + 重算汇总,project_id 不变)
*
* @param param 消费项目(projectId + 项目级公共字段 + 明细列表 details)
* @return 是否修改成功
*/
Boolean
updateByParam
(
AiConsumeProjectParam
param
);
/**
* 校验并批量级联逻辑删除消费项目(父行 + 其全部明细子行均 @TableLogic 逻辑删除)
*
* @param ids 待删除的项目id(project_id)集合
* @return 是否删除成功
*/
Boolean
delByIds
(
List
<
Long
>
ids
);
}
admin/src/main/java/com/anplus/hr/ai/service/AiConsumeRecordServ.java
deleted
100644 → 0
View file @
5e5f9568
package
com
.
anplus
.
hr
.
ai
.
service
;
import
com.alibaba.cola.dto.PageResponse
;
import
com.baomidou.mybatisplus.spring.service.IService
;
import
com.anplus.hr.ai.domain.model.AiConsumeRecord
;
import
com.anplus.hr.ai.domain.params.AiConsumeRecordListParam
;
import
com.anplus.hr.ai.domain.params.AiConsumeRecordParam
;
import
com.anplus.hr.ai.domain.vo.AiConsumeRecordSummaryVo
;
import
com.anplus.hr.ai.domain.vo.AiConsumeRecordVo
;
import
java.util.List
;
/**
* AI工具消费记录Service接口
*
* @author LiuBin
* @date 2026-09-04
*/
public
interface
AiConsumeRecordServ
extends
IService
<
AiConsumeRecord
>
{
/**
* 分页查询AI工具消费记录列表
*
* @param param 查询条件
* @return AI工具消费记录分页列表
*/
PageResponse
<
AiConsumeRecordVo
>
queryPageList
(
AiConsumeRecordListParam
param
);
/**
* 查询符合条件的AI工具消费记录列表
*
* @param param 查询条件
* @return AI工具消费记录列表
*/
List
<
AiConsumeRecordVo
>
queryList
(
AiConsumeRecordListParam
param
);
/**
* 查询当前条件下的金额合计(跨所有分页的全量筛选结果,口径与列表完全一致)
*
* @param param 查询条件(与分页查询共用同一条件对象 AiConsumeRecordListParam)
* @return 合计载体(amountCny 保留两位小数;无匹配数据时为 0.00)
*/
AiConsumeRecordSummaryVo
querySummary
(
AiConsumeRecordListParam
param
);
/**
* 查询AI工具消费记录
*
* @param id 主键
* @return AI工具消费记录
*/
AiConsumeRecordVo
queryById
(
Long
id
);
/**
* 按批次号查询整批详情(公共字段 + 明细列表,line_sort 升序)
*
* @param batchNo 批次号
* @return 整批详情;批次不存在或跨租户返回 null
*/
AiConsumeRecordVo
queryByBatchNo
(
String
batchNo
);
/**
* 新增AI工具消费记录
*
* @param param AI工具消费记录
* @return 是否新增成功
*/
Boolean
insertByParam
(
AiConsumeRecordParam
param
);
/**
* 修改AI工具消费记录
*
* @param param AI工具消费记录
* @return 是否修改成功
*/
Boolean
updateByParam
(
AiConsumeRecordParam
param
);
/**
* 校验并批量删除AI工具消费记录信息
*
* @param ids 待删除的主键集合
* @return 是否删除成功
*/
Boolean
delByIds
(
List
<
Long
>
ids
);
}
admin/src/main/java/com/anplus/hr/ai/service/impl/AiConsumeProjectServImpl.java
0 → 100644
View file @
6ceb318f
package
com
.
anplus
.
hr
.
ai
.
service
.
impl
;
import
cn.hutool.core.collection.CollUtil
;
import
cn.hutool.core.convert.Convert
;
import
cn.hutool.core.util.ObjectUtil
;
import
cn.hutool.core.util.StrUtil
;
import
com.alibaba.cola.dto.PageResponse
;
import
com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper
;
import
com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper
;
import
com.baomidou.mybatisplus.extension.plugins.pagination.Page
;
import
com.baomidou.mybatisplus.spring.service.impl.ServiceImpl
;
import
lombok.RequiredArgsConstructor
;
import
org.springframework.stereotype.Service
;
import
org.springframework.transaction.annotation.Transactional
;
import
com.anplus.hr.ai.domain.model.AiConsumeProject
;
import
com.anplus.hr.ai.domain.model.AiConsumeRecord
;
import
com.anplus.hr.ai.domain.params.AiConsumeDetailParam
;
import
com.anplus.hr.ai.domain.params.AiConsumeProjectListParam
;
import
com.anplus.hr.ai.domain.params.AiConsumeProjectParam
;
import
com.anplus.hr.ai.domain.vo.AiConsumeDetailVo
;
import
com.anplus.hr.ai.domain.vo.AiConsumeExportVo
;
import
com.anplus.hr.ai.domain.vo.AiConsumeProjectVo
;
import
com.anplus.hr.ai.domain.vo.AiConsumeRecordSummaryVo
;
import
com.anplus.hr.ai.mapper.AiConsumeProjectMapper
;
import
com.anplus.hr.ai.mapper.AiConsumeRecordMapper
;
import
com.anplus.hr.ai.service.AiConsumeProjectServ
;
import
top.binfast.app.biz.sysapi.dao.auth.SysDeptMapper
;
import
top.binfast.app.biz.sysbiz.service.SysDeptServ
;
import
top.binfast.common.core.exception.PlatformException
;
import
top.binfast.common.core.util.MapstructUtils
;
import
top.binfast.common.mybatis.query.LambdaQueryBuilder
;
import
top.binfast.common.mybatis.query.QueryBuilder
;
import
top.binfast.common.mybatis.util.QueryUtil
;
import
java.math.BigDecimal
;
import
java.math.RoundingMode
;
import
java.util.ArrayList
;
import
java.util.Collections
;
import
java.util.List
;
import
java.util.Map
;
import
java.util.Set
;
import
java.util.stream.Collectors
;
/**
* AI工具消费项目Service业务层处理
*
* <p>父子表模型应用层:树形分页(两段式,父行分页 + project_id IN 批量查明细 + 内存组装 children)、
* 项目总金额事务重算、每分钟金额派生、级联逻辑删除、扁平导出 + 合计。继承/注解/注入风格对齐
* {@code docs/reference-code/service/impl/SysPostServImpl.java} 与已删旧 {@code AiConsumeRecordServImpl}。</p>
*
* @author LiuBin
* @date 2026-09-18
*/
@RequiredArgsConstructor
@Service
public
class
AiConsumeProjectServImpl
extends
ServiceImpl
<
AiConsumeProjectMapper
,
AiConsumeProject
>
implements
AiConsumeProjectServ
{
private
final
AiConsumeProjectMapper
aiConsumeProjectMapper
;
private
final
AiConsumeRecordMapper
aiConsumeRecordMapper
;
private
final
SysDeptMapper
deptMapper
;
private
final
SysDeptServ
sysDeptServ
;
/**
* 树形分页查询消费项目列表(按父行计数,每父行内嵌明细 children,line_sort 升序)
*
* @param param 查询条件
* @return 消费项目树形分页列表
*/
@Override
public
PageResponse
<
AiConsumeProjectVo
>
queryPageList
(
AiConsumeProjectListParam
param
)
{
//① 父行分页(按 ai_consume_project 计数;getPage 默认 id desc,叠加 consumeTime desc 稳定排序)
Page
<
AiConsumeProject
>
page
=
QueryUtil
.
getPage
(
param
);
LambdaQueryWrapper
<
AiConsumeProject
>
lambdaQuery
=
buildProjectCondition
(
param
)
.
orderByDesc
(
AiConsumeProject:
:
getConsumeTime
)
.
build
();
aiConsumeProjectMapper
.
selectPage
(
page
,
lambdaQuery
);
List
<
AiConsumeProject
>
parents
=
page
.
getRecords
();
List
<
AiConsumeProjectVo
>
voList
=
MapstructUtils
.
convert
(
parents
,
AiConsumeProjectVo
.
class
);
if
(
CollUtil
.
isNotEmpty
(
parents
))
{
//② project_id IN 批量查明细(toolName 命中时仅查匹配子行),line_sort 升序
List
<
Long
>
projectIds
=
parents
.
stream
().
map
(
AiConsumeProject:
:
getId
).
collect
(
Collectors
.
toList
());
List
<
AiConsumeRecord
>
details
=
aiConsumeRecordMapper
.
selectList
(
new
LambdaQueryWrapper
<
AiConsumeRecord
>()
.
in
(
AiConsumeRecord:
:
getProjectId
,
projectIds
)
.
like
(
StrUtil
.
isNotBlank
(
param
.
getToolName
()),
AiConsumeRecord:
:
getToolName
,
param
.
getToolName
())
.
orderByAsc
(
AiConsumeRecord:
:
getLineSort
));
//③ 内存按 project_id 分组组装 children(父级汇总列取父表冗余值,不随子行过滤重算,spec「明细工具名搜索」)
Map
<
Long
,
List
<
AiConsumeRecord
>>
childrenMap
=
details
.
stream
()
.
collect
(
Collectors
.
groupingBy
(
AiConsumeRecord:
:
getProjectId
));
voList
.
forEach
(
vo
->
vo
.
setChildren
(
MapstructUtils
.
convert
(
childrenMap
.
getOrDefault
(
vo
.
getId
(),
Collections
.
emptyList
()),
AiConsumeDetailVo
.
class
)));
}
//④ 分页响应(计数按父行)
return
QueryUtil
.
getPageResponse
(
page
,
voList
);
}
/**
* 查询符合条件的消费记录扁平导出行(父子表 JOIN 展开 + 末尾合计行)
*
* @param param 查询条件
* @return 扁平导出行列表(含末尾合计行)
*/
@Override
public
List
<
AiConsumeExportVo
>
queryList
(
AiConsumeProjectListParam
param
)
{
//① 全量命中父行(不分页;含 toolName EXISTS),consumeTime desc
LambdaQueryWrapper
<
AiConsumeProject
>
lambdaQuery
=
buildProjectCondition
(
param
)
.
orderByDesc
(
AiConsumeProject:
:
getConsumeTime
)
.
build
();
List
<
AiConsumeProject
>
parents
=
aiConsumeProjectMapper
.
selectList
(
lambdaQuery
);
List
<
AiConsumeExportVo
>
rows
=
new
ArrayList
<>();
//合计 = Σ 命中明细子行金额(口径与列表底部合计一致;无 toolName 时等于父表项目总金额之和);无数据落 ZERO 仍追加合计行
BigDecimal
total
=
BigDecimal
.
ZERO
;
if
(
CollUtil
.
isNotEmpty
(
parents
))
{
//② project_id IN 批量查明细(toolName 过滤),line_sort 升序
List
<
Long
>
projectIds
=
parents
.
stream
().
map
(
AiConsumeProject:
:
getId
).
collect
(
Collectors
.
toList
());
List
<
AiConsumeRecord
>
details
=
aiConsumeRecordMapper
.
selectList
(
new
LambdaQueryWrapper
<
AiConsumeRecord
>()
.
in
(
AiConsumeRecord:
:
getProjectId
,
projectIds
)
.
like
(
StrUtil
.
isNotBlank
(
param
.
getToolName
()),
AiConsumeRecord:
:
getToolName
,
param
.
getToolName
())
.
orderByAsc
(
AiConsumeRecord:
:
getLineSort
));
//合计 = Σ details.amountCny(details 即命中明细集,与扁平行一一对应;NULL 明细金额忽略)
total
=
details
.
stream
().
map
(
AiConsumeRecord:
:
getAmountCny
)
.
filter
(
ObjectUtil:
:
isNotNull
)
.
reduce
(
BigDecimal
.
ZERO
,
BigDecimal:
:
add
)
.
setScale
(
2
,
RoundingMode
.
HALF_UP
);
Map
<
Long
,
List
<
AiConsumeRecord
>>
detailsByProject
=
details
.
stream
()
.
collect
(
Collectors
.
groupingBy
(
AiConsumeRecord:
:
getProjectId
));
//③ 批量回填部门名(导出链不触发 JSON 翻译,spec「部门名称展示与导出翻译」)
Set
<
Long
>
deptIds
=
parents
.
stream
().
map
(
AiConsumeProject:
:
getDeptId
)
.
filter
(
ObjectUtil:
:
isNotNull
).
collect
(
Collectors
.
toSet
());
Map
<
Long
,
String
>
deptNames
=
CollUtil
.
isEmpty
(
deptIds
)
?
Collections
.
emptyMap
()
:
sysDeptServ
.
selectDeptNamesByIds
(
deptIds
);
//④ JOIN 展开扁平行:每明细一行 + 父级汇总列重复
for
(
AiConsumeProject
parent
:
parents
)
{
String
deptName
=
deptNames
.
get
(
parent
.
getDeptId
());
for
(
AiConsumeRecord
child
:
detailsByProject
.
getOrDefault
(
parent
.
getId
(),
Collections
.
emptyList
()))
{
AiConsumeExportVo
row
=
new
AiConsumeExportVo
();
row
.
setDeptId
(
parent
.
getDeptId
());
row
.
setDeptName
(
deptName
);
row
.
setProjectName
(
parent
.
getProjectName
());
row
.
setUserName
(
parent
.
getUserName
());
row
.
setConsumeTime
(
parent
.
getConsumeTime
());
row
.
setAmountCny
(
parent
.
getAmountCny
());
row
.
setDurationText
(
formatDuration
(
parent
.
getDurationSeconds
()));
row
.
setAmountPerMinute
(
parent
.
getAmountPerMinute
());
row
.
setRemark
(
parent
.
getRemark
());
row
.
setToolName
(
child
.
getToolName
());
row
.
setRegion
(
child
.
getRegion
());
row
.
setConsumePoints
(
child
.
getConsumePoints
());
row
.
setDetailAmountCny
(
child
.
getAmountCny
());
rows
.
add
(
row
);
}
}
}
//⑤ 末尾追加合计行(无数据时 total=0.00 仍追加,spec「导出文件末尾含合计行」)
rows
.
add
(
buildTotalRow
(
total
));
return
rows
;
}
/**
* 查询当前条件下的命中明细金额合计(跨所有分页的全量筛选结果,口径与列表/导出展示明细逐一等价)
*
* <p>合计 = Σ 命中明细子行 {@code ai_consume_record.amount_cny}:先复用 {@link #buildProjectCondition}
* 取命中父行 id 集合(含 toolName EXISTS 命中),再对子表按 {@code project_id IN + toolName LIKE} 做 SQL SUM。
* 按 toolName 过滤时合计仅覆盖匹配明细,不回退父行全项目总金额(spec「消费记录合计查询接口」)。</p>
*
* @param param 查询条件(与分页查询共用同一条件对象)
* @return 合计载体(amountCny 为明细金额合计,保留两位小数;无匹配数据时为 0.00)
*/
@Override
public
AiConsumeRecordSummaryVo
querySummary
(
AiConsumeProjectListParam
param
)
{
AiConsumeRecordSummaryVo
summary
=
new
AiConsumeRecordSummaryVo
();
//① 复用 buildProjectCondition 取命中父行(口径与列表/导出完全一致,含 toolName EXISTS 命中)
List
<
AiConsumeProject
>
parents
=
aiConsumeProjectMapper
.
selectList
(
buildProjectCondition
(
param
).
build
());
//② 命中父行为空 → 合计 0.00,避免 in(空集合) 退化并省去无谓子查询(design D6)
if
(
CollUtil
.
isEmpty
(
parents
))
{
summary
.
setAmountCny
(
BigDecimal
.
ZERO
.
setScale
(
2
,
RoundingMode
.
HALF_UP
));
return
summary
;
}
List
<
Long
>
projectIds
=
parents
.
stream
().
map
(
AiConsumeProject:
:
getId
).
collect
(
Collectors
.
toList
());
//③ SQL SUM 命中明细金额(project_id IN + toolName LIKE);租户与逻辑删除由 MP 拦截器对主表
// ai_consume_record 自动注入,不显式书写(design D2,对齐 recalcSummary);NULL 明细金额由 SUM 忽略
LambdaQueryWrapper
<
AiConsumeRecord
>
sumWrapper
=
QueryBuilder
.
lambda
(
AiConsumeRecord
.
class
)
.
in
(
AiConsumeRecord:
:
getProjectId
,
projectIds
)
.
likeIfText
(
AiConsumeRecord:
:
getToolName
,
param
.
getToolName
())
.
selectSum
(
AiConsumeRecord:
:
getAmountCny
)
.
build
();
//SUM 无 GROUP BY 恒返回单行;空结果集时该行为 null,落 ZERO(不受 pageNum/pageSize 影响)
List
<
Object
>
objs
=
aiConsumeRecordMapper
.
selectObjs
(
sumWrapper
);
BigDecimal
amountCny
=
CollUtil
.
isEmpty
(
objs
)
?
BigDecimal
.
ZERO
:
Convert
.
toBigDecimal
(
objs
.
get
(
0
),
BigDecimal
.
ZERO
);
summary
.
setAmountCny
(
amountCny
.
setScale
(
2
,
RoundingMode
.
HALF_UP
));
return
summary
;
}
/**
* 按项目id查询整项目详情(父行公共字段 + 全部明细子行,line_sort 升序)
*
* @param id 项目id(project_id)
* @return 整项目详情;项目不存在或跨租户返回 null
*/
@Override
public
AiConsumeProjectVo
queryById
(
Long
id
)
{
//按 project_id 查父行(selectById 自动注入 tenant_id + deleted=0,跨租户返回 null,spec「跨租户详情不可见」)
AiConsumeProject
project
=
aiConsumeProjectMapper
.
selectById
(
id
);
if
(
ObjectUtil
.
isNull
(
project
))
{
return
null
;
}
AiConsumeProjectVo
vo
=
MapstructUtils
.
convert
(
project
,
AiConsumeProjectVo
.
class
);
//明细子行 line_sort 升序(C1 selectByProjectId,租户/逻辑删自动过滤)
vo
.
setChildren
(
MapstructUtils
.
convert
(
aiConsumeRecordMapper
.
selectByProjectId
(
id
),
AiConsumeDetailVo
.
class
));
return
vo
;
}
/**
* 新增消费项目(单一事务:建父 + 插子 line_sort 从 1 连续 + 重算汇总回写父表)
*
* @param param 消费项目(项目级公共字段 + 明细列表 details)
* @return 是否新增成功
*/
@Override
@Transactional
(
rollbackFor
=
Exception
.
class
)
public
Boolean
insertByParam
(
AiConsumeProjectParam
param
)
{
//建父(save 后 MP 回填自增 project_id,R5)
AiConsumeProject
project
=
buildProject
(
param
);
this
.
save
(
project
);
Long
projectId
=
project
.
getId
();
//插子(line_sort 从 1 连续;逐条 insert 触发 MetaObjectHandler 填充 deleted/createTime + 租户注入)
buildRecords
(
projectId
,
param
.
getDetails
()).
forEach
(
aiConsumeRecordMapper:
:
insert
);
//重算汇总回写父表(建父 + 插子 + 重算同事务原子,spec「主从批量新增」)
recalcSummary
(
projectId
,
project
.
getDurationSeconds
());
return
Boolean
.
TRUE
;
}
/**
* 修改消费项目(单一事务:更新父行 + 明细全删重插 + 重算汇总,project_id 不变)
*
* @param param 消费项目(projectId + 项目级公共字段 + 明细列表 details)
* @return 是否修改成功
*/
@Override
@Transactional
(
rollbackFor
=
Exception
.
class
)
public
Boolean
updateByParam
(
AiConsumeProjectParam
param
)
{
Long
projectId
=
param
.
getProjectId
();
if
(
ObjectUtil
.
isNull
(
projectId
))
{
throw
new
PlatformException
(
"项目id不能为空"
);
}
//校验项目属当前租户(selectById 自动租户过滤,跨租户返回 null → 拒绝,宪法 §3)
if
(
ObjectUtil
.
isNull
(
aiConsumeProjectMapper
.
selectById
(
projectId
)))
{
throw
new
PlatformException
(
"消费项目不存在或无权编辑"
);
}
//更新父行公共字段
AiConsumeProject
project
=
buildProject
(
param
);
project
.
setId
(
projectId
);
this
.
updateById
(
project
);
//明细全删重插:物理删除原全部子行(显式带 tenant_id,编辑重插专用,与用户级联逻辑删分离,D6)
aiConsumeRecordMapper
.
deleteByProjectId
(
projectId
);
buildRecords
(
projectId
,
param
.
getDetails
()).
forEach
(
aiConsumeRecordMapper:
:
insert
);
//重算汇总回写父表(同事务,无中间态,spec「整批编辑全删重插」)
recalcSummary
(
projectId
,
project
.
getDurationSeconds
());
return
Boolean
.
TRUE
;
}
/**
* 校验并批量级联逻辑删除消费项目(父行 + 其全部明细子行均 @TableLogic 逻辑删除)
*
* @param ids 待删除的项目id(project_id)集合
* @return 是否删除成功
*/
@Override
@Transactional
(
rollbackFor
=
Exception
.
class
)
public
Boolean
delByIds
(
List
<
Long
>
ids
)
{
if
(
CollUtil
.
isEmpty
(
ids
))
{
return
Boolean
.
TRUE
;
}
//逻辑删父行(removeByIds 走 @TableLogic deleted=1;租户由拦截器过滤,跨租户不可删)
this
.
removeByIds
(
ids
);
//逻辑删全部子行(按 project_id delete 走 @TableLogic,非 deleteByProjectId 物理删,D6 语义分离)
aiConsumeRecordMapper
.
delete
(
new
LambdaQueryWrapper
<
AiConsumeRecord
>()
.
in
(
AiConsumeRecord:
:
getProjectId
,
ids
));
return
Boolean
.
TRUE
;
}
/**
* 按入参构建父行实体(项目级公共字段;成片时长 null 归零,与 DB 默认及每分钟金额派生口径一致)。
*
* @param param 入参
* @return 父行实体(未含 id / 派生汇总字段)
*/
private
AiConsumeProject
buildProject
(
AiConsumeProjectParam
param
)
{
AiConsumeProject
project
=
new
AiConsumeProject
();
project
.
setDeptId
(
param
.
getDeptId
());
project
.
setProjectName
(
param
.
getProjectName
());
project
.
setUserName
(
param
.
getUserName
());
project
.
setConsumeTime
(
param
.
getConsumeTime
());
project
.
setDurationSeconds
(
param
.
getDurationSeconds
()
==
null
?
0
:
param
.
getDurationSeconds
());
project
.
setRemark
(
param
.
getRemark
());
return
project
;
}
/**
* 按 project_id + 明细入参组装子行,line_sort 从 1 连续赋值(子表仅明细字段,公共字段在父表)。
*
* @param projectId 父行主键
* @param details 明细入参列表
* @return 子行实体列表
*/
private
List
<
AiConsumeRecord
>
buildRecords
(
Long
projectId
,
List
<
AiConsumeDetailParam
>
details
)
{
List
<
AiConsumeRecord
>
records
=
new
ArrayList
<>(
details
.
size
());
for
(
int
i
=
0
;
i
<
details
.
size
();
i
++)
{
AiConsumeDetailParam
detail
=
details
.
get
(
i
);
AiConsumeRecord
record
=
new
AiConsumeRecord
();
record
.
setProjectId
(
projectId
);
record
.
setLineSort
(
i
+
1
);
record
.
setToolName
(
detail
.
getToolName
());
record
.
setRegion
(
detail
.
getRegion
());
record
.
setConsumePoints
(
detail
.
getConsumePoints
());
record
.
setAmountCny
(
detail
.
getAmountCny
());
records
.
add
(
record
);
}
return
records
;
}
/**
* 全量重算项目总金额 + 派生每分钟金额并回写父表(design D4,Clarify Q2 全量 SUM 非增量)。
*
* <p>项目总金额 = SUM(子表 amount_cny WHERE project_id AND deleted=0)(租户由拦截器注入,NULL 落 ZERO);
* 每分钟金额 = 总金额×60÷成片时长(2 位 HALF_UP),成片时长为 0/null 时置 NULL(避免除零,spec「每分钟金额派生」)。
* 用 LambdaUpdateWrapper 显式 set 以强制写入 NULL(绕过 updateById 的 NOT_NULL 策略)。</p>
*
* @param projectId 项目id
* @param durationSeconds 成片时长(秒)
*/
private
void
recalcSummary
(
Long
projectId
,
Integer
durationSeconds
)
{
LambdaQueryWrapper
<
AiConsumeRecord
>
sumWrapper
=
QueryBuilder
.
lambda
(
AiConsumeRecord
.
class
)
.
eq
(
AiConsumeRecord:
:
getProjectId
,
projectId
)
.
selectSum
(
AiConsumeRecord:
:
getAmountCny
)
.
build
();
List
<
Object
>
objs
=
aiConsumeRecordMapper
.
selectObjs
(
sumWrapper
);
BigDecimal
total
=
(
CollUtil
.
isEmpty
(
objs
)
?
BigDecimal
.
ZERO
:
Convert
.
toBigDecimal
(
objs
.
get
(
0
),
BigDecimal
.
ZERO
))
.
setScale
(
2
,
RoundingMode
.
HALF_UP
);
BigDecimal
amountPerMinute
=
null
;
if
(
durationSeconds
!=
null
&&
durationSeconds
>
0
)
{
amountPerMinute
=
total
.
multiply
(
BigDecimal
.
valueOf
(
60
))
.
divide
(
BigDecimal
.
valueOf
(
durationSeconds
),
2
,
RoundingMode
.
HALF_UP
);
}
aiConsumeProjectMapper
.
update
(
null
,
new
LambdaUpdateWrapper
<
AiConsumeProject
>()
.
eq
(
AiConsumeProject:
:
getId
,
projectId
)
.
set
(
AiConsumeProject:
:
getAmountCny
,
total
)
.
set
(
AiConsumeProject:
:
getAmountPerMinute
,
amountPerMinute
));
}
/**
* 构建导出末尾合计行:所属部门列固定「合计」、项目总金额列填全量命中明细金额合计,其余列(含金额(人民币)明细列)留空(spec「导出末尾合计行」,design D5)。
*
* @param total 全量命中明细金额合计(两位小数)
* @return 合计行
*/
private
AiConsumeExportVo
buildTotalRow
(
BigDecimal
total
)
{
AiConsumeExportVo
totalRow
=
new
AiConsumeExportVo
();
totalRow
.
setDeptName
(
"合计"
);
totalRow
.
setAmountCny
(
total
);
return
totalRow
;
}
/**
* 导出成片时长格式化:秒值转分秒文本,严格对齐前端 data.ts durationText 约定(spec「消费记录导出成片时长格式化」,design D8)。
*
* <p>{@code null→""}(留白)、{@code s≤0→"0秒"}、{@code s>60→"X分Y秒"}、{@code 1..60→"X秒"}(含 60→"60秒")。
* 仅作用于导出,列表接口 {@code durationSeconds} 原始秒值不变。</p>
*
* @param seconds 成片时长(秒,父表 duration_seconds,可能为 null)
* @return 分秒文本
*/
private
String
formatDuration
(
Integer
seconds
)
{
if
(
seconds
==
null
)
{
return
""
;
}
int
s
=
seconds
;
if
(
s
<=
0
)
{
return
"0秒"
;
}
return
s
>
60
?
(
s
/
60
)
+
"分"
+
(
s
%
60
)
+
"秒"
:
s
+
"秒"
;
}
/**
* 构造父行查询条件(不含排序),供树形分页、导出与合计聚合共用,确保三者筛选口径完全一致(design D3/D9)。
*
* <p>逻辑删除({@code deleted=0})由 MP 拦截器
* 自动注入父表主查询;明细 {@code toolName} 以 EXISTS 子查询定位含匹配明细的父行,子查询内**显式书写**
* {@code r.deleted=0}(R3:不依赖拦截器对原生子查询的自动注入),并以 {@code {0}/{1}}
* 占位符参数化绑定(防 SQL 注入)。{@code toolName} 为用户自由文本,故 MUST 参数化而非字符串拼接。</p>
*
* @param param 查询条件
* @return 承载全部筛选条件(多租户 + 部门树子树 + 父级搜索 + 时间范围 + 明细 EXISTS)的 builder,不含 orderBy
*/
private
LambdaQueryBuilder
<
AiConsumeProject
>
buildProjectCondition
(
AiConsumeProjectListParam
param
)
{
Map
<
String
,
Object
>
params
=
param
.
getParams
();
return
QueryBuilder
.
lambda
(
AiConsumeProject
.
class
)
.
eqIfText
(
AiConsumeProject:
:
getProjectName
,
param
.
getProjectName
())
.
likeIfText
(
AiConsumeProject:
:
getUserName
,
param
.
getUserName
())
.
betweenParams
(
AiConsumeProject:
:
getConsumeTime
,
params
,
"beginTime"
,
"endTime"
)
//部门树子树筛选(含子部门,对齐旧 buildCondition 与 SysPostServImpl.buildQueryWrapper)
.
and
(
ObjectUtil
.
isNotNull
(
param
.
getBelongDeptId
()),
x
->
{
List
<
Long
>
deptIds
=
deptMapper
.
selectDeptAndChildById
(
param
.
getBelongDeptId
());
x
.
in
(
AiConsumeProject:
:
getDeptId
,
deptIds
);
})
//明细 toolName 过滤:EXISTS 子查询定位含匹配明细的父行(R3 显式 deleted ;{0}/{1} 参数化防注入)
.
exists
(
StrUtil
.
isNotBlank
(
param
.
getToolName
()),
"SELECT 1 FROM ai_consume_record r WHERE r.project_id = ai_consume_project.id "
+
"AND r.deleted = 0 AND r.tool_name LIKE {0}"
,
"%"
+
param
.
getToolName
()
+
"%"
);
}
}
admin/src/main/java/com/anplus/hr/ai/service/impl/AiConsumeRecordServImpl.java
deleted
100644 → 0
View file @
5e5f9568
package
com
.
anplus
.
hr
.
ai
.
service
.
impl
;
import
cn.hutool.core.collection.CollUtil
;
import
cn.hutool.core.convert.Convert
;
import
cn.hutool.core.util.ObjectUtil
;
import
cn.hutool.core.util.StrUtil
;
import
com.alibaba.cola.dto.PageResponse
;
import
com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper
;
import
com.baomidou.mybatisplus.extension.plugins.pagination.Page
;
import
com.baomidou.mybatisplus.spring.service.impl.ServiceImpl
;
import
lombok.RequiredArgsConstructor
;
import
org.springframework.stereotype.Service
;
import
org.springframework.transaction.annotation.Transactional
;
import
com.anplus.hr.ai.domain.model.AiConsumeRecord
;
import
com.anplus.hr.ai.domain.params.AiConsumeDetailParam
;
import
com.anplus.hr.ai.domain.params.AiConsumeRecordListParam
;
import
com.anplus.hr.ai.domain.params.AiConsumeRecordParam
;
import
com.anplus.hr.ai.domain.vo.AiConsumeRecordSummaryVo
;
import
com.anplus.hr.ai.domain.vo.AiConsumeRecordVo
;
import
com.anplus.hr.ai.mapper.AiConsumeRecordMapper
;
import
com.anplus.hr.ai.service.AiConsumeRecordServ
;
import
top.binfast.app.biz.sysapi.dao.auth.SysDeptMapper
;
import
top.binfast.app.biz.sysbiz.service.SysDeptServ
;
import
top.binfast.common.core.exception.PlatformException
;
import
top.binfast.common.core.util.MapstructUtils
;
import
top.binfast.common.mybatis.query.LambdaQueryBuilder
;
import
top.binfast.common.mybatis.query.QueryBuilder
;
import
top.binfast.common.mybatis.tenant.core.TenantHelper
;
import
top.binfast.common.mybatis.util.IdGeneratorUtil
;
import
top.binfast.common.mybatis.util.QueryUtil
;
import
java.math.BigDecimal
;
import
java.math.RoundingMode
;
import
java.util.ArrayList
;
import
java.util.List
;
import
java.util.Map
;
import
java.util.Set
;
import
java.util.stream.Collectors
;
/**
* AI工具消费记录Service业务层处理
*
* @author LiuBin
* @date 2026-09-04
*/
@RequiredArgsConstructor
@Service
public
class
AiConsumeRecordServImpl
extends
ServiceImpl
<
AiConsumeRecordMapper
,
AiConsumeRecord
>
implements
AiConsumeRecordServ
{
private
final
AiConsumeRecordMapper
aiConsumeRecordMapper
;
private
final
SysDeptMapper
deptMapper
;
private
final
SysDeptServ
sysDeptServ
;
/**
* 分页查询AI工具消费记录列表
*
* @param param 查询条件
* @return AI工具消费记录分页列表
*/
@Override
public
PageResponse
<
AiConsumeRecordVo
>
queryPageList
(
AiConsumeRecordListParam
param
)
{
Page
<
AiConsumeRecord
>
page
=
QueryUtil
.
getPage
(
param
);
LambdaQueryWrapper
<
AiConsumeRecord
>
lambdaQuery
=
this
.
buildQueryWrapper
(
param
);
aiConsumeRecordMapper
.
selectPage
(
page
,
lambdaQuery
);
return
QueryUtil
.
getPageResponse
(
page
,
MapstructUtils
.
convert
(
page
.
getRecords
(),
AiConsumeRecordVo
.
class
));
}
/**
* 查询符合条件的AI工具消费记录列表
*
* @param param 查询条件
* @return AI工具消费记录列表
*/
@Override
public
List
<
AiConsumeRecordVo
>
queryList
(
AiConsumeRecordListParam
param
)
{
LambdaQueryWrapper
<
AiConsumeRecord
>
lambdaQuery
=
this
.
buildQueryWrapper
(
param
);
List
<
AiConsumeRecordVo
>
list
=
MapstructUtils
.
convert
(
aiConsumeRecordMapper
.
selectList
(
lambdaQuery
),
AiConsumeRecordVo
.
class
);
//导出路径批量回填部门名:@Translation 仅在 JSON 响应增强管线生效,Excel 导出走 fesod 直接反射读字段,需手动回填
Set
<
Long
>
deptIds
=
list
.
stream
()
.
map
(
AiConsumeRecordVo:
:
getDeptId
)
.
filter
(
ObjectUtil:
:
isNotNull
)
.
collect
(
Collectors
.
toSet
());
if
(
CollUtil
.
isNotEmpty
(
deptIds
))
{
Map
<
Long
,
String
>
deptNames
=
sysDeptServ
.
selectDeptNamesByIds
(
deptIds
);
list
.
forEach
(
vo
->
vo
.
setDeptName
(
deptNames
.
get
(
vo
.
getDeptId
())));
}
//导出末尾追加合计行:全量数据已在内存,流式求和(filter nonNull 与 SQL SUM 忽略 NULL 语义一致),不发第二次 SQL(design D7)
BigDecimal
amountCnyTotal
=
list
.
stream
()
.
map
(
AiConsumeRecordVo:
:
getAmountCny
)
.
filter
(
ObjectUtil:
:
isNotNull
)
.
reduce
(
BigDecimal
.
ZERO
,
BigDecimal:
:
add
)
.
setScale
(
2
,
RoundingMode
.
HALF_UP
);
//合计行仅 deptName + amountCny 有值,其余字段保持 null(导出为空单元格,列结构不变,design D10/D11)
AiConsumeRecordVo
totalRow
=
new
AiConsumeRecordVo
();
totalRow
.
setDeptName
(
"合计"
);
totalRow
.
setAmountCny
(
amountCnyTotal
);
list
.
add
(
totalRow
);
return
list
;
}
/**
* 查询当前条件下的金额合计(跨所有分页的全量筛选结果,口径与列表完全一致)。
*
* @param param 查询条件(与分页查询共用同一条件对象)
* @return 合计载体(amountCny 保留两位小数;无匹配数据时为 0.00)
*/
@Override
public
AiConsumeRecordSummaryVo
querySummary
(
AiConsumeRecordListParam
param
)
{
//复用 buildCondition(不含 orderBy),规避 ONLY_FULL_GROUP_BY 下「聚合 + 非聚合列排序」报错(design D3)
LambdaQueryWrapper
<
AiConsumeRecord
>
wrapper
=
buildCondition
(
param
)
.
selectSum
(
AiConsumeRecord:
:
getAmountCny
)
.
build
();
//SUM 无 GROUP BY 恒返回单行;空结果集时该行为 null,落 ZERO(design D5)
List
<
Object
>
objs
=
aiConsumeRecordMapper
.
selectObjs
(
wrapper
);
BigDecimal
amountCny
=
CollUtil
.
isEmpty
(
objs
)
?
BigDecimal
.
ZERO
:
Convert
.
toBigDecimal
(
objs
.
get
(
0
),
BigDecimal
.
ZERO
);
AiConsumeRecordSummaryVo
summary
=
new
AiConsumeRecordSummaryVo
();
summary
.
setAmountCny
(
amountCny
.
setScale
(
2
,
RoundingMode
.
HALF_UP
));
return
summary
;
}
/**
* 构造查询条件(不含排序),供列表查询与合计聚合共用,确保两者筛选口径完全一致(design D3)。
*
* @param param 查询条件
* @return 承载全部筛选条件(多租户 + 部门树子树 + 搜索条件 + 时间范围)的 builder,不含 orderBy
*/
private
LambdaQueryBuilder
<
AiConsumeRecord
>
buildCondition
(
AiConsumeRecordListParam
param
)
{
Map
<
String
,
Object
>
params
=
param
.
getParams
();
return
QueryBuilder
.
lambda
(
AiConsumeRecord
.
class
)
.
eqIfText
(
AiConsumeRecord:
:
getProjectName
,
param
.
getProjectName
())
.
eqIfText
(
AiConsumeRecord:
:
getToolName
,
param
.
getToolName
())
.
likeIfText
(
AiConsumeRecord:
:
getUserName
,
param
.
getUserName
())
.
betweenParams
(
AiConsumeRecord:
:
getConsumeTime
,
params
,
"beginTime"
,
"endTime"
)
//部门树筛选(含子部门子树,对齐 SysUserServImpl.buildQueryWrapper)
.
and
(
ObjectUtil
.
isNotNull
(
param
.
getBelongDeptId
()),
x
->
{
List
<
Long
>
deptIds
=
deptMapper
.
selectDeptAndChildById
(
param
.
getBelongDeptId
());
x
.
in
(
AiConsumeRecord:
:
getDeptId
,
deptIds
);
});
}
private
LambdaQueryWrapper
<
AiConsumeRecord
>
buildQueryWrapper
(
AiConsumeRecordListParam
param
)
{
return
buildCondition
(
param
).
orderByDesc
(
AiConsumeRecord:
:
getConsumeTime
).
build
();
}
/**
* 查询AI工具消费记录
*
* @param id 主键
* @return AI工具消费记录
*/
@Override
public
AiConsumeRecordVo
queryById
(
Long
id
)
{
AiConsumeRecord
aiConsumeRecord
=
aiConsumeRecordMapper
.
selectById
(
id
);
return
MapstructUtils
.
convert
(
aiConsumeRecord
,
AiConsumeRecordVo
.
class
);
}
/**
* 按批次号查询整批详情(公共字段取首行,details 为全部明细行,line_sort 升序)。
*
* @param batchNo 批次号
* @return 整批详情视图对象;批次不存在或跨租户时返回 null
*/
@Override
public
AiConsumeRecordVo
queryByBatchNo
(
String
batchNo
)
{
List
<
AiConsumeRecord
>
rows
=
aiConsumeRecordMapper
.
selectByBatchNo
(
batchNo
);
if
(
CollUtil
.
isEmpty
(
rows
))
{
return
null
;
}
AiConsumeRecordVo
vo
=
MapstructUtils
.
convert
(
rows
.
get
(
0
),
AiConsumeRecordVo
.
class
);
vo
.
setDetails
(
MapstructUtils
.
convert
(
rows
,
AiConsumeRecordVo
.
class
));
return
vo
;
}
/**
* 新增AI工具消费记录(主从批量:一次提交生成共享同一 batch_no 的多行)
*
* @param param AI工具消费记录(公共字段 + 明细列表)
* @return 是否新增成功
*/
@Override
@Transactional
(
rollbackFor
=
Exception
.
class
)
public
Boolean
insertByParam
(
AiConsumeRecordParam
param
)
{
//生成全局唯一批次号(项目标准雪花设施:IdGeneratorUtil 底层为已注册的 SnGen 生成器)
String
batchNo
=
IdGeneratorUtil
.
nextId
();
List
<
AiConsumeRecord
>
records
=
this
.
buildRecords
(
param
,
batchNo
);
return
this
.
saveBatch
(
records
);
}
/**
* 修改AI工具消费记录(主从批量:事务内「整批全删重插」,batch_no 保持不变)
*
* @param param AI工具消费记录(batchNo + 公共字段 + 明细列表)
* @return 是否修改成功
*/
@Override
@Transactional
(
rollbackFor
=
Exception
.
class
)
public
Boolean
updateByParam
(
AiConsumeRecordParam
param
)
{
String
batchNo
=
param
.
getBatchNo
();
if
(
StrUtil
.
isBlank
(
batchNo
))
{
throw
new
PlatformException
(
"批次号不能为空"
);
}
//先按 batch_no 物理删除原批(显式带 tenant_id,杜绝跨租户误删,D3/R1),再以原 batch_no 重插当前明细
aiConsumeRecordMapper
.
deleteByBatchNo
(
batchNo
,
TenantHelper
.
getTenantId
());
List
<
AiConsumeRecord
>
records
=
this
.
buildRecords
(
param
,
batchNo
);
return
this
.
saveBatch
(
records
);
}
/**
* 按公共字段 + 明细列表组装待落库记录,line_sort 从 1 连续赋值(同批共享 batch_no)。
*
* @param param 入参(公共字段 + details)
* @param batchNo 批次号
* @return 记录列表
*/
private
List
<
AiConsumeRecord
>
buildRecords
(
AiConsumeRecordParam
param
,
String
batchNo
)
{
List
<
AiConsumeDetailParam
>
details
=
param
.
getDetails
();
List
<
AiConsumeRecord
>
records
=
new
ArrayList
<>(
details
.
size
());
for
(
int
i
=
0
;
i
<
details
.
size
();
i
++)
{
AiConsumeDetailParam
detail
=
details
.
get
(
i
);
AiConsumeRecord
record
=
new
AiConsumeRecord
();
//公共字段(同批各行冗余存储)
record
.
setDeptId
(
param
.
getDeptId
());
record
.
setProjectName
(
param
.
getProjectName
());
record
.
setUserName
(
param
.
getUserName
());
//消费时间:LocalDate 直接落库(date 列,无时分秒)
record
.
setConsumeTime
(
param
.
getConsumeTime
());
record
.
setRemark
(
param
.
getRemark
());
//批次元数据
record
.
setBatchNo
(
batchNo
);
record
.
setLineSort
(
i
+
1
);
//明细字段
record
.
setToolName
(
detail
.
getToolName
());
record
.
setRegion
(
detail
.
getRegion
());
record
.
setConsumePoints
(
detail
.
getConsumePoints
());
record
.
setAmountCny
(
detail
.
getAmountCny
());
records
.
add
(
record
);
}
return
records
;
}
/**
* 校验并批量删除AI工具消费记录信息
*
* @param ids 待删除的主键集合
* @return 是否删除成功
*/
@Override
public
Boolean
delByIds
(
List
<
Long
>
ids
)
{
//无关联业务校验,直接逻辑删除(@TableLogic)
return
this
.
removeByIds
(
ids
);
}
}
admin/src/main/resources/mapper/AiConsumeProjectMapper.xml
0 → 100644
View file @
6ceb318f
<?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.anplus.hr.ai.mapper.AiConsumeProjectMapper"
>
</mapper>
admin/src/main/resources/mapper/AiConsumeRecordMapper.xml
View file @
6ceb318f
...
@@ -5,12 +5,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
...
@@ -5,12 +5,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<mapper
namespace=
"com.anplus.hr.ai.mapper.AiConsumeRecordMapper"
>
<mapper
namespace=
"com.anplus.hr.ai.mapper.AiConsumeRecordMapper"
>
<!--
<!--
按
批次号物理删除整批(编辑「整批全删重插」
用,绕过 @TableLogic 逻辑删除)。
按
项目ID物理删除该项目全部明细(编辑「整项目全删重插」/级联删除
用,绕过 @TableLogic 逻辑删除)。
显式带 tenant_id 条件(design.md D
3 / R1),杜绝跨租户误删同 batch_no
行。
显式带 tenant_id 条件(design.md D
4 / R1),杜绝跨租户误删同 project_id
行。
-->
-->
<delete
id=
"deleteByBatchNo"
>
<delete
id=
"deleteByProjectId"
>
delete from ai_consume_record
delete from ai_consume_record where project_id = #{projectId}
where batch_no = #{batchNo} and tenant_id = #{tenantId}
</delete>
</delete>
</mapper>
</mapper>
frontend/src/api/ai/consume/index.ts
View file @
6ceb318f
...
@@ -40,25 +40,16 @@ export function consumeExport(data: Partial<Consume>) {
...
@@ -40,25 +40,16 @@ export function consumeExport(data: Partial<Consume>) {
}
}
/**
/**
* 消费记录
详情(单条
)
* 消费记录
项目详情(父行公共字段 + durationSeconds + 内嵌全量明细子行 children;编辑回填用
)
* @param id
id
* @param id
项目ID(project_id)
* @returns 详情
* @returns
项目
详情
*/
*/
export
function
consumeInfo
(
id
:
ID
)
{
export
function
consumeInfo
(
id
:
ID
)
{
return
alovaInstance
.
get
<
Consume
>
(
`
${
Api
.
root
}
/
${
id
}
`
);
return
alovaInstance
.
get
<
Consume
>
(
`
${
Api
.
root
}
/
${
id
}
`
);
}
}
/**
/**
* 消费记录整批详情(主从批量录入编辑回填用)
* 消费记录新增(父子结构:项目级公共字段 + details 明细数组)
* @param batchNo 批次号
* @returns 整批详情(公共字段 + details 明细列表)
*/
export
function
consumeBatchDetail
(
batchNo
:
string
)
{
return
alovaInstance
.
get
<
Consume
>
(
`
${
Api
.
root
}
/batch/
${
batchNo
}
`
);
}
/**
* 消费记录新增(主从批量:公共字段 + details 明细数组)
* @param data 批量入参
* @param data 批量入参
*/
*/
export
function
consumeAdd
(
data
:
ConsumeForm
)
{
export
function
consumeAdd
(
data
:
ConsumeForm
)
{
...
@@ -66,8 +57,8 @@ export function consumeAdd(data: ConsumeForm) {
...
@@ -66,8 +57,8 @@ export function consumeAdd(data: ConsumeForm) {
}
}
/**
/**
* 消费记录修改(
主从批量:
整批全删重插)
* 消费记录修改(
父子结构:父行公共字段更新 + 明细
整批全删重插)
* @param data
批量
入参
* @param data
项目
入参
*/
*/
export
function
consumeUpdate
(
data
:
ConsumeForm
)
{
export
function
consumeUpdate
(
data
:
ConsumeForm
)
{
return
alovaInstance
.
putWithMsg
<
void
>
(
Api
.
root
,
data
);
return
alovaInstance
.
putWithMsg
<
void
>
(
Api
.
root
,
data
);
...
...
frontend/src/api/ai/consume/model.d.ts
View file @
6ceb318f
/**
/**
* @description: Consume interface
* @description: Consume interface
(项目父行,对应后端 AiConsumeProjectVo)
*/
*/
export
interface
Consume
{
export
interface
Consume
{
id
:
number
;
/**
* 批次号(主从批量录入元数据;非展示、非导出,编辑流按此定位整批)
*/
batchNo
?:
string
;
/**
/**
*
行序号(批次内从 1 连续;非展示、非导出
)
*
项目ID(父行主键;详情/编辑/删除以此定位项目
)
*/
*/
lineSort
?
:
number
;
id
:
number
;
/**
/**
* 所属部门ID
* 所属部门ID
(关联 sys_dept.id)
*/
*/
deptId
?:
number
;
deptId
?:
number
;
/**
/**
* 所属部门名称(后端 @Translation 翻译 / 导出回填)
* 所属部门名称(后端 @Translation 翻译 / 导出回填)
*/
*/
deptName
?:
string
;
deptName
?:
string
;
/**
* 归属部门id(部门树查询用,含子部门)
*/
belongDeptId
?:
number
|
string
;
/**
/**
* 项目名称(用户直接输入的自由文本)
* 项目名称(用户直接输入的自由文本)
*/
*/
...
@@ -31,6 +22,41 @@ export interface Consume {
...
@@ -31,6 +22,41 @@ export interface Consume {
* 使用人姓名
* 使用人姓名
*/
*/
userName
:
string
;
userName
:
string
;
/**
* 消费发生日期(父级,纯日期 YYYY-MM-DD)
*/
consumeTime
:
string
;
/**
* 项目总金额(= Σ 明细金额,人民币;列表「项目总金额」列绑定字段)
*/
amountCny
:
number
;
/**
* 成片时长(秒,非负整数)
*/
durationSeconds
?:
number
;
/**
* 每分钟金额(= 项目总金额×60÷成片时长,2 位小数;成片时长为 0 时为 null)
*/
amountPerMinute
?:
number
;
/**
* 备注(父级)
*/
remark
?:
string
;
createTime
:
string
;
/**
* 明细子行列表(分页 / 项目详情内嵌返回,lineSort 升序);列表页作为展开子表数据源
*/
children
?:
ConsumeDetail
[];
}
/**
* @description: 消费明细子行(对应后端 AiConsumeDetailVo;内嵌于父行 children)
*/
export
interface
ConsumeDetail
{
/**
* 明细主键ID
*/
id
:
number
;
/**
/**
* AI工具/平台名称(用户直接输入的自由文本)
* AI工具/平台名称(用户直接输入的自由文本)
*/
*/
...
@@ -42,24 +68,15 @@ export interface Consume {
...
@@ -42,24 +68,15 @@ export interface Consume {
/**
/**
* 消耗积分
* 消耗积分
*/
*/
consumePoints
:
number
;
consumePoints
?:
number
;
/**
* 金额(人民币)
*/
amountCny
:
number
;
/**
* 消费发生时间
*/
consumeTime
:
string
;
/**
/**
*
备注
*
单条明细金额(人民币;区别于父行项目总金额);展开子表「金额(人民币)」列绑定字段
*/
*/
remark
:
string
;
amountCny
?:
number
;
createTime
:
string
;
/**
/**
*
明细行列表(仅整批详情接口 GET /batch/{batchNo} 返回;列表与单条详情为 undefined
)
*
行序号(项目内明细从 1 连续排序
)
*/
*/
details
?:
Consume
[]
;
lineSort
?:
number
;
}
}
/**
/**
...
@@ -85,13 +102,13 @@ export interface ConsumeDetailForm {
...
@@ -85,13 +102,13 @@ export interface ConsumeDetailForm {
}
}
/**
/**
* @description: 消费
记录批量入参(主从结构,对应后端 AiConsumeRecord
Param)
* @description: 消费
项目入参(父子结构,对应后端 AiConsumeProject
Param)
*/
*/
export
interface
ConsumeForm
{
export
interface
ConsumeForm
{
/**
/**
*
批次号(编辑时定位原批;新增时为空、由服务端
生成)
*
项目ID(编辑时定位原项目;新增时为空、由数据库自增
生成)
*/
*/
batchNo
?:
string
;
projectId
?:
number
;
/**
/**
* 所属部门ID
* 所属部门ID
*/
*/
...
@@ -105,9 +122,13 @@ export interface ConsumeForm {
...
@@ -105,9 +122,13 @@ export interface ConsumeForm {
*/
*/
userName
?:
string
;
userName
?:
string
;
/**
/**
* 消费发生
时间
(日期类型,落库 date 当天日期)
* 消费发生
日期
(日期类型,落库 date 当天日期)
*/
*/
consumeTime
?:
string
;
consumeTime
?:
string
;
/**
* 成片时长(秒,非负整数;无视频产出默认 0)
*/
durationSeconds
?:
number
;
/**
/**
* 备注
* 备注
*/
*/
...
@@ -123,7 +144,7 @@ export interface ConsumeForm {
...
@@ -123,7 +144,7 @@ export interface ConsumeForm {
*/
*/
export
interface
ConsumeSummary
{
export
interface
ConsumeSummary
{
/**
/**
*
金额(人民币)
全量合计(两位小数)
*
项目总金额
全量合计(两位小数)
*/
*/
amountCny
:
number
;
amountCny
:
number
;
}
}
frontend/src/views/ai/consume/consume-drawer.vue
View file @
6ceb318f
...
@@ -7,7 +7,7 @@ import type { VxeGridInstance, VxeGridProps } from 'vxe-table';
...
@@ -7,7 +7,7 @@ import type { VxeGridInstance, VxeGridProps } from 'vxe-table';
import
{
computed
,
nextTick
,
ref
,
useTemplateRef
}
from
'vue'
;
import
{
computed
,
nextTick
,
ref
,
useTemplateRef
}
from
'vue'
;
import
{
consumeAdd
,
consume
BatchDetail
,
consumeUpdate
}
from
'@/api/ai/consume'
;
import
{
consumeAdd
,
consume
Info
,
consumeUpdate
}
from
'@/api/ai/consume'
;
import
{
getDeptTree
}
from
'@/api/system/user'
;
import
{
getDeptTree
}
from
'@/api/system/user'
;
import
{
useVbenDrawer
}
from
'@/components'
;
import
{
useVbenDrawer
}
from
'@/components'
;
import
{
import
{
...
@@ -27,6 +27,7 @@ import {
...
@@ -27,6 +27,7 @@ import {
Input
,
Input
,
InputNumber
,
InputNumber
,
Select
,
Select
,
TimePicker
,
}
from
'antdv-next'
;
}
from
'antdv-next'
;
import
{
VxeGrid
}
from
'vxe-table'
;
import
{
VxeGrid
}
from
'vxe-table'
;
...
@@ -37,23 +38,45 @@ const title = computed(() => {
...
@@ -37,23 +38,45 @@ const title = computed(() => {
return
isUpdate
.
value
?
$t
(
'pages.common.edit'
)
:
$t
(
'pages.common.add'
);
return
isUpdate
.
value
?
$t
(
'pages.common.edit'
)
:
$t
(
'pages.common.add'
);
});
});
// 公共字段(
主表):所属部门 / 项目名称 / 使用人姓名 / 消费时间
/ 备注
// 公共字段(
项目级):所属部门 / 项目名称 / 使用人姓名 / 消费时间 / 成片时长
/ 备注
type
CommonFormData
=
{
type
CommonFormData
=
{
batchNo
?:
string
;
projectId
?:
number
;
consumeTime
?:
string
;
consumeTime
?:
string
;
deptId
?:
number
|
string
;
deptId
?:
number
|
string
;
projectName
?:
string
;
projectName
?:
string
;
remark
?:
string
;
userName
?:
string
;
userName
?:
string
;
/** 成片时长 mm:ss(TimePicker 绑定串;提交换算为整数秒 durationSeconds,design D5) */
duration
?:
string
;
remark
?:
string
;
};
};
// 成片时长 mm:ss → 整数秒(留空/非法 → 0,非负,design D5)
function
mmssToSeconds
(
mmss
?:
string
):
number
{
if
(
!
mmss
)
{
return
0
;
}
const
[
m
,
s
]
=
mmss
.
split
(
':'
).
map
((
n
)
=>
Number
.
parseInt
(
n
,
10
)
||
0
);
return
(
m
??
0
)
*
60
+
(
s
??
0
);
}
// 整数秒 → mm:ss 显示串(0/空 → undefined,编辑回填用,design D5)
function
secondsToMmss
(
sec
?:
number
):
string
|
undefined
{
if
(
!
sec
||
sec
<=
0
)
{
return
undefined
;
}
const
m
=
Math
.
floor
(
sec
/
60
);
const
s
=
sec
%
60
;
return
`
${
String
(
m
).
padStart
(
2
,
'0'
)}
:
${
String
(
s
).
padStart
(
2
,
'0'
)}
`
;
}
function
getDefaultCommon
():
CommonFormData
{
function
getDefaultCommon
():
CommonFormData
{
return
{
return
{
batchNo
:
undefined
,
projectId
:
undefined
,
deptId
:
undefined
,
deptId
:
undefined
,
projectName
:
undefined
,
projectName
:
undefined
,
userName
:
undefined
,
userName
:
undefined
,
consumeTime
:
undefined
,
consumeTime
:
undefined
,
duration
:
undefined
,
remark
:
''
,
remark
:
''
,
};
};
}
}
...
@@ -263,26 +286,27 @@ const [BasicDrawer, drawerApi] = useVbenDrawer({
...
@@ -263,26 +286,27 @@ const [BasicDrawer, drawerApi] = useVbenDrawer({
return
null
;
return
null
;
}
}
drawerApi
.
drawerLoading
(
true
);
drawerApi
.
drawerLoading
(
true
);
const
{
batchNo
,
id
}
=
drawerApi
.
getData
()
as
{
const
{
id
}
=
drawerApi
.
getData
()
as
{
batchNo
?:
string
;
id
?:
number
|
string
;
id
?:
number
|
string
;
};
};
isUpdate
.
value
=
!!
id
;
isUpdate
.
value
=
!!
id
;
// 初始化部门树
// 初始化部门树
await
setupDeptSelect
();
await
setupDeptSelect
();
await
nextTick
();
await
nextTick
();
// 编辑态:按 batch_no 回填整批(公共字段 + 明细);新增态:默认一空行
// 编辑态:按 project_id 调 GET /ai/consume/{id} 回填整项目(公共字段 + 成片时长 + 全量明细);
if
(
isUpdate
.
value
&&
batchNo
)
{
// 不复用列表行——列表 children 在 toolName 搜索时仅含匹配子行会丢明细(design D6)
const
batch
=
await
consumeBatchDetail
(
batchNo
);
if
(
isUpdate
.
value
&&
id
)
{
const
project
=
await
consumeInfo
(
id
);
formData
.
value
=
{
formData
.
value
=
{
batchNo
:
batch
?.
batchNo
??
batchNo
,
projectId
:
project
?.
id
,
deptId
:
batch
?.
deptId
,
deptId
:
project
?.
deptId
,
projectName
:
batch
?.
projectName
,
projectName
:
project
?.
projectName
,
userName
:
batch
?.
userName
,
userName
:
project
?.
userName
,
consumeTime
:
batch
?.
consumeTime
,
consumeTime
:
project
?.
consumeTime
,
remark
:
batch
?.
remark
??
''
,
duration
:
secondsToMmss
(
project
?.
durationSeconds
),
remark
:
project
?.
remark
??
''
,
};
};
const
details
=
(
batch
?.
details
??
[]).
map
((
item
)
=>
({
const
details
=
(
project
?.
children
??
[]).
map
((
item
)
=>
({
toolName
:
item
.
toolName
,
toolName
:
item
.
toolName
,
region
:
item
.
region
,
region
:
item
.
region
,
consumePoints
:
item
.
consumePoints
,
consumePoints
:
item
.
consumePoints
,
...
@@ -317,12 +341,13 @@ async function handleConfirm() {
...
@@ -317,12 +341,13 @@ async function handleConfirm() {
window
.
message
.
warning
(
'请至少添加一行消费明细'
);
window
.
message
.
warning
(
'请至少添加一行消费明细'
);
return
;
return
;
}
}
// 4. 组装
主从批量入参
// 4. 组装
父子项目入参(成片时长 mm:ss → 整数秒 durationSeconds,design D5)
const
common
=
cloneDeep
(
formData
.
value
);
const
common
=
cloneDeep
(
formData
.
value
);
const
data
:
ConsumeForm
=
{
const
data
:
ConsumeForm
=
{
batchNo
:
common
.
batchNo
,
projectId
:
common
.
projectId
,
consumeTime
:
common
.
consumeTime
,
consumeTime
:
common
.
consumeTime
,
deptId
:
common
.
deptId
as
number
|
undefined
,
deptId
:
common
.
deptId
as
number
|
undefined
,
durationSeconds
:
mmssToSeconds
(
common
.
duration
),
details
:
cloneDeep
(
details
),
details
:
cloneDeep
(
details
),
projectName
:
common
.
projectName
,
projectName
:
common
.
projectName
,
remark
:
common
.
remark
,
remark
:
common
.
remark
,
...
@@ -391,6 +416,15 @@ async function handleClosed() {
...
@@ -391,6 +416,15 @@ async function handleClosed() {
v-model:value=
"formData.consumeTime"
v-model:value=
"formData.consumeTime"
/>
/>
</FormItem>
</FormItem>
<FormItem
label=
"成片时长"
name=
"duration"
>
<TimePicker
class=
"w-full"
format=
"mm:ss"
value-format=
"mm:ss"
placeholder=
"无视频产出可留空"
v-model:value=
"formData.duration"
/>
</FormItem>
<FormItem
label=
"备注"
name=
"remark"
>
<FormItem
label=
"备注"
name=
"remark"
>
<FormTextArea
<FormTextArea
allow-clear
allow-clear
...
...
frontend/src/views/ai/consume/data.ts
View file @
6ceb318f
import
type
{
VxeGridProps
}
from
'vxe-table'
;
import
type
{
VxeGridProps
}
from
'vxe-table'
;
/**
* 金额格式化:空值留白(合并列中父/子行在对方列天然为 null),有值保留 2 位小数。
* 同时用于父行「项目总金额」与子行「金额(人民币)」列(design D8)
*/
function
amountText
(
value
?:
null
|
number
|
string
):
string
{
return
value
==
null
||
value
===
''
?
''
:
Number
(
value
).
toFixed
(
2
);
}
/**
* 成片时长格式化:>60 秒「X分X秒」、≤60 秒「X秒」、0「0秒」;空值(子行)留白(design D8)
*/
function
durationText
(
value
?:
null
|
number
):
string
{
if
(
value
==
null
)
{
return
''
;
}
const
s
=
Number
(
value
);
if
(
s
<=
0
)
{
return
'0秒'
;
}
return
s
>
60
?
`
${
Math
.
floor
(
s
/
60
)}
分
${
s
%
60
}
秒`
:
`
${
s
}
秒`
;
}
/**
* 主网格列集(design D7):仅项目父级列 + 展开列。
* - 展开列 type:'expand':点击在该行下方内嵌明细子表(列见 detailColumns,数据取 row.children)
* - 父级列:所属部门 / 项目名称 / 姓名 / 项目总金额(amountCny) / 成片时长 / 每分钟金额 / 消费时间 / 备注 / 操作
* - 明细字段(AI工具名称 / 国内外 / 消耗积分 / 金额)不再占主网格列,移入展开子表
*/
export
const
columns
:
VxeGridProps
[
'columns'
]
=
[
export
const
columns
:
VxeGridProps
[
'columns'
]
=
[
{
type
:
'checkbox'
,
width
:
60
},
{
type
:
'checkbox'
,
width
:
60
},
{
type
:
'expand'
,
width
:
50
,
slots
:
{
content
:
'expandContent'
}
},
{
{
title
:
'所属部门'
,
title
:
'所属部门'
,
field
:
'deptName'
,
field
:
'deptName'
,
minWidth
:
130
,
},
},
{
{
title
:
'项目名称'
,
title
:
'项目名称'
,
field
:
'projectName'
,
field
:
'projectName'
,
minWidth
:
140
,
},
},
{
{
title
:
'姓名'
,
title
:
'姓名'
,
field
:
'userName'
,
field
:
'userName'
,
minWidth
:
100
,
},
},
{
{
title
:
'AI工具/平台名称'
,
title
:
'项目总金额'
,
field
:
'toolName'
,
field
:
'amountCny'
,
},
minWidth
:
100
,
{
align
:
'right'
,
title
:
'国内/国外'
,
formatter
:
({
cellValue
})
=>
amountText
(
cellValue
),
field
:
'region'
,
},
},
{
{
title
:
'消耗积分'
,
title
:
'成片时长'
,
field
:
'consumePoints'
,
field
:
'durationSeconds'
,
minWidth
:
100
,
align
:
'center'
,
formatter
:
({
cellValue
})
=>
durationText
(
cellValue
),
},
},
{
{
title
:
'金额(人民币)'
,
title
:
'每分钟金额'
,
field
:
'amountCny'
,
field
:
'amountPerMinute'
,
minWidth
:
90
,
align
:
'right'
,
formatter
:
({
cellValue
})
=>
amountText
(
cellValue
),
},
},
{
{
title
:
'消费时间'
,
title
:
'消费时间'
,
field
:
'consumeTime'
,
field
:
'consumeTime'
,
minWidth
:
120
,
},
},
{
{
title
:
'备注'
,
title
:
'备注'
,
field
:
'remark'
,
field
:
'remark'
,
minWidth
:
120
,
},
},
{
{
field
:
'action'
,
field
:
'action'
,
...
@@ -47,3 +86,36 @@ export const columns: VxeGridProps['columns'] = [
...
@@ -47,3 +86,36 @@ export const columns: VxeGridProps['columns'] = [
width
:
'auto'
,
width
:
'auto'
,
},
},
];
];
/**
* 展开行明细子表列集(design D7 / spec「消费记录列表展开明细子表前端展示」):
* AI工具名称 / 国内外 / 消耗积分 / 金额(人民币)。数据源为父行内嵌 children(ConsumeDetail,
* 金额字段 amountCny);金额列复用 amountText 保留 2 位、空值留白。仅供主网格展开区嵌套只读子表使用。
*/
export
const
detailColumns
:
VxeGridProps
[
'columns'
]
=
[
{
title
:
'AI工具/平台名称'
,
field
:
'toolName'
,
minWidth
:
160
,
align
:
'center'
,
},
{
title
:
'国内/国外'
,
field
:
'region'
,
minWidth
:
100
,
align
:
'center'
,
},
{
title
:
'消耗积分'
,
field
:
'consumePoints'
,
minWidth
:
100
,
align
:
'center'
,
},
{
title
:
'金额(人民币)'
,
field
:
'amountCny'
,
minWidth
:
120
,
align
:
'center'
,
formatter
:
({
cellValue
})
=>
amountText
(
cellValue
),
},
];
frontend/src/views/ai/consume/index.vue
View file @
6ceb318f
...
@@ -24,7 +24,7 @@ import { VxeGrid } from 'vxe-table';
...
@@ -24,7 +24,7 @@ import { VxeGrid } from 'vxe-table';
import
consumeDrawer
from
'./consume-drawer.vue'
;
import
consumeDrawer
from
'./consume-drawer.vue'
;
import
ConsumeSearchForm
from
'./consume-search.vue'
;
import
ConsumeSearchForm
from
'./consume-search.vue'
;
import
{
columns
}
from
'./data'
;
import
{
columns
,
detailColumns
}
from
'./data'
;
// 左边部门用
// 左边部门用
const
selectDeptId
=
ref
<
string
[]
>
([]);
const
selectDeptId
=
ref
<
string
[]
>
([]);
...
@@ -45,8 +45,10 @@ const gridOptions = withDefaultVxeGridOptions<Consume>({
...
@@ -45,8 +45,10 @@ const gridOptions = withDefaultVxeGridOptions<Consume>({
// 翻页时保留选中状态
// 翻页时保留选中状态
reserve
:
true
,
reserve
:
true
,
trigger
:
'cell'
,
trigger
:
'cell'
,
// 主网格每行均为项目父行(明细移入展开子表),全部可勾选,无需 checkMethod 过滤(design D7)
},
},
columns
,
columns
,
size
:
'medium'
,
height
:
'auto'
,
height
:
'auto'
,
keepSource
:
true
,
keepSource
:
true
,
pagerConfig
:
{},
pagerConfig
:
{},
...
@@ -73,10 +75,13 @@ const gridOptions = withDefaultVxeGridOptions<Consume>({
...
@@ -73,10 +75,13 @@ const gridOptions = withDefaultVxeGridOptions<Consume>({
}),
}),
consumeSummary
(
values
),
consumeSummary
(
values
),
]);
]);
// 合计写入独立 footerData;键名按 data.ts 列 field 对齐;toFixed(2) 规避 JSON 数值退化(design D6)
// 合计写入独立 footerData;amountCny 键对齐 data.ts「项目总金额」列 field——
// 合计口径为命中明细金额之和,落「项目总金额」列、其余列留空(design D5,req1);值来源仍是 summary 的 amountCny 单值
footerData
.
value
=
[
footerData
.
value
=
[
{
deptName
:
'合计'
,
amountCny
:
Number
(
sum
.
amountCny
).
toFixed
(
2
)
},
{
deptName
:
'合计'
,
amountCny
:
Number
(
sum
.
amountCny
).
toFixed
(
2
)
},
];
];
// 主网格每行 = 一个项目父行,children 原样保留供展开子表直接渲染(design D7);
// 明细移入独立子表实例,父子不再同表格 → 无需子行字段改名 / 复合键 _rid / rowType 判别
return
listRes
;
return
listRes
;
}
finally
{
}
finally
{
tableLoading
.
value
=
false
;
tableLoading
.
value
=
false
;
...
@@ -85,10 +90,14 @@ const gridOptions = withDefaultVxeGridOptions<Consume>({
...
@@ -85,10 +90,14 @@ const gridOptions = withDefaultVxeGridOptions<Consume>({
},
},
},
},
rowConfig
:
{
rowConfig
:
{
// 主网格每行为项目父行,直接用项目 id 作唯一键(明细子表另有独立实例,无父子 id 冲突,design D7)
keyField
:
'id'
,
keyField
:
'id'
,
},
},
// footer 页面局部开启(不改全局 vxe 配置,design D
2
)
// footer 页面局部开启(不改全局 vxe 配置,design D
7
)
showFooter
:
true
,
showFooter
:
true
,
// 展开行:主网格每行为项目父行,展开后内嵌明细子表(数据取 row.children,见 expandContent slot);
// pagerConfig 保持启用 → 分页按父行计数(明细子表不参与分页,design D7)
expandConfig
:
{},
toolbarConfig
:
{
toolbarConfig
:
{
slots
:
{
slots
:
{
buttons
:
'toolbar-left'
,
buttons
:
'toolbar-left'
,
...
@@ -98,6 +107,28 @@ const gridOptions = withDefaultVxeGridOptions<Consume>({
...
@@ -98,6 +107,28 @@ const gridOptions = withDefaultVxeGridOptions<Consume>({
id
:
'ai-consume-index'
,
id
:
'ai-consume-index'
,
});
});
// 展开区明细子表(只读,design D7):数据源为父行 row.children,复用 detailColumns;
// 关闭代理/分页/工具栏 + 紧凑尺寸,每个展开行渲染独立 VxeGrid 实例(对齐 consume-drawer 明细表标杆)
const
subGridOptions
=
withDefaultVxeGridOptions
({
columns
:
detailColumns
,
proxyConfig
:
{
enabled
:
false
},
pagerConfig
:
{
enabled
:
false
},
toolbarConfig
:
{
enabled
:
false
},
rowConfig
:
{
isHover
:
true
,
useKey
:
true
},
border
:
true
,
showOverflow
:
false
,
size
:
'mini'
,
minHeight
:
40
,
});
// 明细子表表头强调(design D7 增强):主色浅底 + 主色文字,与中性表头的项目父表区分更醒目。
// 应用 vxe 主题把表头变量定义在 .vxe-grid 根元素上,故在子表实例根节点内联覆盖(inline 优先级更高,仅作用于本实例,深浅模式均跟随主题)
const
subGridHeaderStyle
=
{
'--vxe-ui-table-header-background-color'
:
'color-mix(in srgb, var(--ant-color-primary) 10%, transparent)'
,
'--vxe-ui-table-header-font-color'
:
'var(--ant-color-primary)'
,
};
const
tableRef
=
useTemplateRef
<
VxeGridInstance
<
Consume
>>
(
'tableRef'
);
const
tableRef
=
useTemplateRef
<
VxeGridInstance
<
Consume
>>
(
'tableRef'
);
const
{
query
,
reload
}
=
useTableQuery
(
const
{
query
,
reload
}
=
useTableQuery
(
searchFormRef
,
searchFormRef
,
...
@@ -121,7 +152,8 @@ function handleAdd() {
...
@@ -121,7 +152,8 @@ function handleAdd() {
}
}
async
function
handleEdit
(
record
:
Consume
)
{
async
function
handleEdit
(
record
:
Consume
)
{
drawerApi
.
setData
({
batchNo
:
record
.
batchNo
,
id
:
record
.
id
});
// 按项目主键 project_id 传递,抽屉调 consumeInfo(id) 回填整项目(design D6)
drawerApi
.
setData
({
id
:
record
.
id
});
drawerApi
.
open
();
drawerApi
.
open
();
}
}
...
@@ -186,6 +218,7 @@ function getCheckedRows() {
...
@@ -186,6 +218,7 @@ function getCheckedRows() {
if
(
!
table
)
{
if
(
!
table
)
{
return
[];
return
[];
}
}
// 主网格每行均为项目父行(明细在独立子表),勾选记录即项目行,无需 rowType 过滤(design D7)
return
[
return
[
...
table
.
getCheckboxRecords
(),
...
table
.
getCheckboxRecords
(),
...
table
.
getCheckboxReserveRecords
(),
...
table
.
getCheckboxReserveRecords
(),
...
@@ -258,6 +291,29 @@ function syncCheckedRows() {
...
@@ -258,6 +291,29 @@ function syncCheckedRows() {
</a-button>
</a-button>
</Space>
</Space>
</
template
>
</
template
>
<
template
#
expandContent=
"{ row }"
>
<!-- 展开区明细面板:灰底色带 + 主色标题条 + 白底子表卡片,与项目父行形成层次区分(design D7) -->
<div
class=
"bg-muted px-4 py-3"
>
<div
class=
"mb-2 flex items-center justify-between"
>
<span
class=
"text-foreground flex items-center gap-1.5 text-xs font-medium"
>
<span
class=
"bg-primary h-3 w-[3px] rounded-full"
></span>
消费明细
</span>
<span
class=
"text-muted-foreground text-xs"
>
共
{{
row
.
children
?.
length
??
0
}}
条
</span>
</div>
<div
class=
"bg-card overflow-hidden rounded-md"
>
<VxeGrid
v-bind=
"subGridOptions"
:data=
"row.children ?? []"
:style=
"subGridHeaderStyle"
/>
</div>
</div>
</
template
>
<
template
#
action=
"{ row }"
>
<
template
#
action=
"{ row }"
>
<Space>
<Space>
<action-button
<action-button
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment