feat: 部门余额变更

This commit is contained in:
yys 2026-04-17 17:47:25 +08:00
parent 580e484dd0
commit c92f531f1b
13 changed files with 544 additions and 5 deletions

View File

@ -44,3 +44,11 @@ export function delDept(deptId) {
method: 'delete'
})
}
export function chargeRefundDept(data) {
return request({
url: '/ai/dept/charge-refund',
method: 'post',
data: data
})
}

View File

@ -50,3 +50,12 @@ export function delDept(deptId) {
method: 'delete'
})
}
// 部门充值/退款
export function chargeRefundDept(data) {
return request({
url: '/system/dept/charge-refund',
method: 'post',
data: data
})
}

View File

@ -0,0 +1,76 @@
/**
* 西式千分位en-US金额最多两位小数积分为整数
* 用于输入/粘贴后即时格式化展示
*/
export const WESTERN_MONEY_MAX = 10000000
export const WESTERN_INT_MAX = 100000000
const MONEY_MAX = WESTERN_MONEY_MAX
const INT_MAX = WESTERN_INT_MAX
/** 只保留数字与至多一个小数点,小数位最多 2 位 */
export function sanitizeMoneyDigits(raw) {
let s = String(raw == null ? '' : raw).replace(/,/g, '').replace(/[^\d.]/g, '')
const firstDot = s.indexOf('.')
if (firstDot !== -1) {
s = s.slice(0, firstDot + 1) + s.slice(firstDot + 1).replace(/\./g, '').slice(0, 2)
}
return s
}
/** 将规范化后的金额字符串格式化为 9,999,999.99 形式(输入过程中允许末尾为小数点) */
export function formatMoneyWesternDisplay(sanitized) {
if (!sanitized) return ''
const s = sanitized
const dot = s.indexOf('.')
const intRaw = dot === -1 ? s : s.slice(0, dot)
const decRaw = dot === -1 ? '' : s.slice(dot + 1).slice(0, 2)
const intNum = intRaw === '' ? 0 : parseInt(intRaw, 10)
const intFmt = (isNaN(intNum) ? 0 : intNum).toLocaleString('en-US')
if (dot === -1) return intFmt
if (s.endsWith('.') && decRaw === '') return intFmt + '.'
return intFmt + '.' + decRaw
}
/** 规范化字符串 -> 金额数值undefined 表示空) */
export function moneyStringToNumber(sanitized) {
if (sanitized === '' || sanitized === '.' || sanitized == null) return undefined
const n = parseFloat(sanitized)
if (isNaN(n)) return undefined
const rounded = Math.round(n * 100) / 100
if (rounded > MONEY_MAX) return MONEY_MAX
if (rounded < 0) return 0
return rounded
}
/** 失焦时金额固定两位小数展示 */
export function formatMoneyWesternFinal(n) {
if (n === undefined || n === null || isNaN(n)) return ''
const v = Math.min(MONEY_MAX, Math.max(0, Math.round(Number(n) * 100) / 100))
return v.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
/** 积分:仅数字,并限制范围 */
export function sanitizeIntDigits(raw) {
let d = String(raw == null ? '' : raw).replace(/,/g, '').replace(/\D/g, '')
if (d === '') return ''
let n = parseInt(d, 10)
if (isNaN(n)) return ''
n = Math.min(INT_MAX, Math.max(0, n))
return String(n)
}
export function formatIntWesternDisplay(digits) {
if (!digits) return ''
const n = parseInt(digits, 10)
if (isNaN(n)) return ''
return n.toLocaleString('en-US')
}
export function intStringToNumber(digits) {
if (!digits) return undefined
const n = parseInt(digits, 10)
if (isNaN(n)) return undefined
return n
}

View File

@ -57,19 +57,31 @@
:tree-props="{children: 'children', hasChildren: 'hasChildren'}"
>
<el-table-column prop="deptName" label="部门名称" width="260"></el-table-column>
<el-table-column prop="orderNum" label="排序" width="200"></el-table-column>
<el-table-column prop="orderNum" label="排序" width="80"></el-table-column>
<el-table-column prop="balance" label="剩余积分" width="200" align="right">
<template slot-scope="scope">
<span>{{ scope.row.balance != null ? scope.row.balance : '—' }}</span>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template slot-scope="scope">
<dict-tag :options="dict.type.sys_normal_disable" :value="scope.row.status"/>
</template>
</el-table-column>
<el-table-column label="创建时间" align="center" prop="createTime" width="200">
<el-table-column label="创建时间" align="center" prop="createTime" width="150">
<template slot-scope="scope">
<span>{{ parseTime(scope.row.createTime) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" align="center" class-name="small-padding fixed-width">
<template slot-scope="scope">
<el-button
size="mini"
type="text"
icon="el-icon-wallet"
@click="handleChargeRefund(scope.row)"
v-hasPermi="['system:dept:chargeRefund']"
>充值/退款</el-button>
<el-button
size="mini"
type="text"
@ -215,6 +227,54 @@
<el-button @click="cancel"> </el-button>
</div>
</el-dialog>
<el-dialog
:title="'充值/退款 — ' + (chargeRefundForm.deptName || '')"
:visible.sync="chargeRefundOpen"
width="520px"
append-to-body
@close="resetChargeRefund"
>
<el-form ref="chargeRefundFormRef" :model="chargeRefundForm" :rules="chargeRefundRules" label-width="88px">
<el-form-item label="类型" prop="orderType">
<el-radio-group v-model="chargeRefundForm.orderType">
<el-radio :label="0">充值</el-radio>
<el-radio :label="1">退款</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="金额" prop="money">
<el-input
:value="chargeRefundMoneyDisplay"
placeholder="财务记录(元),如 9,999,999.00"
clearable
@input="onChargeRefundMoneyInput"
@blur="onChargeRefundMoneyBlur"
/>
</el-form-item>
<el-form-item label="积分" prop="amount">
<el-input
:value="chargeRefundAmountDisplay"
placeholder="变动积分,如 99,999,999"
clearable
@input="onChargeRefundAmountInput"
/>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input
v-model="chargeRefundForm.remark"
type="textarea"
:rows="2"
maxlength="50"
show-word-limit
placeholder="选填最多50字"
/>
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button type="primary" @click="submitChargeRefund"> </el-button>
<el-button @click="chargeRefundOpen = false"> </el-button>
</div>
</el-dialog>
</div>
</template>
@ -244,7 +304,17 @@
</style>
<script>
import { listDept, getDept, delDept, addDept, updateDept, listDeptExcludeChild } from "@/api/system/dept"
import { listDept, getDept, delDept, addDept, updateDept, listDeptExcludeChild, chargeRefundDept } from "@/api/system/dept"
import {
WESTERN_MONEY_MAX,
sanitizeMoneyDigits,
formatMoneyWesternDisplay,
moneyStringToNumber,
formatMoneyWesternFinal,
sanitizeIntDigits,
formatIntWesternDisplay,
intStringToNumber
} from "@/utils/westernNumberFormat"
import Treeselect from "@riophae/vue-treeselect"
import "@riophae/vue-treeselect/dist/vue-treeselect.css"
@ -278,6 +348,33 @@ export default {
//
form: {},
modelParamRows: [{ label: '', value: '' }],
chargeRefundOpen: false,
chargeRefundMoneyDisplay: "",
chargeRefundAmountDisplay: "",
chargeRefundForm: {
deptId: undefined,
deptName: "",
orderType: 0,
money: undefined,
amount: undefined,
remark: ""
},
chargeRefundRules: {
orderType: [
{ required: true, message: "类型不能为空", trigger: "change" }
],
money: [
{ required: true, message: "金额不能为空", trigger: "blur" },
{ type: "number", min: 0, max: 10000000, message: "金额须在 010000000 之间", trigger: "blur" }
],
amount: [
{ required: true, message: "积分不能为空", trigger: "blur" },
{ type: "number", min: 1, max: 100000000, message: "积分须在 1100000000 之间", trigger: "blur" }
],
remark: [
{ max: 50, message: "备注不能超过50个字符", trigger: "blur" }
]
},
//
rules: {
parentId: [
@ -467,6 +564,87 @@ export default {
}
})
},
resetChargeRefund() {
this.chargeRefundMoneyDisplay = ""
this.chargeRefundAmountDisplay = ""
this.chargeRefundForm = {
deptId: undefined,
deptName: "",
orderType: 0,
money: undefined,
amount: undefined,
remark: ""
}
this.$nextTick(() => {
if (this.$refs.chargeRefundFormRef) {
this.$refs.chargeRefundFormRef.clearValidate()
}
})
},
handleChargeRefund(row) {
this.chargeRefundMoneyDisplay = ""
this.chargeRefundAmountDisplay = ""
this.chargeRefundForm = {
deptId: row.deptId,
deptName: row.deptName,
orderType: 0,
money: undefined,
amount: undefined,
remark: ""
}
this.chargeRefundOpen = true
this.$nextTick(() => {
if (this.$refs.chargeRefundFormRef) {
this.$refs.chargeRefundFormRef.clearValidate()
}
})
},
onChargeRefundMoneyInput(val) {
const sanitized = sanitizeMoneyDigits(val)
if (!sanitized) {
this.chargeRefundMoneyDisplay = ""
this.chargeRefundForm.money = undefined
return
}
const raw = parseFloat(sanitized)
if (!isNaN(raw) && raw > WESTERN_MONEY_MAX) {
this.chargeRefundForm.money = WESTERN_MONEY_MAX
this.chargeRefundMoneyDisplay = formatMoneyWesternFinal(WESTERN_MONEY_MAX)
return
}
this.chargeRefundMoneyDisplay = formatMoneyWesternDisplay(sanitized)
this.chargeRefundForm.money = moneyStringToNumber(sanitized)
},
onChargeRefundMoneyBlur() {
if (this.chargeRefundForm.money !== undefined && this.chargeRefundForm.money !== null) {
this.chargeRefundMoneyDisplay = formatMoneyWesternFinal(this.chargeRefundForm.money)
}
},
onChargeRefundAmountInput(val) {
const digits = sanitizeIntDigits(val)
this.chargeRefundAmountDisplay = digits === "" ? "" : formatIntWesternDisplay(digits)
this.chargeRefundForm.amount = intStringToNumber(digits)
},
submitChargeRefund() {
this.onChargeRefundMoneyBlur()
this.$refs["chargeRefundFormRef"].validate(valid => {
if (!valid) {
return
}
const data = {
deptId: this.chargeRefundForm.deptId,
orderType: this.chargeRefundForm.orderType,
money: this.chargeRefundForm.money,
amount: this.chargeRefundForm.amount,
remark: this.chargeRefundForm.remark ? this.chargeRefundForm.remark.trim() : undefined
}
chargeRefundDept(data).then(() => {
this.$modal.msgSuccess("操作成功")
this.chargeRefundOpen = false
this.getList()
})
})
},
/** 删除按钮操作 */
handleDelete(row) {
this.$modal.confirm('是否确认删除名称为"' + row.deptName + '"的数据项?').then(function() {

View File

@ -26,8 +26,8 @@ import com.ruoyi.system.service.ISysDeptService;
/**
* AI 业务侧部门管理数据源与系统部门 sys_dept 一致权限独立
*/
@RestController
@RequestMapping("/ai/dept")
//@RestController
//@RequestMapping("/ai/dept")
public class AiDeptController extends BaseController
{
@Autowired

View File

@ -13,11 +13,13 @@ import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.ruoyi.ai.service.IDeptChargeRefundService;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.constant.UserConstants;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.domain.entity.SysDept;
import com.ruoyi.common.core.request.system.DeptChargeRefundRequest;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.system.service.ISysDeptService;
@ -34,6 +36,9 @@ public class SysDeptController extends BaseController
@Autowired
private ISysDeptService deptService;
@Autowired
private IDeptChargeRefundService deptChargeRefundService;
/**
* 获取部门列表
*/
@ -129,4 +134,18 @@ public class SysDeptController extends BaseController
deptService.checkDeptDataScope(deptId);
return toAjax(deptService.deleteDeptById(deptId));
}
/**
* 部门手工充值/退款更新部门积分余额并记订单与流水
* 需具备 {@code system:dept:chargeRefund}且目标部门须通过数据权限校验
*/
@PreAuthorize("@ss.hasPermi('system:dept:chargeRefund')")
@Log(title = "部门管理", businessType = BusinessType.UPDATE)
@PostMapping("/charge-refund")
public AjaxResult chargeRefund(@Validated @RequestBody DeptChargeRefundRequest request)
{
deptService.checkDeptDataScope(request.getDeptId());
deptChargeRefundService.chargeOrRefund(request);
return success();
}
}

View File

@ -0,0 +1,47 @@
package com.ruoyi.common.core.request.system;
import java.math.BigDecimal;
import javax.validation.constraints.DecimalMax;
import javax.validation.constraints.DecimalMin;
import javax.validation.constraints.Digits;
import javax.validation.constraints.Max;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
import lombok.Data;
/**
* 部门手工充值/退款请求后台入账
* <p>
* 业务约定{@code amount}积分驱动 {@code sys_dept.balance} 增减{@code money} 仅写入订单表作财务记录不参与余额计算
* 校验规则需与前端充值弹窗一致
*/
@Data
public class DeptChargeRefundRequest {
/** 目标部门 */
@NotNull(message = "部门不能为空")
private Long deptId;
/** 0-充值 1-退款,对应 {@link com.ruoyi.common.enums.ChargeRefundOrderType} */
@NotNull(message = "订单类型不能为空")
@Min(value = 0, message = "订单类型不合法")
@Max(value = 1, message = "订单类型不合法")
private Integer orderType;
/** 财务记录金额(两位小数),不参与余额运算;必填,允许为 0 */
@NotNull(message = "金额不能为空")
@DecimalMin(value = "0", inclusive = true, message = "金额不能小于0")
@DecimalMax(value = "10000000", message = "金额不能超过10000000")
@Digits(integer = 8, fraction = 2, message = "金额最多两位小数")
private BigDecimal money;
/** 积分变动量(正整数),与 sys_dept.balance 增减一致 */
@NotNull(message = "积分不能为空")
@Min(value = 1, message = "积分不能小于1")
@Max(value = 100_000_000, message = "积分不能超过100000000")
private Long amount;
@Size(max = 50, message = "备注不能超过50个字符")
private String remark;
}

View File

@ -0,0 +1,16 @@
package com.ruoyi.ai.service;
import com.ruoyi.common.core.request.system.DeptChargeRefundRequest;
/**
* 部门手工充值/退款同一事务内写入订单更新部门积分写入集团余额流水
*/
public interface IDeptChargeRefundService {
/**
* 执行充值或退款任一步失败则整笔回滚含已插入的订单行
*
* @param request 已通过 Bean Validation 的入参
*/
void chargeOrRefund(DeptChargeRefundRequest request);
}

View File

@ -0,0 +1,122 @@
package com.ruoyi.ai.service.impl;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.ruoyi.ai.domain.AiChargeRefundOrder;
import com.ruoyi.ai.domain.AiGroupBalanceChangeRecord;
import com.ruoyi.ai.service.IAiChargeRefundOrderService;
import com.ruoyi.ai.service.IAiGroupBalanceChangeRecordService;
import com.ruoyi.ai.service.IDeptChargeRefundService;
import com.ruoyi.common.core.domain.entity.SysDept;
import com.ruoyi.common.core.request.system.DeptChargeRefundRequest;
import com.ruoyi.common.enums.ChargeRefundOrderStatusType;
import com.ruoyi.common.enums.ChargeRefundOrderType;
import com.ruoyi.common.enums.GroupBalanceChangeType;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.DateUtils;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.system.service.ISysDeptService;
/**
* 部门手工充值/退款实现
* <p>
* 顺序生成单号 {@code ai_charge_refund_order}同步成功态 原子更新 {@code sys_dept.balance}
* 查最新余额 {@code ai_group_balance_change_record}带符号变动量变动后余额
*/
@Service
public class DeptChargeRefundServiceImpl implements IDeptChargeRefundService {
@Autowired
private ISysDeptService deptService;
@Autowired
private IAiChargeRefundOrderService aiChargeRefundOrderService;
@Autowired
private IAiGroupBalanceChangeRecordService aiGroupBalanceChangeRecordService;
@Override
@Transactional(rollbackFor = Exception.class)
public void chargeOrRefund(DeptChargeRefundRequest request) {
ChargeRefundOrderType orderType = ChargeRefundOrderType.fromCode(request.getOrderType());
if (orderType == null) {
throw new ServiceException("订单类型不合法");
}
// 积分变动量 sys_dept.balance 增减一致money 仅落库订单不参与此处运算
BigDecimal amountBd = BigDecimal.valueOf(request.getAmount());
String orderNum = buildOrderNum();
Date now = DateUtils.getNowDate();
// 1) 手工入账订单状态直接为已完成
AiChargeRefundOrder order = new AiChargeRefundOrder();
order.setDelFlag("0");
order.setCreateBy(SecurityUtils.getUserId());
order.setCreateTime(now);
order.setUpdateTime(now);
order.setOrderNum(orderNum);
order.setDeptId(request.getDeptId());
order.setOrderType(request.getOrderType());
// 金额两位小数向零截断非四舍五入避免入账金额被抬高
order.setMoney(request.getMoney().setScale(2, RoundingMode.DOWN));
order.setAmount(amountBd);
order.setRemark(request.getRemark());
order.setStatus(ChargeRefundOrderStatusType.SUCCESS.getCode());
aiChargeRefundOrderService.insert(order);
// 2) 原子更新部门积分退款时 rows==0 表示余额不足或部门无效依赖事务回滚撤销上一 INSERT
int rows;
if (orderType == ChargeRefundOrderType.CHARGE) {
rows = deptService.addDeptBalance(request.getDeptId(), amountBd);
} else {
rows = deptService.subtractDeptBalance(request.getDeptId(), amountBd);
}
if (rows == 0) {
if (orderType == ChargeRefundOrderType.REFUND) {
throw new ServiceException("余额不足或部门不存在");
}
throw new ServiceException("部门不存在或已删除");
}
SysDept dept = deptService.selectDeptById(request.getDeptId());
if (dept == null) {
throw new ServiceException("部门不存在");
}
// 3) 流水充值为正退款为负result_amount 为变动后部门余额
BigDecimal signedChange = orderType == ChargeRefundOrderType.CHARGE
? amountBd
: amountBd.negate();
AiGroupBalanceChangeRecord record = new AiGroupBalanceChangeRecord();
record.setRelationOrderNo(orderNum);
record.setDeptId(request.getDeptId());
record.setType(orderType == ChargeRefundOrderType.CHARGE
? GroupBalanceChangeType.RECHARGE.getCode()
: GroupBalanceChangeType.REFUND.getCode());
record.setChangeAmount(signedChange);
record.setResultAmount(dept.getBalance());
record.setRemark(request.getRemark());
record.setCreateBy(SecurityUtils.getUserId());
record.setCreateTime(now);
record.setUpdateTime(now);
aiGroupBalanceChangeRecordService.insert(record);
}
/** 业务单号前缀 + 日序 + 随机后缀,保证与流水 relation_order_no 一致。 */
private static String buildOrderNum() {
String uuid = UUID.randomUUID().toString().replace("-", "").substring(0, 8);
String dateTime = new SimpleDateFormat("yyyyMMdd").format(new Date());
return "CG" + dateTime + uuid;
}
}

View File

@ -1,5 +1,6 @@
package com.ruoyi.system.mapper;
import java.math.BigDecimal;
import java.util.List;
import org.apache.ibatis.annotations.Param;
import com.ruoyi.common.core.domain.entity.SysDept;
@ -117,4 +118,21 @@ public interface SysDeptMapper
public int deleteDeptById(Long deptId);
public int getMaxOrder(Long deptId);
/**
* 原子增加部门积分余额充值{@code balance += amount} {@code del_flag = '0'} 的部门
* 用于并发下避免先读后写 {@link #subtractDeptBalance} 成对使用
*
* @param amount 积分变动量正数与订单 {@code ai_charge_refund_order.amount} 一致
* @return 影响行数0 表示部门不存在或已删除
*/
int addDeptBalance(@Param("deptId") Long deptId, @Param("amount") BigDecimal amount);
/**
* 原子扣减部门积分余额退款条件 {@code IFNULL(balance,0) >= amount} 时执行 {@code balance -= amount}防止透支
*
* @param amount 积分变动量正数
* @return 影响行数0 表示余额不足部门不存在或已删除
*/
int subtractDeptBalance(@Param("deptId") Long deptId, @Param("amount") BigDecimal amount);
}

View File

@ -1,5 +1,6 @@
package com.ruoyi.system.service;
import java.math.BigDecimal;
import java.util.List;
import com.ruoyi.common.core.domain.TreeSelect;
import com.ruoyi.common.core.domain.entity.SysDept;
@ -123,4 +124,18 @@ public interface ISysDeptService
public int deleteDeptById(Long deptId);
public int getMaxOrder(Long deptId);
/**
* 原子增加部门积分余额充值 {@link com.ruoyi.system.mapper.SysDeptMapper#addDeptBalance}
*
* @param amount 积分增量与手工入账订单中的积分字段一致
*/
int addDeptBalance(Long deptId, BigDecimal amount);
/**
* 原子扣减部门积分余额退款 {@link com.ruoyi.system.mapper.SysDeptMapper#subtractDeptBalance}
*
* @param amount 积分减量正数
*/
int subtractDeptBalance(Long deptId, BigDecimal amount);
}

View File

@ -1,5 +1,6 @@
package com.ruoyi.system.service.impl;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
@ -324,6 +325,20 @@ public class SysDeptServiceImpl implements ISysDeptService
return maxOrder != null ? maxOrder : 1;
}
/** 委托 Mapper 原子更新 {@code sys_dept.balance}(充值)。 */
@Override
public int addDeptBalance(Long deptId, BigDecimal amount)
{
return deptMapper.addDeptBalance(deptId, amount);
}
/** 委托 Mapper 原子更新 {@code sys_dept.balance}(退款,防透支)。 */
@Override
public int subtractDeptBalance(Long deptId, BigDecimal amount)
{
return deptMapper.subtractDeptBalance(deptId, amount);
}
/**
* 递归列表
*/

View File

@ -180,4 +180,20 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
update sys_dept set del_flag = '2' where dept_id = #{deptId}
</delete>
<!-- 部门积分充值:单行原子加余额 -->
<update id="addDeptBalance">
update sys_dept
set balance = IFNULL(balance, 0) + #{amount},
update_time = sysdate()
where dept_id = #{deptId} and del_flag = '0'
</update>
<!-- 部门积分退款:条件更新,余额不足则影响行数为 0调用方需回滚事务并提示 -->
<update id="subtractDeptBalance">
update sys_dept
set balance = IFNULL(balance, 0) - #{amount},
update_time = sysdate()
where dept_id = #{deptId} and del_flag = '0' and IFNULL(balance, 0) &gt;= #{amount}
</update>
</mapper>