Commit 6ceb318f authored by 刘斌's avatar 刘斌

fix: 重构消费记录为父子表结构

parent 5e5f9568
......@@ -11,11 +11,12 @@ import jakarta.validation.constraints.NotEmpty;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import com.anplus.hr.ai.domain.params.AiConsumeRecordListParam;
import com.anplus.hr.ai.domain.params.AiConsumeRecordParam;
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 com.anplus.hr.ai.domain.vo.AiConsumeRecordVo;
import com.anplus.hr.ai.service.AiConsumeRecordServ;
import com.anplus.hr.ai.service.AiConsumeProjectServ;
import top.binfast.app.biz.sysapi.bean.params.sysDept.SysDeptParam;
import top.binfast.app.biz.sysbiz.service.SysDeptServ;
import top.binfast.common.core.constant.BusinessType;
......@@ -26,122 +27,116 @@ import top.binfast.common.log.annotation.PinSysLog;
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
* @date 2026-09-04
* @date 2026-09-18
*/
@Validated
@RequiredArgsConstructor
@RestController
@RequestMapping("/ai/consume")
public class AiConsumeRecordCtrl {
public class AiConsumeProjectCtrl {
private final AiConsumeRecordServ aiConsumeRecordServ;
private final AiConsumeProjectServ aiConsumeProjectServ;
private final SysDeptServ sysDeptServ;
/**
* 分页查询AI工具消费记录列表。
* 树形分页查询消费项目列表(按父行计数,每父行内嵌明细 children)。
*
* @param param 查询条件
* @return AI工具消费记录分页结果
* @return 消费项目树形分页结果
*/
@SaCheckPermission("ai:consume:list")
@GetMapping("/list")
public PageResponse<AiConsumeRecordVo> pageList(AiConsumeRecordListParam param) {
return aiConsumeRecordServ.queryPageList(param);
public PageResponse<AiConsumeProjectVo> pageList(AiConsumeProjectListParam param) {
return aiConsumeProjectServ.queryPageList(param);
}
/**
* 查询当前筛选条件下的金额合计(跨所有分页的全量结果,口径与列表完全一致)。
* 查询当前筛选条件下的项目总金额合计(跨所有分页的全量结果,口径与列表完全一致)。
*
* @param param 查询条件(与分页查询共用同一条件对象)
* @return 合计载体(amountCny 保留两位小数;无匹配数据时为 0.00)
*/
@SaCheckPermission("ai:consume:list")
@GetMapping("/summary")
public SingleResponse<AiConsumeRecordSummaryVo> summary(AiConsumeRecordListParam param) {
return SingleResponse.of(aiConsumeRecordServ.querySummary(param));
public SingleResponse<AiConsumeRecordSummaryVo> summary(AiConsumeProjectListParam param) {
return SingleResponse.of(aiConsumeProjectServ.querySummary(param));
}
/**
* 导出AI工具消费记录列表。
* 导出消费记录(扁平明细行 + 父级汇总列重复 + 末尾合计行)。
*
* @param param 查询条件
* @return 扁平导出行列表
*/
@ExcelExport
@SaCheckPermission("ai:consume:export")
@PinSysLog(value = "AI工具消费记录", businessType = BusinessType.EXPORT)
@PostMapping("/export")
public List<AiConsumeRecordVo> export(AiConsumeRecordListParam param) {
return aiConsumeRecordServ.queryList(param);
public List<AiConsumeExportVo> export(AiConsumeProjectListParam param) {
return aiConsumeProjectServ.queryList(param);
}
/**
* 获取AI工具消费记录详细信息
* 获取消费项目详细信息(父行公共字段 + 全部明细子行,line_sort 升序)。
*
* @param id 主键
* @param id 项目id(project_id)
* @return 整项目详情
*/
@SaCheckPermission("ai:consume:query")
@GetMapping("/{id}")
public SingleResponse<AiConsumeRecordVo> getDetail(@PathVariable @Min(1)
Long id) {
return SingleResponse.of(aiConsumeRecordServ.queryById(id));
public SingleResponse<AiConsumeProjectVo> getDetail(@PathVariable @Min(1) Long id) {
return SingleResponse.of(aiConsumeProjectServ.queryById(id));
}
/**
* 按批次号获取整批消费记录详情(主从批量编辑回填用)。
* 新增消费项目(主从批量:项目级公共字段 + 明细数组,单一事务建父插子重算)。
*
* @param batchNo 批次号
* @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工具消费记录参数
* @param param 消费项目参数
* @return 操作结果
*/
@SaCheckPermission("ai:consume:add")
@PinSysLog(value = "AI工具消费记录", businessType = BusinessType.INSERT)
@PostMapping()
public Response add(@Validated @RequestBody AiConsumeRecordParam param) {
return ResponseUtils.ofResult(aiConsumeRecordServ.insertByParam(param));
public Response add(@Validated @RequestBody AiConsumeProjectParam param) {
return ResponseUtils.ofResult(aiConsumeProjectServ.insertByParam(param));
}
/**
* 修改AI工具消费记录。
* 修改消费项目(整批编辑:更新父行 + 明细全删重插 + 重算汇总,project_id 不变)。
*
* @param param AI工具消费记录参数
* @param param 消费项目参数
* @return 操作结果
*/
@SaCheckPermission("ai:consume:edit")
@PinSysLog(value = "AI工具消费记录", businessType = BusinessType.UPDATE)
@PutMapping()
public Response edit(@Validated @RequestBody AiConsumeRecordParam param) {
return ResponseUtils.ofResult(aiConsumeRecordServ.updateByParam(param));
public Response edit(@Validated @RequestBody AiConsumeProjectParam param) {
return ResponseUtils.ofResult(aiConsumeProjectServ.updateByParam(param));
}
/**
* 删除AI工具消费记录
* 删除消费项目(级联逻辑删除父行 + 其全部明细子行)。
*
* @param ids 主键串
* @param ids 项目id(project_id)串
* @return 操作结果
*/
@SaCheckPermission("ai:consume:remove")
@PinSysLog(value = "AI工具消费记录", businessType = BusinessType.DELETE)
@DeleteMapping("/{ids}")
public Response remove(@NotEmpty(message = "主键不能为空")
@PathVariable Long[] ids) {
return ResponseUtils.ofResult(aiConsumeRecordServ.delByIds(List.of(ids)));
public Response remove(@NotEmpty(message = "主键不能为空") @PathVariable Long[] ids) {
return ResponseUtils.ofResult(aiConsumeProjectServ.delByIds(List.of(ids)));
}
/**
* 获取消费记录筛选用的部门树。
* 获取消费记录筛选用的部门树(当前租户,含子部门子树)。
*
* @param dept 部门查询条件
* @return 部门树列表
......@@ -151,4 +146,5 @@ public class AiConsumeRecordCtrl {
public MultiResponse<Tree<Long>> deptTree(SysDeptParam dept) {
return MultiResponse.of(sysDeptServ.selectDeptTreeList(dept));
}
}
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;
}
......@@ -8,10 +8,13 @@ import lombok.EqualsAndHashCode;
import java.io.Serial;
import java.math.BigDecimal;
import java.time.LocalDate;
/**
* AI工具消费记录对象 ai_consume_record
* AI工具消费记录明细对象(子表) ai_consume_record
*
* <p>父子表模型下的明细级记录:仅保留工具明细字段,项目级公共字段
* (部门/项目名/姓名/消费时间/备注)上提至父表 {@link AiConsumeProject},
* 以 projectId 外键关联。</p>
*
* @author LiuBin
* @date 2026-09-04
......@@ -26,19 +29,9 @@ public class AiConsumeRecord extends TenantModel {
private static final long serialVersionUID = 1L;
/**
* 所属部门ID(关联sys_dept.id)
*/
private Long deptId;
/**
* 项目名称(用户直接输入的自由文本)
* 所属项目ID(关联 ai_consume_project.id)
*/
private String projectName;
/**
* 使用人姓名(支持模糊查询)
*/
private String userName;
private Long projectId;
/**
* AI工具/平台名称(用户直接输入的自由文本)
......@@ -56,27 +49,12 @@ public class AiConsumeRecord extends TenantModel {
private BigDecimal consumePoints;
/**
* 金额(人民币)
* 单条明细金额(人民币;区别于父表 ai_consume_project.amount_cny 项目总金额,R6)
*/
private BigDecimal amountCny;
/**
* 消费发生时间
*/
private LocalDate consumeTime;
/**
* 备注
*/
private String remark;
/**
* 批次号(主从批量录入:同批多行共享同一 batch_no,全局唯一)
*/
private String batchNo;
/**
* 行序号(批次内从 1 连续自增)
* 行序号(项目内明细从 1 连续排序)
*/
private Integer lineSort;
......
......@@ -8,35 +8,43 @@ import java.util.HashMap;
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
* @date 2026-09-04
* @date 2026-09-18
*/
@Getter
@Setter
public class AiConsumeRecordListParam extends PageQueryParam {
public class AiConsumeProjectListParam extends PageQueryParam {
/**
* 项目名称(用户直接输入的自由文本)
* 项目名称(父级,eq 精确匹配)
*/
private String projectName;
/**
* 使用人姓名(支持模糊查询)
* 使用人姓名(父级,like 模糊查询)
*/
private String userName;
/**
* AI工具/平台名称(用户直接输入的自由文本)
* AI工具/平台名称(明细字段,以 EXISTS 子查询 like 定位含匹配明细的父行)
*/
private String toolName;
/**
* 归属部门id(部门树,含子部门)
* 归属部门id(部门树,含子部门子树)
*/
private Long belongDeptId;
/**
* 其它查询参数(承载 beginTime/endTime 消费时间范围,纯日期边界)
*/
private Map<String, Object> params = new HashMap<>();
}
......@@ -4,6 +4,7 @@ import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
import lombok.Data;
......@@ -11,22 +12,23 @@ import java.time.LocalDate;
import java.util.List;
/**
* AI工具消费记录业务对象(主从批量录入)ai_consume_record
* AI工具消费项目业务对象(父子表项目级录入)ai_consume_project
*
* <p>上部公共字段对整批生效,下部 {@code details} 为明细行列表;新增时 {@code batchNo}
* 为空由服务端生成,编辑时携带 {@code batchNo} 定位原批(整批全删重插)。校验注解均不带
* groups,配合 Controller 的 {@code @Validated} 默认组生效(对齐既有 Param 风格)。</p>
* <p>上部项目级公共字段对整个项目生效,下部 {@code details} 为明细行列表;新增时
* {@code projectId} 为空、由数据库自增生成,编辑时携带 {@code projectId} 定位原项目
* (父行公共字段更新 + 明细整批全删重插)。校验注解均不带 groups,配合 Controller 的
* {@code @Validated} 默认组生效(对齐既有 Param 风格)。</p>
*
* @author LiuBin
* @date 2026-09-04
* @date 2026-09-18
*/
@Data
public class AiConsumeRecordParam {
public class AiConsumeProjectParam {
/**
* 批次号(编辑时定位原批;新增时为空、由服务端生成)
* 项目ID(父行主键;编辑时定位原项目,新增时为空由数据库自增生成)
*/
private String batchNo;
private Long projectId;
/**
* 所属部门ID(关联sys_dept.id)
......@@ -47,11 +49,17 @@ public class AiConsumeRecordParam {
private String userName;
/**
* 消费发生时间
* 消费发生日期(父级,纯日期)
*/
@NotNull(message = "消费发生时间不能为空")
private LocalDate consumeTime;
/**
* 成片时长(秒):项目级单一录入,非负整数,无视频产出默认0
*/
@PositiveOrZero(message = "成片时长不能为负数")
private Integer durationSeconds;
/**
* 备注
*/
......
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;
}
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;
}
......@@ -4,8 +4,7 @@ import com.fasterxml.jackson.annotation.JsonInclude;
import io.github.linpeilie.annotations.AutoMapper;
import lombok.Data;
import org.apache.fesod.sheet.annotation.ExcelIgnoreUnannotated;
import org.apache.fesod.sheet.annotation.ExcelProperty;
import com.anplus.hr.ai.domain.model.AiConsumeRecord;
import com.anplus.hr.ai.domain.model.AiConsumeProject;
import top.binfast.common.translation.annotation.Translation;
import top.binfast.common.translation.constant.TransConstant;
......@@ -16,36 +15,29 @@ import java.time.LocalDate;
import java.time.LocalDateTime;
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
* @date 2026-09-04
* @date 2026-09-18
*/
@Data
@ExcelIgnoreUnannotated
@AutoMapper(target = AiConsumeRecord.class)
public class AiConsumeRecordVo implements Serializable {
@AutoMapper(target = AiConsumeProject.class)
public class AiConsumeProjectVo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 主键ID
* 项目ID(父行主键;详情/编辑/删除以此定位项目)
*/
private Long id;
/**
* 批次号(主从批量录入元数据;非展示、非导出,前端编辑流按此定位整批)
*/
private String batchNo;
/**
* 行序号(批次内从 1 连续;非展示、非导出)
*/
private Integer lineSort;
/**
* 所属部门ID(关联sys_dept.id)
*/
......@@ -55,64 +47,49 @@ public class AiConsumeRecordVo implements Serializable {
* 所属部门名称(@Translation 列表自动翻译;导出由 ServiceImpl 批量回填)
*/
@Translation(type = TransConstant.DEPT_ID_TO_NAME, mapper = "deptId")
@ExcelProperty(value = "所属部门")
private String deptName;
/**
* 项目名称(用户直接输入的自由文本)
*/
@ExcelProperty(value = "项目名称")
private String projectName;
/**
* 使用人姓名
*/
@ExcelProperty(value = "姓名")
private String userName;
/**
* AI工具/平台名称(用户直接输入的自由文本)
* 消费发生日期(父级,纯日期)
*/
@ExcelProperty(value = "AI工具/平台名称")
private String toolName;
/**
* 国内/国外(中文原文,固定二值)
*/
@ExcelProperty(value = "国内/国外")
private String region;
private LocalDate consumeTime;
/**
* 消耗积分
* 项目总金额(= Σ 子表明细金额,事务重算冗余存储,人民币)
*/
@ExcelProperty(value = "消耗积分")
private BigDecimal consumePoints;
private BigDecimal amountCny;
/**
* 金额(人民币)
* 成片时长(秒)
*/
@ExcelProperty(value = "金额(人民币)")
private BigDecimal amountCny;
private Integer durationSeconds;
/**
* 消费发生时间
* 每分钟金额(= 项目总金额×60÷成片时长,2位小数HALF_UP;成片时长为0时NULL)
*/
@ExcelProperty(value = "消费时间")
private LocalDate consumeTime;
private BigDecimal amountPerMinute;
/**
* 备注
* 备注(父级)
*/
@ExcelProperty(value = "备注")
private String remark;
private LocalDateTime createTime;
/**
* 明细行列表(仅整批详情接口 GET /ai/consume/batch/{batchNo} 返回;
* 列表与单条详情为 null 且不参与 JSON 序列化;非展示、非导出)
* 明细子行列表(树形分页/详情内嵌返回,line_sort 升序;为 null 时不参与 JSON 序列化)
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
private List<AiConsumeRecordVo> details;
private List<AiConsumeDetailVo> children;
}
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> {
}
......@@ -19,28 +19,26 @@ import java.util.List;
public interface AiConsumeRecordMapper extends BinBaseMapper<AiConsumeRecord>, MPJBaseMapper<AiConsumeRecord> {
/**
* 按批次号查询整批记录(line_sort 升序)。
* 按项目ID查询明细记录(line_sort 升序)。
*
* <p>租户过滤由全局 TenantLineInnerInterceptor 自动注入,逻辑删除行由 @TableLogic 自动排除。</p>
*
* @param batchNo 批次号
* @return 该批次下的全部有效记录,按行序号升序
* @param projectId 所属项目ID
* @return 该项目下的全部有效明细,按行序号升序
*/
default List<AiConsumeRecord> selectByBatchNo(String batchNo) {
default List<AiConsumeRecord> selectByProjectId(Long projectId) {
return this.selectList(new LambdaQueryWrapper<AiConsumeRecord>()
.eq(AiConsumeRecord::getBatchNo, batchNo)
.eq(AiConsumeRecord::getProjectId, projectId)
.orderByAsc(AiConsumeRecord::getLineSort));
}
/**
* 按批次号物理删除整批记录(编辑「整批全删重插」用,绕过 @TableLogic 逻辑删除)。
* 按项目ID物理删除该项目全部明细(编辑「整项目全删重插」/级联删除用,绕过 @TableLogic 逻辑删除)。
*
* <p>SQL 显式带 tenant_id 条件(design.md D3 / R1),杜绝跨租户误删同 batch_no 行。</p>
*
* @param batchNo 批次号
* @param tenantId 当前租户ID
* @param projectId 所属项目ID
* @return 删除的行数
*/
int deleteByBatchNo(@Param("batchNo") String batchNo, @Param("tenantId") Long tenantId);
int deleteByProjectId(@Param("projectId") Long projectId);
}
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);
}
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);
}
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() + "%");
}
}
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);
}
}
<?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>
......@@ -5,12 +5,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<mapper namespace="com.anplus.hr.ai.mapper.AiConsumeRecordMapper">
<!--
按批次号物理删除整批(编辑「整批全删重插」用,绕过 @TableLogic 逻辑删除)。
显式带 tenant_id 条件(design.md D3 / R1),杜绝跨租户误删同 batch_no 行。
按项目ID物理删除该项目全部明细(编辑「整项目全删重插」/级联删除用,绕过 @TableLogic 逻辑删除)。
显式带 tenant_id 条件(design.md D4 / R1),杜绝跨租户误删同 project_id 行。
-->
<delete id="deleteByBatchNo">
delete from ai_consume_record
where batch_no = #{batchNo} and tenant_id = #{tenantId}
<delete id="deleteByProjectId">
delete from ai_consume_record where project_id = #{projectId}
</delete>
</mapper>
......@@ -40,25 +40,16 @@ export function consumeExport(data: Partial<Consume>) {
}
/**
* 消费记录详情(单条)
* @param id id
* @returns 详情
* 消费记录项目详情(父行公共字段 + durationSeconds + 内嵌全量明细子行 children;编辑回填用)
* @param id 项目ID(project_id)
* @returns 项目详情
*/
export function consumeInfo(id: ID) {
return alovaInstance.get<Consume>(`${Api.root}/${id}`);
}
/**
* 消费记录整批详情(主从批量录入编辑回填用)
* @param batchNo 批次号
* @returns 整批详情(公共字段 + details 明细列表)
*/
export function consumeBatchDetail(batchNo: string) {
return alovaInstance.get<Consume>(`${Api.root}/batch/${batchNo}`);
}
/**
* 消费记录新增(主从批量:公共字段 + details 明细数组)
* 消费记录新增(父子结构:项目级公共字段 + details 明细数组)
* @param data 批量入参
*/
export function consumeAdd(data: ConsumeForm) {
......@@ -66,8 +57,8 @@ export function consumeAdd(data: ConsumeForm) {
}
/**
* 消费记录修改(主从批量:整批全删重插)
* @param data 批量入参
* 消费记录修改(父子结构:父行公共字段更新 + 明细整批全删重插)
* @param data 项目入参
*/
export function consumeUpdate(data: ConsumeForm) {
return alovaInstance.putWithMsg<void>(Api.root, data);
......
/**
* @description: Consume interface
* @description: Consume interface(项目父行,对应后端 AiConsumeProjectVo)
*/
export interface Consume {
id: number;
/**
* 批次号(主从批量录入元数据;非展示、非导出,编辑流按此定位整批)
*/
batchNo?: string;
/**
* 行序号(批次内从 1 连续;非展示、非导出)
* 项目ID(父行主键;详情/编辑/删除以此定位项目)
*/
lineSort?: number;
id: number;
/**
* 所属部门ID
* 所属部门ID(关联 sys_dept.id)
*/
deptId?: number;
/**
* 所属部门名称(后端 @Translation 翻译 / 导出回填)
*/
deptName?: string;
/**
* 归属部门id(部门树查询用,含子部门)
*/
belongDeptId?: number | string;
/**
* 项目名称(用户直接输入的自由文本)
*/
......@@ -31,6 +22,41 @@ export interface Consume {
* 使用人姓名
*/
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工具/平台名称(用户直接输入的自由文本)
*/
......@@ -42,24 +68,15 @@ export interface Consume {
/**
* 消耗积分
*/
consumePoints: number;
/**
* 金额(人民币)
*/
amountCny: number;
/**
* 消费发生时间
*/
consumeTime: string;
consumePoints?: number;
/**
* 备注
* 单条明细金额(人民币;区别于父行项目总金额);展开子表「金额(人民币)」列绑定字段
*/
remark: string;
createTime: string;
amountCny?: number;
/**
* 明细行列表(仅整批详情接口 GET /batch/{batchNo} 返回;列表与单条详情为 undefined)
* 行序号(项目内明细从 1 连续排序)
*/
details?: Consume[];
lineSort?: number;
}
/**
......@@ -85,13 +102,13 @@ export interface ConsumeDetailForm {
}
/**
* @description: 消费记录批量入参(主从结构,对应后端 AiConsumeRecordParam)
* @description: 消费项目入参(父子结构,对应后端 AiConsumeProjectParam)
*/
export interface ConsumeForm {
/**
* 批次号(编辑时定位原批;新增时为空、由服务端生成)
* 项目ID(编辑时定位原项目;新增时为空、由数据库自增生成)
*/
batchNo?: string;
projectId?: number;
/**
* 所属部门ID
*/
......@@ -105,9 +122,13 @@ export interface ConsumeForm {
*/
userName?: string;
/**
* 消费发生时间(日期类型,落库 date 当天日期)
* 消费发生日期(日期类型,落库 date 当天日期)
*/
consumeTime?: string;
/**
* 成片时长(秒,非负整数;无视频产出默认 0)
*/
durationSeconds?: number;
/**
* 备注
*/
......@@ -123,7 +144,7 @@ export interface ConsumeForm {
*/
export interface ConsumeSummary {
/**
* 金额(人民币)全量合计(两位小数)
* 项目总金额全量合计(两位小数)
*/
amountCny: number;
}
......@@ -7,7 +7,7 @@ import type { VxeGridInstance, VxeGridProps } from 'vxe-table';
import { computed, nextTick, ref, useTemplateRef } from 'vue';
import { consumeAdd, consumeBatchDetail, consumeUpdate } from '@/api/ai/consume';
import { consumeAdd, consumeInfo, consumeUpdate } from '@/api/ai/consume';
import { getDeptTree } from '@/api/system/user';
import { useVbenDrawer } from '@/components';
import {
......@@ -27,6 +27,7 @@ import {
Input,
InputNumber,
Select,
TimePicker,
} from 'antdv-next';
import { VxeGrid } from 'vxe-table';
......@@ -37,23 +38,45 @@ const title = computed(() => {
return isUpdate.value ? $t('pages.common.edit') : $t('pages.common.add');
});
// 公共字段(主表):所属部门 / 项目名称 / 使用人姓名 / 消费时间 / 备注
// 公共字段(项目级):所属部门 / 项目名称 / 使用人姓名 / 消费时间 / 成片时长 / 备注
type CommonFormData = {
batchNo?: string;
projectId?: number;
consumeTime?: string;
deptId?: number | string;
projectName?: string;
remark?: 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 {
return {
batchNo: undefined,
projectId: undefined,
deptId: undefined,
projectName: undefined,
userName: undefined,
consumeTime: undefined,
duration: undefined,
remark: '',
};
}
......@@ -263,26 +286,27 @@ const [BasicDrawer, drawerApi] = useVbenDrawer({
return null;
}
drawerApi.drawerLoading(true);
const { batchNo, id } = drawerApi.getData() as {
batchNo?: string;
const { id } = drawerApi.getData() as {
id?: number | string;
};
isUpdate.value = !!id;
// 初始化部门树
await setupDeptSelect();
await nextTick();
// 编辑态:按 batch_no 回填整批(公共字段 + 明细);新增态:默认一空行
if (isUpdate.value && batchNo) {
const batch = await consumeBatchDetail(batchNo);
// 编辑态:按 project_id 调 GET /ai/consume/{id} 回填整项目(公共字段 + 成片时长 + 全量明细);
// 不复用列表行——列表 children 在 toolName 搜索时仅含匹配子行会丢明细(design D6)
if (isUpdate.value && id) {
const project = await consumeInfo(id);
formData.value = {
batchNo: batch?.batchNo ?? batchNo,
deptId: batch?.deptId,
projectName: batch?.projectName,
userName: batch?.userName,
consumeTime: batch?.consumeTime,
remark: batch?.remark ?? '',
projectId: project?.id,
deptId: project?.deptId,
projectName: project?.projectName,
userName: project?.userName,
consumeTime: project?.consumeTime,
duration: secondsToMmss(project?.durationSeconds),
remark: project?.remark ?? '',
};
const details = (batch?.details ?? []).map((item) => ({
const details = (project?.children ?? []).map((item) => ({
toolName: item.toolName,
region: item.region,
consumePoints: item.consumePoints,
......@@ -317,12 +341,13 @@ async function handleConfirm() {
window.message.warning('请至少添加一行消费明细');
return;
}
// 4. 组装主从批量入参
// 4. 组装父子项目入参(成片时长 mm:ss → 整数秒 durationSeconds,design D5)
const common = cloneDeep(formData.value);
const data: ConsumeForm = {
batchNo: common.batchNo,
projectId: common.projectId,
consumeTime: common.consumeTime,
deptId: common.deptId as number | undefined,
durationSeconds: mmssToSeconds(common.duration),
details: cloneDeep(details),
projectName: common.projectName,
remark: common.remark,
......@@ -391,6 +416,15 @@ async function handleClosed() {
v-model:value="formData.consumeTime"
/>
</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">
<FormTextArea
allow-clear
......
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'] = [
{ type: 'checkbox', width: 60 },
{ type: 'expand', width: 50, slots: { content: 'expandContent' } },
{
title: '所属部门',
field: 'deptName',
minWidth: 130,
},
{
title: '项目名称',
field: 'projectName',
minWidth: 140,
},
{
title: '姓名',
field: 'userName',
minWidth: 100,
},
{
title: 'AI工具/平台名称',
field: 'toolName',
},
{
title: '国内/国外',
field: 'region',
title: '项目总金额',
field: 'amountCny',
minWidth: 100,
align: 'right',
formatter: ({ cellValue }) => amountText(cellValue),
},
{
title: '消耗积分',
field: 'consumePoints',
title: '成片时长',
field: 'durationSeconds',
minWidth: 100,
align: 'center',
formatter: ({ cellValue }) => durationText(cellValue),
},
{
title: '金额(人民币)',
field: 'amountCny',
title: '每分钟金额',
field: 'amountPerMinute',
minWidth: 90,
align: 'right',
formatter: ({ cellValue }) => amountText(cellValue),
},
{
title: '消费时间',
field: 'consumeTime',
minWidth: 120,
},
{
title: '备注',
field: 'remark',
minWidth: 120,
},
{
field: 'action',
......@@ -47,3 +86,36 @@ export const columns: VxeGridProps['columns'] = [
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),
},
];
......@@ -24,7 +24,7 @@ import { VxeGrid } from 'vxe-table';
import consumeDrawer from './consume-drawer.vue';
import ConsumeSearchForm from './consume-search.vue';
import { columns } from './data';
import { columns, detailColumns } from './data';
// 左边部门用
const selectDeptId = ref<string[]>([]);
......@@ -45,8 +45,10 @@ const gridOptions = withDefaultVxeGridOptions<Consume>({
// 翻页时保留选中状态
reserve: true,
trigger: 'cell',
// 主网格每行均为项目父行(明细移入展开子表),全部可勾选,无需 checkMethod 过滤(design D7)
},
columns,
size: 'medium',
height: 'auto',
keepSource: true,
pagerConfig: {},
......@@ -73,10 +75,13 @@ const gridOptions = withDefaultVxeGridOptions<Consume>({
}),
consumeSummary(values),
]);
// 合计写入独立 footerData;键名按 data.ts 列 field 对齐;toFixed(2) 规避 JSON 数值退化(design D6)
// 合计写入独立 footerData;amountCny 键对齐 data.ts「项目总金额」列 field——
// 合计口径为命中明细金额之和,落「项目总金额」列、其余列留空(design D5,req1);值来源仍是 summary 的 amountCny 单值
footerData.value = [
{ deptName: '合计', amountCny: Number(sum.amountCny).toFixed(2) },
];
// 主网格每行 = 一个项目父行,children 原样保留供展开子表直接渲染(design D7);
// 明细移入独立子表实例,父子不再同表格 → 无需子行字段改名 / 复合键 _rid / rowType 判别
return listRes;
} finally {
tableLoading.value = false;
......@@ -85,10 +90,14 @@ const gridOptions = withDefaultVxeGridOptions<Consume>({
},
},
rowConfig: {
// 主网格每行为项目父行,直接用项目 id 作唯一键(明细子表另有独立实例,无父子 id 冲突,design D7)
keyField: 'id',
},
// footer 页面局部开启(不改全局 vxe 配置,design D2)
// footer 页面局部开启(不改全局 vxe 配置,design D7)
showFooter: true,
// 展开行:主网格每行为项目父行,展开后内嵌明细子表(数据取 row.children,见 expandContent slot);
// pagerConfig 保持启用 → 分页按父行计数(明细子表不参与分页,design D7)
expandConfig: {},
toolbarConfig: {
slots: {
buttons: 'toolbar-left',
......@@ -98,6 +107,28 @@ const gridOptions = withDefaultVxeGridOptions<Consume>({
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 { query, reload } = useTableQuery(
searchFormRef,
......@@ -121,7 +152,8 @@ function handleAdd() {
}
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();
}
......@@ -186,6 +218,7 @@ function getCheckedRows() {
if (!table) {
return [];
}
// 主网格每行均为项目父行(明细在独立子表),勾选记录即项目行,无需 rowType 过滤(design D7)
return [
...table.getCheckboxRecords(),
...table.getCheckboxReserveRecords(),
......@@ -258,6 +291,29 @@ function syncCheckedRows() {
</a-button>
</Space>
</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 }">
<Space>
<action-button
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment