Commit 40305b50 authored by 刘斌's avatar 刘斌

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

parent 6ceb318f
...@@ -59,10 +59,10 @@ public class AiConsumeProjectCtrl { ...@@ -59,10 +59,10 @@ public class AiConsumeProjectCtrl {
} }
/** /**
* 查询当前筛选条件下的项目总金额合计(跨所有分页的全量结果,口径与列表完全一致)。 * 查询当前筛选条件下的命中明细金额合计与积分合计(跨所有分页的全量结果,口径与列表完全一致)。
* *
* @param param 查询条件(与分页查询共用同一条件对象) * @param param 查询条件(与分页查询共用同一条件对象)
* @return 合计载体(amountCny 保留两位小数;无匹配数据时为 0.00) * @return 合计载体(amountCny 明细金额合计 + consumePoints 明细积分合计,均保留两位小数;无匹配数据时均为 0.00)
*/ */
@SaCheckPermission("ai:consume:list") @SaCheckPermission("ai:consume:list")
@GetMapping("/summary") @GetMapping("/summary")
......
...@@ -59,6 +59,11 @@ public class AiConsumeProject extends TenantModel { ...@@ -59,6 +59,11 @@ public class AiConsumeProject extends TenantModel {
*/ */
private BigDecimal amountCny; private BigDecimal amountCny;
/**
* 消费积分(= Σ 子表明细积分,随明细增删改事务聚合重算)
*/
private BigDecimal consumePoints;
/** /**
* 每分钟金额(= 项目总金额×60÷成片时长,2位小数HALF_UP;成片时长为0时为NULL;派生计算属 C2) * 每分钟金额(= 项目总金额×60÷成片时长,2位小数HALF_UP;成片时长为0时为NULL;派生计算属 C2)
*/ */
......
...@@ -13,11 +13,11 @@ import java.time.LocalDate; ...@@ -13,11 +13,11 @@ import java.time.LocalDate;
* AI工具消费记录扁平导出行视图对象(导出专用,D8) * AI工具消费记录扁平导出行视图对象(导出专用,D8)
* *
* <p>导出({@code POST /ai/consume/export})将父子表 JOIN 展开为扁平明细行:每行 = 一条明细子行 * <p>导出({@code POST /ai/consume/export})将父子表 JOIN 展开为扁平明细行:每行 = 一条明细子行
* + 该项目父级汇总列(所属部门 / 项目名称 / 姓名 / 消费时间 / 项目总金额 / 成片时长 / 每分钟金额 / * + 该项目父级汇总列(所属部门 / 项目名称 / 姓名 / 消费时间 / 项目总金额 / 消费积分 / 成片时长 / 每分钟金额 /
* 备注)重复展示,其后接明细列(AI工具名称 / 国内外 / 消耗积分 / 金额)。列序与标签对齐 spec * 备注)重复展示,其后接明细列(AI工具名称 / 国内外 / 消耗积分 / 金额)。列序与标签对齐 spec
* 「导出末尾合计行」,并保持与既有导出列口径稳定;部门名由 ServiceImpl 批量回填(导出链不触发 * 「导出末尾合计行」,并保持与既有导出列口径稳定;部门名由 ServiceImpl 批量回填(导出链不触发
* JSON 翻译)。末尾合计行:所属部门 = 「合计」、项目总金额 = 全量命中明细金额合计,其余列(含「金额(人民币)」 * JSON 翻译)。末尾合计行:所属部门 = 「合计」、项目总金额 = 全量命中明细金额合计、消费积分 = 全量命中
* 明细列)留空。成片时长列以分秒文本输出(spec「导出成片时长格式化」)。</p> * 明细积分合计,其余列(含「金额(人民币)」明细列)留空。成片时长列以分秒文本输出(spec「导出成片时长格式化」)。</p>
* *
* <p>非导出辅助字段 {@code deptId} 无 {@code @ExcelProperty},被 {@code @ExcelIgnoreUnannotated} * <p>非导出辅助字段 {@code deptId} 无 {@code @ExcelProperty},被 {@code @ExcelIgnoreUnannotated}
* 忽略,仅用于批量查询部门名回填 {@code deptName}。两个金额列语义不同:{@code amountCny} 为父级 * 忽略,仅用于批量查询部门名回填 {@code deptName}。两个金额列语义不同:{@code amountCny} 为父级
...@@ -68,6 +68,13 @@ public class AiConsumeExportVo implements Serializable { ...@@ -68,6 +68,13 @@ public class AiConsumeExportVo implements Serializable {
@ExcelProperty(value = "项目总金额") @ExcelProperty(value = "项目总金额")
private BigDecimal amountCny; private BigDecimal amountCny;
/**
* 消费积分(父级汇总列,= Σ 子表明细积分,每明细行重复;末尾合计行此列填全量命中明细积分合计;
* 与明细列 {@link #consumePoints}「消耗积分」配对,两者语义不同勿混)
*/
@ExcelProperty(value = "消费积分")
private BigDecimal projectConsumePoints;
/** /**
* 成片时长(父级汇总列,每明细行重复):导出层格式化为分秒文本({@code >60 秒→「X分Y秒」、 * 成片时长(父级汇总列,每明细行重复):导出层格式化为分秒文本({@code >60 秒→「X分Y秒」、
* 1..60 秒→「X秒」、0→「0秒」、NULL→留白}),对齐前端列表 durationText 约定(spec「导出成片时长格式化」) * 1..60 秒→「X秒」、0→「0秒」、NULL→留白}),对齐前端列表 durationText 约定(spec「导出成片时长格式化」)
......
...@@ -69,6 +69,11 @@ public class AiConsumeProjectVo implements Serializable { ...@@ -69,6 +69,11 @@ public class AiConsumeProjectVo implements Serializable {
*/ */
private BigDecimal amountCny; private BigDecimal amountCny;
/**
* 消费积分(= Σ 子表明细积分,事务重算冗余存储)
*/
private BigDecimal consumePoints;
/** /**
* 成片时长(秒) * 成片时长(秒)
*/ */
......
...@@ -24,4 +24,9 @@ public class AiConsumeRecordSummaryVo implements Serializable { ...@@ -24,4 +24,9 @@ public class AiConsumeRecordSummaryVo implements Serializable {
*/ */
private BigDecimal amountCny; private BigDecimal amountCny;
/**
* 消费积分合计:当前查询条件筛选出的全部命中明细子行积分(跨所有分页)求和,保留两位小数(与 amountCny 同口径)
*/
private BigDecimal consumePoints;
} }
...@@ -40,10 +40,10 @@ public interface AiConsumeProjectServ extends IService<AiConsumeProject> { ...@@ -40,10 +40,10 @@ public interface AiConsumeProjectServ extends IService<AiConsumeProject> {
List<AiConsumeExportVo> queryList(AiConsumeProjectListParam param); List<AiConsumeExportVo> queryList(AiConsumeProjectListParam param);
/** /**
* 查询当前条件下的项目总金额合计(跨所有分页的全量筛选结果,口径与列表完全一致) * 查询当前条件下的命中明细金额合计与积分合计(跨所有分页的全量筛选结果,口径与列表完全一致)
* *
* @param param 查询条件(与分页查询共用同一条件对象 AiConsumeProjectListParam) * @param param 查询条件(与分页查询共用同一条件对象 AiConsumeProjectListParam)
* @return 合计载体(amountCny 保留两位小数;无匹配数据时为 0.00) * @return 合计载体(amountCny 明细金额合计 + consumePoints 明细积分合计,均保留两位小数;无匹配数据时均为 0.00)
*/ */
AiConsumeRecordSummaryVo querySummary(AiConsumeProjectListParam param); AiConsumeRecordSummaryVo querySummary(AiConsumeProjectListParam param);
......
...@@ -107,8 +107,9 @@ public class AiConsumeProjectServImpl extends ServiceImpl<AiConsumeProjectMapper ...@@ -107,8 +107,9 @@ public class AiConsumeProjectServImpl extends ServiceImpl<AiConsumeProjectMapper
.build(); .build();
List<AiConsumeProject> parents = aiConsumeProjectMapper.selectList(lambdaQuery); List<AiConsumeProject> parents = aiConsumeProjectMapper.selectList(lambdaQuery);
List<AiConsumeExportVo> rows = new ArrayList<>(); List<AiConsumeExportVo> rows = new ArrayList<>();
//合计 = Σ 命中明细子行金额(口径与列表底部合计一致;无 toolName 时等于父表项目总金额之和);无数据落 ZERO 仍追加合计行 //合计 = Σ 命中明细子行金额 / 积分(口径与列表底部合计一致;无 toolName 时金额合计等于父表项目总金额之和);无数据落 ZERO 仍追加合计行
BigDecimal total = BigDecimal.ZERO; BigDecimal total = BigDecimal.ZERO;
BigDecimal totalPoints = BigDecimal.ZERO;
if (CollUtil.isNotEmpty(parents)) { if (CollUtil.isNotEmpty(parents)) {
//② project_id IN 批量查明细(toolName 过滤),line_sort 升序 //② project_id IN 批量查明细(toolName 过滤),line_sort 升序
List<Long> projectIds = parents.stream().map(AiConsumeProject::getId).collect(Collectors.toList()); List<Long> projectIds = parents.stream().map(AiConsumeProject::getId).collect(Collectors.toList());
...@@ -121,6 +122,11 @@ public class AiConsumeProjectServImpl extends ServiceImpl<AiConsumeProjectMapper ...@@ -121,6 +122,11 @@ public class AiConsumeProjectServImpl extends ServiceImpl<AiConsumeProjectMapper
.filter(ObjectUtil::isNotNull) .filter(ObjectUtil::isNotNull)
.reduce(BigDecimal.ZERO, BigDecimal::add) .reduce(BigDecimal.ZERO, BigDecimal::add)
.setScale(2, RoundingMode.HALF_UP); .setScale(2, RoundingMode.HALF_UP);
//合计 = Σ details.consumePoints(同一命中明细集;NULL 明细积分忽略,spec「导出末尾合计行」)
totalPoints = details.stream().map(AiConsumeRecord::getConsumePoints)
.filter(ObjectUtil::isNotNull)
.reduce(BigDecimal.ZERO, BigDecimal::add)
.setScale(2, RoundingMode.HALF_UP);
Map<Long, List<AiConsumeRecord>> detailsByProject = details.stream() Map<Long, List<AiConsumeRecord>> detailsByProject = details.stream()
.collect(Collectors.groupingBy(AiConsumeRecord::getProjectId)); .collect(Collectors.groupingBy(AiConsumeRecord::getProjectId));
//③ 批量回填部门名(导出链不触发 JSON 翻译,spec「部门名称展示与导出翻译」) //③ 批量回填部门名(导出链不触发 JSON 翻译,spec「部门名称展示与导出翻译」)
...@@ -128,20 +134,27 @@ public class AiConsumeProjectServImpl extends ServiceImpl<AiConsumeProjectMapper ...@@ -128,20 +134,27 @@ public class AiConsumeProjectServImpl extends ServiceImpl<AiConsumeProjectMapper
.filter(ObjectUtil::isNotNull).collect(Collectors.toSet()); .filter(ObjectUtil::isNotNull).collect(Collectors.toSet());
Map<Long, String> deptNames = CollUtil.isEmpty(deptIds) Map<Long, String> deptNames = CollUtil.isEmpty(deptIds)
? Collections.emptyMap() : sysDeptServ.selectDeptNamesByIds(deptIds); ? Collections.emptyMap() : sysDeptServ.selectDeptNamesByIds(deptIds);
//④ JOIN 展开扁平行:每明细一行 + 父级汇总列重复 //④ JOIN 展开扁平行:每明细一行;父级汇总列仅该项目首条明细行(line_sort 升序的首条命中明细)写入,其余明细行留空(spec「消费记录导出末尾合计行」)
for (AiConsumeProject parent : parents) { for (AiConsumeProject parent : parents) {
String deptName = deptNames.get(parent.getDeptId()); String deptName = deptNames.get(parent.getDeptId());
for (AiConsumeRecord child : detailsByProject.getOrDefault(parent.getId(), Collections.emptyList())) { List<AiConsumeRecord> projectDetails = detailsByProject.getOrDefault(parent.getId(), Collections.emptyList());
for (int i = 0; i < projectDetails.size(); i++) {
AiConsumeRecord child = projectDetails.get(i);
AiConsumeExportVo row = new AiConsumeExportVo(); AiConsumeExportVo row = new AiConsumeExportVo();
row.setDeptId(parent.getDeptId()); if (i == 0) {
row.setDeptName(deptName); //首条明细行:写入父级汇总列(含部门名);非首行不写入,保持 null 由 Fesod 输出空单元格
row.setProjectName(parent.getProjectName()); row.setDeptId(parent.getDeptId());
row.setUserName(parent.getUserName()); row.setDeptName(deptName);
row.setConsumeTime(parent.getConsumeTime()); row.setProjectName(parent.getProjectName());
row.setAmountCny(parent.getAmountCny()); row.setUserName(parent.getUserName());
row.setDurationText(formatDuration(parent.getDurationSeconds())); row.setConsumeTime(parent.getConsumeTime());
row.setAmountPerMinute(parent.getAmountPerMinute()); row.setAmountCny(parent.getAmountCny());
row.setRemark(parent.getRemark()); row.setProjectConsumePoints(parent.getConsumePoints());
row.setDurationText(formatDuration(parent.getDurationSeconds()));
row.setAmountPerMinute(parent.getAmountPerMinute());
row.setRemark(parent.getRemark());
}
//明细列逐行照写
row.setToolName(child.getToolName()); row.setToolName(child.getToolName());
row.setRegion(child.getRegion()); row.setRegion(child.getRegion());
row.setConsumePoints(child.getConsumePoints()); row.setConsumePoints(child.getConsumePoints());
...@@ -151,28 +164,30 @@ public class AiConsumeProjectServImpl extends ServiceImpl<AiConsumeProjectMapper ...@@ -151,28 +164,30 @@ public class AiConsumeProjectServImpl extends ServiceImpl<AiConsumeProjectMapper
} }
} }
//⑤ 末尾追加合计行(无数据时 total=0.00 仍追加,spec「导出文件末尾含合计行」) //⑤ 末尾追加合计行(无数据时 total=0.00 仍追加,spec「导出文件末尾含合计行」)
rows.add(buildTotalRow(total)); rows.add(buildTotalRow(total, totalPoints));
return rows; return rows;
} }
/** /**
* 查询当前条件下的命中明细金额合计(跨所有分页的全量筛选结果,口径与列表/导出展示明细逐一等价) * 查询当前条件下的命中明细金额合计与积分合计(跨所有分页的全量筛选结果,口径与列表/导出展示明细逐一等价)
* *
* <p>合计 = Σ 命中明细子行 {@code ai_consume_record.amount_cny}:先复用 {@link #buildProjectCondition} * <p>合计 = Σ 命中明细子行 {@code ai_consume_record.amount_cny} 与 {@code consume_points}:先复用
* 取命中父行 id 集合(含 toolName EXISTS 命中),再对子表按 {@code project_id IN + toolName LIKE} 做 SQL SUM。 * {@link #buildProjectCondition} 取命中父行 id 集合(含 toolName EXISTS 命中),再对子表按
* 按 toolName 过滤时合计仅覆盖匹配明细,不回退父行全项目总金额(spec「消费记录合计查询接口」)。</p> * {@code project_id IN + toolName LIKE} 做 SQL SUM。按 toolName 过滤时合计仅覆盖匹配明细,
* 不回退父行全项目汇总值(spec「消费记录合计查询接口」双字段)。</p>
* *
* @param param 查询条件(与分页查询共用同一条件对象) * @param param 查询条件(与分页查询共用同一条件对象)
* @return 合计载体(amountCny 为明细金额合计,保留两位小数;无匹配数据时为 0.00) * @return 合计载体(amountCny 明细金额合计 + consumePoints 明细积分合计,均保留两位小数;无匹配数据时均为 0.00)
*/ */
@Override @Override
public AiConsumeRecordSummaryVo querySummary(AiConsumeProjectListParam param) { public AiConsumeRecordSummaryVo querySummary(AiConsumeProjectListParam param) {
AiConsumeRecordSummaryVo summary = new AiConsumeRecordSummaryVo(); AiConsumeRecordSummaryVo summary = new AiConsumeRecordSummaryVo();
//① 复用 buildProjectCondition 取命中父行(口径与列表/导出完全一致,含 toolName EXISTS 命中) //① 复用 buildProjectCondition 取命中父行(口径与列表/导出完全一致,含 toolName EXISTS 命中)
List<AiConsumeProject> parents = aiConsumeProjectMapper.selectList(buildProjectCondition(param).build()); List<AiConsumeProject> parents = aiConsumeProjectMapper.selectList(buildProjectCondition(param).build());
//② 命中父行为空 → 合计 0.00,避免 in(空集合) 退化并省去无谓子查询(design D6) //② 命中父行为空 → 双合计 0.00,避免 in(空集合) 退化并省去无谓子查询(design D6)
if (CollUtil.isEmpty(parents)) { if (CollUtil.isEmpty(parents)) {
summary.setAmountCny(BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP)); summary.setAmountCny(BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP));
summary.setConsumePoints(BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP));
return summary; return summary;
} }
List<Long> projectIds = parents.stream().map(AiConsumeProject::getId).collect(Collectors.toList()); List<Long> projectIds = parents.stream().map(AiConsumeProject::getId).collect(Collectors.toList());
...@@ -189,6 +204,17 @@ public class AiConsumeProjectServImpl extends ServiceImpl<AiConsumeProjectMapper ...@@ -189,6 +204,17 @@ public class AiConsumeProjectServImpl extends ServiceImpl<AiConsumeProjectMapper
? BigDecimal.ZERO ? BigDecimal.ZERO
: Convert.toBigDecimal(objs.get(0), BigDecimal.ZERO); : Convert.toBigDecimal(objs.get(0), BigDecimal.ZERO);
summary.setAmountCny(amountCny.setScale(2, RoundingMode.HALF_UP)); summary.setAmountCny(amountCny.setScale(2, RoundingMode.HALF_UP));
//④ 同口径 SQL SUM 命中明细积分(与金额共用命中范围;NULL 由 SUM 忽略,spec「消费记录合计查询接口」双字段)
LambdaQueryWrapper<AiConsumeRecord> pointsSumWrapper = QueryBuilder.lambda(AiConsumeRecord.class)
.in(AiConsumeRecord::getProjectId, projectIds)
.likeIfText(AiConsumeRecord::getToolName, param.getToolName())
.selectSum(AiConsumeRecord::getConsumePoints)
.build();
List<Object> pointsObjs = aiConsumeRecordMapper.selectObjs(pointsSumWrapper);
BigDecimal consumePoints = CollUtil.isEmpty(pointsObjs)
? BigDecimal.ZERO
: Convert.toBigDecimal(pointsObjs.get(0), BigDecimal.ZERO);
summary.setConsumePoints(consumePoints.setScale(2, RoundingMode.HALF_UP));
return summary; return summary;
} }
...@@ -321,11 +347,12 @@ public class AiConsumeProjectServImpl extends ServiceImpl<AiConsumeProjectMapper ...@@ -321,11 +347,12 @@ public class AiConsumeProjectServImpl extends ServiceImpl<AiConsumeProjectMapper
} }
/** /**
* 全量重算项目总金额 + 派生每分钟金额并回写父表(design D4,Clarify Q2 全量 SUM 非增量)。 * 全量重算项目总金额 / 消费积分 + 派生每分钟金额并回写父表(design D2/D4,全量 SUM 非增量)。
* *
* <p>项目总金额 = SUM(子表 amount_cny WHERE project_id AND deleted=0)(租户由拦截器注入,NULL 落 ZERO); * <p>项目总金额 = SUM(子表 amount_cny WHERE project_id AND deleted=0)(租户由拦截器注入,NULL 落 ZERO);
* 消费积分 = SUM(子表 consume_points WHERE project_id AND deleted=0)(同口径,NULL 由 SUM 忽略);
* 每分钟金额 = 总金额×60÷成片时长(2 位 HALF_UP),成片时长为 0/null 时置 NULL(避免除零,spec「每分钟金额派生」)。 * 每分钟金额 = 总金额×60÷成片时长(2 位 HALF_UP),成片时长为 0/null 时置 NULL(避免除零,spec「每分钟金额派生」)。
* 用 LambdaUpdateWrapper 显式 set 以强制写入 NULL(绕过 updateById 的 NOT_NULL 策略)。</p> * 金额 / 积分 / 每分钟金额以同一条 UPDATE 回写(spec「消费项目消费积分事务重算」);用 LambdaUpdateWrapper 显式 set 以强制写入 NULL(绕过 updateById 的 NOT_NULL 策略)。</p>
* *
* @param projectId 项目id * @param projectId 项目id
* @param durationSeconds 成片时长(秒) * @param durationSeconds 成片时长(秒)
...@@ -340,6 +367,16 @@ public class AiConsumeProjectServImpl extends ServiceImpl<AiConsumeProjectMapper ...@@ -340,6 +367,16 @@ public class AiConsumeProjectServImpl extends ServiceImpl<AiConsumeProjectMapper
? BigDecimal.ZERO ? BigDecimal.ZERO
: Convert.toBigDecimal(objs.get(0), BigDecimal.ZERO)) : Convert.toBigDecimal(objs.get(0), BigDecimal.ZERO))
.setScale(2, RoundingMode.HALF_UP); .setScale(2, RoundingMode.HALF_UP);
//消费积分 = SUM(consume_points)(与金额同模式独立 SUM;NULL 由 SQL SUM 忽略,空落 ZERO)
LambdaQueryWrapper<AiConsumeRecord> pointsSumWrapper = QueryBuilder.lambda(AiConsumeRecord.class)
.eq(AiConsumeRecord::getProjectId, projectId)
.selectSum(AiConsumeRecord::getConsumePoints)
.build();
List<Object> pointsObjs = aiConsumeRecordMapper.selectObjs(pointsSumWrapper);
BigDecimal points = (CollUtil.isEmpty(pointsObjs)
? BigDecimal.ZERO
: Convert.toBigDecimal(pointsObjs.get(0), BigDecimal.ZERO))
.setScale(2, RoundingMode.HALF_UP);
BigDecimal amountPerMinute = null; BigDecimal amountPerMinute = null;
if (durationSeconds != null && durationSeconds > 0) { if (durationSeconds != null && durationSeconds > 0) {
amountPerMinute = total.multiply(BigDecimal.valueOf(60)) amountPerMinute = total.multiply(BigDecimal.valueOf(60))
...@@ -348,19 +385,23 @@ public class AiConsumeProjectServImpl extends ServiceImpl<AiConsumeProjectMapper ...@@ -348,19 +385,23 @@ public class AiConsumeProjectServImpl extends ServiceImpl<AiConsumeProjectMapper
aiConsumeProjectMapper.update(null, new LambdaUpdateWrapper<AiConsumeProject>() aiConsumeProjectMapper.update(null, new LambdaUpdateWrapper<AiConsumeProject>()
.eq(AiConsumeProject::getId, projectId) .eq(AiConsumeProject::getId, projectId)
.set(AiConsumeProject::getAmountCny, total) .set(AiConsumeProject::getAmountCny, total)
.set(AiConsumeProject::getConsumePoints, points)
.set(AiConsumeProject::getAmountPerMinute, amountPerMinute)); .set(AiConsumeProject::getAmountPerMinute, amountPerMinute));
} }
/** /**
* 构建导出末尾合计行:所属部门列固定「合计」、项目总金额列填全量命中明细金额合计,其余列(含金额(人民币)明细列)留空(spec「导出末尾合计行」,design D5)。 * 构建导出末尾合计行:所属部门列固定「合计」、项目总金额列填全量命中明细金额合计、消费积分列填全量命中明细积分合计,
* 其余列(含金额(人民币)明细列)留空(spec「导出末尾合计行」,design D4)。
* *
* @param total 全量命中明细金额合计(两位小数) * @param total 全量命中明细金额合计(两位小数)
* @param totalPoints 全量命中明细积分合计(两位小数)
* @return 合计行 * @return 合计行
*/ */
private AiConsumeExportVo buildTotalRow(BigDecimal total) { private AiConsumeExportVo buildTotalRow(BigDecimal total, BigDecimal totalPoints) {
AiConsumeExportVo totalRow = new AiConsumeExportVo(); AiConsumeExportVo totalRow = new AiConsumeExportVo();
totalRow.setDeptName("合计"); totalRow.setDeptName("合计");
totalRow.setAmountCny(total); totalRow.setAmountCny(total);
totalRow.setProjectConsumePoints(totalPoints);
return totalRow; return totalRow;
} }
......
...@@ -23,9 +23,9 @@ export function consumeList(params?: PageQuery) { ...@@ -23,9 +23,9 @@ export function consumeList(params?: PageQuery) {
} }
/** /**
* 查询消费记录金额合计(跨所有分页的全量筛选结果,口径与列表一致) * 查询消费记录金额与积分合计(跨所有分页的全量筛选结果,口径与列表一致)
* @param params 请求参数(与列表查询共用同一条件) * @param params 请求参数(与列表查询共用同一条件)
* @returns 合计载体(amountCny) * @returns 合计载体(amountCny 明细金额合计 + consumePoints 明细积分合计)
*/ */
export function consumeSummary(params?: PageQuery) { export function consumeSummary(params?: PageQuery) {
return alovaInstance.get<ConsumeSummary>(Api.consumeSummary, { params }); return alovaInstance.get<ConsumeSummary>(Api.consumeSummary, { params });
......
...@@ -30,6 +30,10 @@ export interface Consume { ...@@ -30,6 +30,10 @@ export interface Consume {
* 项目总金额(= Σ 明细金额,人民币;列表「项目总金额」列绑定字段) * 项目总金额(= Σ 明细金额,人民币;列表「项目总金额」列绑定字段)
*/ */
amountCny: number; amountCny: number;
/**
* 消费积分(= Σ 明细积分,列表「消费积分」列绑定字段;存量未回填行为 null 时列留白)
*/
consumePoints?: number;
/** /**
* 成片时长(秒,非负整数) * 成片时长(秒,非负整数)
*/ */
...@@ -144,7 +148,11 @@ export interface ConsumeForm { ...@@ -144,7 +148,11 @@ export interface ConsumeForm {
*/ */
export interface ConsumeSummary { export interface ConsumeSummary {
/** /**
* 项目总金额全量合计(两位小数) * 明细金额合计(命中明细子行金额之和,两位小数)
*/ */
amountCny: number; amountCny: number;
/**
* 明细积分合计(命中明细子行积分之和,两位小数;与 amountCny 同口径)
*/
consumePoints: number;
} }
...@@ -30,6 +30,7 @@ import { ...@@ -30,6 +30,7 @@ import {
TimePicker, TimePicker,
} from 'antdv-next'; } from 'antdv-next';
import { VxeGrid } from 'vxe-table'; import { VxeGrid } from 'vxe-table';
import { cnyText } from './data.ts'
const emit = defineEmits<{ reload: [] }>(); const emit = defineEmits<{ reload: [] }>();
...@@ -186,12 +187,14 @@ const detailColumns: VxeGridProps['columns'] = [ ...@@ -186,12 +187,14 @@ const detailColumns: VxeGridProps['columns'] = [
title: '金额(人民币)', title: '金额(人民币)',
minWidth: 130, minWidth: 130,
align: 'center', align: 'center',
formatter: ({ cellValue }) => cnyText(cellValue),
editRender: {}, editRender: {},
slots: { slots: {
edit: (props) => { edit: (props) => {
const { row, $grid } = props; const { row, $grid } = props;
return ( return (
<InputNumber <InputNumber
prefix="¥"
class="w-full" class="w-full"
min={0} min={0}
onChange={() => $grid?.updateStatus(props)} onChange={() => $grid?.updateStatus(props)}
......
import type { VxeGridProps } from 'vxe-table'; import type { VxeGridProps } from 'vxe-table';
/** /**
* 金额格式化:空值留白(合并列中父/子行在对方列天然为 null),有值保留 2 位小数。 * 数值格式化:空值/空串留白,有值保留 2 位小数。用于父行「消费积分」列(design D8/D5)
* 同时用于父行「项目总金额」与子行「金额(人民币)」列(design D8)
*/ */
function amountText(value?: null | number | string): string { function amountText(value?: null | number | string): string {
return value == null || value === '' ? '' : Number(value).toFixed(2); return value == null || value === '' ? '' : Number(value).toFixed(2);
} }
/**
* 人民币金额格式化:空值/空串留白;已带「¥」标识的值直通(幂等,兼容 footer 单元格复用列 formatter),
* 否则补「¥ + 2 位小数」。用于父行「项目总金额」/「每分钟金额」与子行「金额(人民币)」列
* (spec「消费记录列表金额列币种标识」)
*/
export function cnyText(value?: null | number | string): string {
if (value == null || value === '') {
return '';
}
const text = String(value);
return text.startsWith('¥') ? text : `¥${Number(value).toFixed(2)}`;
}
/** /**
* 成片时长格式化:>60 秒「X分X秒」、≤60 秒「X秒」、0「0秒」;空值(子行)留白(design D8) * 成片时长格式化:>60 秒「X分X秒」、≤60 秒「X秒」、0「0秒」;空值(子行)留白(design D8)
*/ */
...@@ -25,7 +37,7 @@ function durationText(value?: null | number): string { ...@@ -25,7 +37,7 @@ function durationText(value?: null | number): string {
/** /**
* 主网格列集(design D7):仅项目父级列 + 展开列。 * 主网格列集(design D7):仅项目父级列 + 展开列。
* - 展开列 type:'expand':点击在该行下方内嵌明细子表(列见 detailColumns,数据取 row.children) * - 展开列 type:'expand':点击在该行下方内嵌明细子表(列见 detailColumns,数据取 row.children)
* - 父级列:所属部门 / 项目名称 / 姓名 / 项目总金额(amountCny) / 成片时长 / 每分钟金额 / 消费时间 / 备注 / 操作 * - 父级列:所属部门 / 项目名称 / 姓名 / 项目总金额(amountCny) / 消费积分(consumePoints) / 成片时长 / 每分钟金额 / 消费时间 / 备注 / 操作
* - 明细字段(AI工具名称 / 国内外 / 消耗积分 / 金额)不再占主网格列,移入展开子表 * - 明细字段(AI工具名称 / 国内外 / 消耗积分 / 金额)不再占主网格列,移入展开子表
*/ */
export const columns: VxeGridProps['columns'] = [ export const columns: VxeGridProps['columns'] = [
...@@ -51,6 +63,13 @@ export const columns: VxeGridProps['columns'] = [ ...@@ -51,6 +63,13 @@ export const columns: VxeGridProps['columns'] = [
field: 'amountCny', field: 'amountCny',
minWidth: 100, minWidth: 100,
align: 'right', align: 'right',
formatter: ({ cellValue }) => cnyText(cellValue),
},
{
title: '消费积分',
field: 'consumePoints',
minWidth: 100,
align: 'right',
formatter: ({ cellValue }) => amountText(cellValue), formatter: ({ cellValue }) => amountText(cellValue),
}, },
{ {
...@@ -65,7 +84,7 @@ export const columns: VxeGridProps['columns'] = [ ...@@ -65,7 +84,7 @@ export const columns: VxeGridProps['columns'] = [
field: 'amountPerMinute', field: 'amountPerMinute',
minWidth: 90, minWidth: 90,
align: 'right', align: 'right',
formatter: ({ cellValue }) => amountText(cellValue), formatter: ({ cellValue }) => cnyText(cellValue),
}, },
{ {
title: '消费时间', title: '消费时间',
...@@ -90,7 +109,7 @@ export const columns: VxeGridProps['columns'] = [ ...@@ -90,7 +109,7 @@ export const columns: VxeGridProps['columns'] = [
/** /**
* 展开行明细子表列集(design D7 / spec「消费记录列表展开明细子表前端展示」): * 展开行明细子表列集(design D7 / spec「消费记录列表展开明细子表前端展示」):
* AI工具名称 / 国内外 / 消耗积分 / 金额(人民币)。数据源为父行内嵌 children(ConsumeDetail, * AI工具名称 / 国内外 / 消耗积分 / 金额(人民币)。数据源为父行内嵌 children(ConsumeDetail,
* 金额字段 amountCny);金额列复用 amountText 保留 2 位、空值留白。仅供主网格展开区嵌套只读子表使用。 * 金额字段 amountCny);金额列用 cnyText 补「¥」标识保留 2 位、空值留白。仅供主网格展开区嵌套只读子表使用。
*/ */
export const detailColumns: VxeGridProps['columns'] = [ export const detailColumns: VxeGridProps['columns'] = [
{ {
...@@ -116,6 +135,6 @@ export const detailColumns: VxeGridProps['columns'] = [ ...@@ -116,6 +135,6 @@ export const detailColumns: VxeGridProps['columns'] = [
field: 'amountCny', field: 'amountCny',
minWidth: 120, minWidth: 120,
align: 'center', align: 'center',
formatter: ({ cellValue }) => amountText(cellValue), formatter: ({ cellValue }) => cnyText(cellValue),
}, },
]; ];
...@@ -75,10 +75,15 @@ const gridOptions = withDefaultVxeGridOptions<Consume>({ ...@@ -75,10 +75,15 @@ const gridOptions = withDefaultVxeGridOptions<Consume>({
}), }),
consumeSummary(values), consumeSummary(values),
]); ]);
// 合计写入独立 footerData;amountCny 键对齐 data.ts「项目总金额」列 field—— // 合计写入独立 footerData;amountCny / consumePoints 键对齐 data.ts「项目总金额」/「消费积分」列 field——
// 合计口径为命中明细金额之和,落「项目总金额」列、其余列留空(design D5,req1);值来源仍是 summary 的 amountCny 单值 // 合计口径为命中明细金额 / 积分之和,落「项目总金额」与「消费积分」两列、其余列留空(design D3/D5);值来源为 summary 双字段。
// 金额值补「¥」前缀(spec「消费记录列表底部合计行」);积分非金额列不加标识
footerData.value = [ footerData.value = [
{ deptName: '合计', amountCny: Number(sum.amountCny).toFixed(2) }, {
deptName: '合计',
amountCny: `¥${Number(sum.amountCny).toFixed(2)}`,
consumePoints: Number(sum.consumePoints ?? 0).toFixed(2),
},
]; ];
// 主网格每行 = 一个项目父行,children 原样保留供展开子表直接渲染(design D7); // 主网格每行 = 一个项目父行,children 原样保留供展开子表直接渲染(design D7);
// 明细移入独立子表实例,父子不再同表格 → 无需子行字段改名 / 复合键 _rid / rowType 判别 // 明细移入独立子表实例,父子不再同表格 → 无需子行字段改名 / 复合键 _rid / rowType 判别
......
...@@ -3,6 +3,19 @@ import type { VxeGridProps } from 'vxe-table'; ...@@ -3,6 +3,19 @@ import type { VxeGridProps } from 'vxe-table';
import { DictEnum } from '@/constants'; import { DictEnum } from '@/constants';
import { renderDict } from '@/utils/render'; import { renderDict } from '@/utils/render';
/**
* 币种金额格式化:空值/空串留白;已带对应币种标识的值直通(幂等,兼容 footer 单元格复用列 formatter),
* 否则补「标识 + 原始数值」前缀(不强制补位小数,保持后端返回展示精度)。
* 用于「充值金额(美元)」「充值金额(人民币)」列(spec「充值记录列表金额列币种标识」)
*/
function currencyText(value: null | number | string | undefined, symbol: string): string {
if (value == null || value === '') {
return '';
}
const text = String(value);
return text.startsWith(symbol) ? text : `${symbol}${text}`;
}
export const columns: VxeGridProps['columns'] = [ export const columns: VxeGridProps['columns'] = [
{ type: 'checkbox', width: 60 }, { type: 'checkbox', width: 60 },
{ {
...@@ -41,10 +54,12 @@ export const columns: VxeGridProps['columns'] = [ ...@@ -41,10 +54,12 @@ export const columns: VxeGridProps['columns'] = [
{ {
title: '充值金额(美元)', title: '充值金额(美元)',
field: 'amountUsd', field: 'amountUsd',
formatter: ({ cellValue }) => currencyText(cellValue, '$'),
}, },
{ {
title: '充值金额(人民币)', title: '充值金额(人民币)',
field: 'amountCny', field: 'amountCny',
formatter: ({ cellValue }) => currencyText(cellValue, '¥'),
}, },
{ {
title: '开通账号数量', title: '开通账号数量',
......
...@@ -73,12 +73,13 @@ const gridOptions = withDefaultVxeGridOptions<Recharge>({ ...@@ -73,12 +73,13 @@ const gridOptions = withDefaultVxeGridOptions<Recharge>({
}), }),
rechargeSummary(values), rechargeSummary(values),
]); ]);
// 合计写入独立 footerData;两金额键按 data.ts 列 field 对齐;toFixed(2) 规避 JSON 数值退化(design D6) // 合计写入独立 footerData;两金额键按 data.ts 列 field 对齐;toFixed(2) 规避 JSON 数值退化(design D6)。
// 合计值补币种标识($ / ¥,spec「充值记录列表底部合计行」)
footerData.value = [ footerData.value = [
{ {
deptName: '合计', deptName: '合计',
amountUsd: Number(sum.amountUsd).toFixed(2), amountUsd: `$${Number(sum.amountUsd).toFixed(2)}`,
amountCny: Number(sum.amountCny).toFixed(2), amountCny: `¥${Number(sum.amountCny).toFixed(2)}`,
}, },
]; ];
return listRes; return listRes;
......
...@@ -224,6 +224,7 @@ async function handleClosed() { ...@@ -224,6 +224,7 @@ async function handleClosed() {
</FormItem> </FormItem>
<FormItem label="充值金额(美元)" name="amountUsd"> <FormItem label="充值金额(美元)" name="amountUsd">
<InputNumber <InputNumber
prefix="$"
class="w-full" class="w-full"
:style="{ width: '50%' }" :style="{ width: '50%' }"
:min="0" :min="0"
...@@ -233,6 +234,7 @@ async function handleClosed() { ...@@ -233,6 +234,7 @@ async function handleClosed() {
</FormItem> </FormItem>
<FormItem label="充值金额(人民币)" name="amountCny" :rules="formRules.amountCny"> <FormItem label="充值金额(人民币)" name="amountCny" :rules="formRules.amountCny">
<InputNumber <InputNumber
prefix="¥"
class="w-full" class="w-full"
:style="{ width: '50%' }" :style="{ width: '50%' }"
:min="0" :min="0"
......
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