package cn.iocoder.yudao.module.finance.service.voucher;
|
|
import cn.hutool.core.util.ObjectUtil;
|
import cn.hutool.core.util.StrUtil;
|
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
|
import cn.iocoder.yudao.module.finance.controller.admin.voucher.vo.FinVoucherEntrySaveReqVO;
|
import cn.iocoder.yudao.module.finance.controller.admin.voucher.vo.FinVoucherPageReqVO;
|
import cn.iocoder.yudao.module.finance.controller.admin.voucher.vo.FinVoucherSaveReqVO;
|
import cn.iocoder.yudao.module.finance.dal.dataobject.subject.FinAccountSubjectDO;
|
import cn.iocoder.yudao.module.finance.dal.dataobject.voucher.FinVoucherDO;
|
import cn.iocoder.yudao.module.finance.dal.dataobject.voucher.FinVoucherEntryDO;
|
import cn.iocoder.yudao.module.finance.dal.mysql.voucher.FinVoucherEntryMapper;
|
import cn.iocoder.yudao.module.finance.dal.mysql.voucher.FinVoucherMapper;
|
import cn.iocoder.yudao.module.finance.dal.redis.no.FinNoRedisDAO;
|
import cn.iocoder.yudao.module.finance.enums.FinVoucherStatusEnum;
|
import cn.iocoder.yudao.module.finance.service.subject.FinAccountSubjectService;
|
import cn.iocoder.yudao.module.system.api.storage.StorageAttachmentApi;
|
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
|
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
|
import jakarta.annotation.Resource;
|
import org.springframework.stereotype.Service;
|
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.validation.annotation.Validated;
|
|
import java.math.BigDecimal;
|
import java.math.RoundingMode;
|
import java.time.LocalDate;
|
import java.util.ArrayList;
|
import java.util.Collection;
|
import java.util.Collections;
|
import java.util.List;
|
import java.util.Map;
|
import java.util.Set;
|
|
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
|
import static cn.iocoder.yudao.module.finance.enums.ErrorCodeConstants.*;
|
|
/**
|
* 记账凭证 Service 实现类
|
*
|
* 借贷记账规则:有借必有贷、借贷必相等
|
*
|
* @author 超级管理员
|
*/
|
@Service
|
@Validated
|
public class FinVoucherServiceImpl implements FinVoucherService {
|
|
/**
|
* 附件:应用用途
|
*/
|
private static final String ATTACHMENT_APPLICATION = "file";
|
/**
|
* 附件:业务记录类型
|
*/
|
private static final String ATTACHMENT_RECORD_TYPE = "fin_voucher";
|
|
@Resource
|
private FinVoucherMapper voucherMapper;
|
@Resource
|
private FinVoucherEntryMapper voucherEntryMapper;
|
@Resource
|
private FinAccountSubjectService accountSubjectService;
|
@Resource
|
private FinNoRedisDAO noRedisDAO;
|
@Resource
|
private StorageAttachmentApi storageAttachmentApi;
|
@Resource
|
private AdminUserApi adminUserApi;
|
|
@Override
|
@Transactional(rollbackFor = Exception.class)
|
public Long createVoucher(FinVoucherSaveReqVO createReqVO) {
|
// 1.1 校验分录,并得到规范化后的分录明细
|
List<FinVoucherEntryDO> entries = buildVoucherEntries(createReqVO.getEntries(), createReqVO.getSummary());
|
// 1.2 凭证字号:留空时自动生成,并校验唯一
|
String voucherNo = StrUtil.isBlank(createReqVO.getVoucherNo())
|
? noRedisDAO.generate(FinNoRedisDAO.VOUCHER_NO_PREFIX) : StrUtil.trim(createReqVO.getVoucherNo());
|
validateVoucherNoUnique(null, voucherNo);
|
|
// 2.1 插入凭证
|
FinVoucherDO voucher = BeanUtils.toBean(createReqVO, FinVoucherDO.class);
|
voucher.setVoucherNo(voucherNo);
|
voucher.setStatus(FinVoucherStatusEnum.UNPOSTED.getStatus());
|
voucher.setDebit(sumAmount(entries, true));
|
voucher.setCredit(sumAmount(entries, false));
|
voucher.setAttachmentCount(ObjectUtil.defaultIfNull(createReqVO.getAttachmentCount(), 0));
|
voucherMapper.insert(voucher);
|
// 2.2 插入分录
|
insertVoucherEntryList(voucher.getId(), entries);
|
// 2.3 绑定附件
|
storageAttachmentApi.bindAttachments(ATTACHMENT_APPLICATION, ATTACHMENT_RECORD_TYPE,
|
voucher.getId(), createReqVO.getBlobIds());
|
return voucher.getId();
|
}
|
|
@Override
|
@Transactional(rollbackFor = Exception.class)
|
public void updateVoucher(FinVoucherSaveReqVO updateReqVO) {
|
// 1.1 校验存在
|
FinVoucherDO voucher = validateVoucherExists(updateReqVO.getId());
|
// 1.2 校验状态:只有未过账的凭证允许修改
|
validateVoucherUnposted(voucher);
|
// 1.3 校验分录,并得到规范化后的分录明细
|
List<FinVoucherEntryDO> entries = buildVoucherEntries(updateReqVO.getEntries(), updateReqVO.getSummary());
|
// 1.4 凭证字号:留空时沿用原字号,填写了则校验唯一
|
String voucherNo = StrUtil.isBlank(updateReqVO.getVoucherNo())
|
? voucher.getVoucherNo() : StrUtil.trim(updateReqVO.getVoucherNo());
|
validateVoucherNoUnique(voucher.getId(), voucherNo);
|
|
// 2.1 更新凭证(状态、制单人不由前端改动)
|
FinVoucherDO updateObj = BeanUtils.toBean(updateReqVO, FinVoucherDO.class);
|
updateObj.setId(voucher.getId());
|
updateObj.setVoucherNo(voucherNo);
|
updateObj.setDebit(sumAmount(entries, true));
|
updateObj.setCredit(sumAmount(entries, false));
|
updateObj.setAttachmentCount(ObjectUtil.defaultIfNull(updateReqVO.getAttachmentCount(), 0));
|
voucherMapper.updateById(updateObj);
|
// 2.2 整表重建分录
|
voucherEntryMapper.deleteByVoucherId(voucher.getId());
|
insertVoucherEntryList(voucher.getId(), entries);
|
// 2.3 更新附件
|
storageAttachmentApi.updateAttachments(ATTACHMENT_APPLICATION, ATTACHMENT_RECORD_TYPE,
|
voucher.getId(), updateReqVO.getBlobIds());
|
}
|
|
@Override
|
@Transactional(rollbackFor = Exception.class)
|
public void deleteVoucher(Long id) {
|
FinVoucherDO voucher = validateVoucherExists(id);
|
// 只有未过账、已作废的凭证允许删除
|
validateVoucherDeletable(voucher);
|
deleteVoucherList0(Collections.singletonList(id));
|
}
|
|
@Override
|
@Transactional(rollbackFor = Exception.class)
|
public void deleteVoucherList(List<Long> ids) {
|
if (ids == null || ids.isEmpty()) {
|
return;
|
}
|
// 校验状态:只要存在已过账的凭证,整批拒绝,避免部分成功
|
voucherMapper.selectListByIds(ids).forEach(this::validateVoucherDeletable);
|
deleteVoucherList0(ids);
|
}
|
|
private void deleteVoucherList0(List<Long> ids) {
|
voucherMapper.deleteByIds(ids);
|
voucherEntryMapper.deleteByVoucherIds(ids);
|
// 删除附件关联
|
ids.forEach(id -> storageAttachmentApi.deleteAttachmentsByRecord(ATTACHMENT_RECORD_TYPE, id));
|
}
|
|
@Override
|
@Transactional(rollbackFor = Exception.class)
|
public void postVoucher(Long id) {
|
FinVoucherDO voucher = validateVoucherExists(id);
|
validateVoucherUnposted(voucher);
|
voucherMapper.updateById(new FinVoucherDO().setId(id)
|
.setStatus(FinVoucherStatusEnum.POSTED.getStatus()));
|
}
|
|
@Override
|
@Transactional(rollbackFor = Exception.class)
|
public void cancelVoucher(Long id) {
|
FinVoucherDO voucher = validateVoucherExists(id);
|
validateVoucherCancellable(voucher);
|
voucherMapper.updateById(new FinVoucherDO().setId(id)
|
.setStatus(FinVoucherStatusEnum.CANCELLED.getStatus()));
|
}
|
|
@Override
|
public FinVoucherDO getVoucher(Long id) {
|
return voucherMapper.selectById(id);
|
}
|
|
@Override
|
public PageResult<FinVoucherDO> getVoucherPage(FinVoucherPageReqVO pageReqVO) {
|
// 「制单人」搜索框里填的是昵称(列表展示的就是昵称),先解析成用户编号再过滤
|
Set<String> creatorIds = resolveCreatorIds(pageReqVO.getCreator());
|
if (creatorIds != null && creatorIds.isEmpty()) {
|
// 搜索词没匹配到任何用户:直接返回空页。
|
// 不能把空集合当成「不过滤」传下去,否则用户搜一个不存在的昵称会看到全部凭证,像是筛选失效
|
return new PageResult<>(Collections.emptyList(), 0L);
|
}
|
return voucherMapper.selectPage(pageReqVO, creatorIds);
|
}
|
|
/**
|
* 把「制单人」搜索词解析成用户编号集合。
|
*
|
* 支持三种输入:昵称、账号(都是模糊匹配)、纯数字的用户编号(兼容这个搜索框以前按编号模糊匹配的老习惯)。
|
* 昵称是列表展示的字段,账号是搜索框上写的提示词,所以两者都要认。
|
*
|
* @param creatorKeyword 搜索词
|
* @return null 表示搜索词为空、不按制单人过滤;空集合表示搜索词没匹配到任何用户
|
*/
|
private Set<String> resolveCreatorIds(String creatorKeyword) {
|
if (StrUtil.isBlank(creatorKeyword)) {
|
return null;
|
}
|
String keyword = creatorKeyword.trim();
|
Set<Long> userIds = convertSet(adminUserApi.getUserListByNickname(keyword), AdminUserRespDTO::getId);
|
userIds.addAll(convertSet(adminUserApi.getUserListByUsername(keyword), AdminUserRespDTO::getId));
|
try {
|
userIds.add(Long.parseLong(keyword));
|
} catch (NumberFormatException ignored) {
|
// 不是纯数字,只按昵称、账号匹配
|
}
|
return convertSet(userIds, String::valueOf);
|
}
|
|
@Override
|
public List<FinVoucherDO> getVoucherList(Collection<Long> ids) {
|
if (ids == null || ids.isEmpty()) {
|
return Collections.emptyList();
|
}
|
return voucherMapper.selectListByIds(ids);
|
}
|
|
@Override
|
public List<FinVoucherDO> getVoucherListByDateBetween(LocalDate startDate, LocalDate endDate) {
|
return voucherMapper.selectListByDateBetween(startDate, endDate);
|
}
|
|
@Override
|
public List<FinVoucherEntryDO> getVoucherEntryListByVoucherId(Long voucherId) {
|
return voucherEntryMapper.selectListByVoucherId(voucherId);
|
}
|
|
@Override
|
public List<FinVoucherEntryDO> getVoucherEntryListByVoucherIds(Collection<Long> voucherIds) {
|
if (voucherIds == null || voucherIds.isEmpty()) {
|
return Collections.emptyList();
|
}
|
return voucherEntryMapper.selectListByVoucherIds(voucherIds);
|
}
|
|
private FinVoucherDO validateVoucherExists(Long id) {
|
if (id == null) {
|
throw exception(VOUCHER_NOT_EXISTS);
|
}
|
FinVoucherDO voucher = voucherMapper.selectById(id);
|
if (voucher == null) {
|
throw exception(VOUCHER_NOT_EXISTS);
|
}
|
return voucher;
|
}
|
|
private void validateVoucherNoUnique(Long id, String voucherNo) {
|
FinVoucherDO voucher = voucherMapper.selectByVoucherNo(voucherNo);
|
if (voucher == null) {
|
return;
|
}
|
if (id == null || !voucher.getId().equals(id)) {
|
throw exception(VOUCHER_NO_DUPLICATE, voucherNo);
|
}
|
}
|
|
private void validateVoucherUnposted(FinVoucherDO voucher) {
|
if (!FinVoucherStatusEnum.UNPOSTED.getStatus().equals(voucher.getStatus())) {
|
throw exception(VOUCHER_STATUS_NOT_UNPOSTED, voucher.getVoucherNo(),
|
FinVoucherStatusEnum.getName(voucher.getStatus()));
|
}
|
}
|
|
private void validateVoucherCancellable(FinVoucherDO voucher) {
|
if (FinVoucherStatusEnum.UNPOSTED.getStatus().equals(voucher.getStatus())
|
|| FinVoucherStatusEnum.POSTED.getStatus().equals(voucher.getStatus())) {
|
return;
|
}
|
throw exception(VOUCHER_STATUS_NOT_CANCELLABLE, voucher.getVoucherNo(),
|
FinVoucherStatusEnum.getName(voucher.getStatus()));
|
}
|
|
private void validateVoucherDeletable(FinVoucherDO voucher) {
|
if (FinVoucherStatusEnum.UNPOSTED.getStatus().equals(voucher.getStatus())
|
|| FinVoucherStatusEnum.CANCELLED.getStatus().equals(voucher.getStatus())) {
|
return;
|
}
|
throw exception(VOUCHER_DELETE_NOT_ALLOWED, voucher.getVoucherNo(),
|
FinVoucherStatusEnum.getName(voucher.getStatus()));
|
}
|
|
/**
|
* 校验凭证分录,并转换为待落库的分录列表
|
*
|
* 校验项:① 整行空白忽略 ② 有金额必须有科目 ③ 有科目必须有金额
|
* ④ 单行借贷不得同时大于 0 ⑤ 科目必须存在 ⑥ 借贷合计相等且大于 0
|
*
|
* @param entryReqVOs 前端提交的分录
|
* @param voucherSummary 凭证摘要,用于回填分录摘要
|
* @return 待落库的分录列表(行号从 1 连续编号)
|
*/
|
private List<FinVoucherEntryDO> buildVoucherEntries(List<FinVoucherEntrySaveReqVO> entryReqVOs,
|
String voucherSummary) {
|
// 1. 一次性查询所有涉及的会计科目,避免逐行查库
|
Set<String> subjectCodes = convertSet(entryReqVOs,
|
entry -> StrUtil.trim(entry.getSubjectCode()),
|
entry -> StrUtil.isNotBlank(entry.getSubjectCode()));
|
Map<String, FinAccountSubjectDO> subjectMap = accountSubjectService.getAccountSubjectMap(subjectCodes);
|
|
// 2. 逐行校验
|
List<FinVoucherEntryDO> entries = new ArrayList<>(entryReqVOs.size());
|
for (int i = 0; i < entryReqVOs.size(); i++) {
|
int rowNo = i + 1; // 提示中的行号与前端表格行号保持一致
|
FinVoucherEntrySaveReqVO entry = entryReqVOs.get(i);
|
BigDecimal debit = normalizeAmount(entry.getDebit());
|
BigDecimal credit = normalizeAmount(entry.getCredit());
|
boolean hasAmount = isPositive(debit) || isPositive(credit);
|
// 2.1 整行空白(无科目、无金额),直接忽略
|
if (StrUtil.isBlank(entry.getSubjectCode())) {
|
if (!hasAmount) {
|
continue;
|
}
|
throw exception(VOUCHER_ENTRY_SUBJECT_REQUIRED, rowNo, formatAmount(debit), formatAmount(credit));
|
}
|
String subjectCode = StrUtil.trim(entry.getSubjectCode());
|
// 2.2 有科目但没填金额
|
if (!hasAmount) {
|
throw exception(VOUCHER_ENTRY_AMOUNT_REQUIRED, rowNo, subjectCode);
|
}
|
// 2.3 单行借贷不得同时大于 0
|
if (isPositive(debit) && isPositive(credit)) {
|
throw exception(VOUCHER_ENTRY_DIRECTION_BOTH, rowNo, subjectCode,
|
formatAmount(debit), formatAmount(credit));
|
}
|
// 2.4 科目必须存在
|
FinAccountSubjectDO subject = subjectMap.get(subjectCode);
|
if (subject == null) {
|
throw exception(VOUCHER_ENTRY_SUBJECT_NOT_EXISTS, rowNo, subjectCode);
|
}
|
// 3. 组装
|
entries.add(FinVoucherEntryDO.builder()
|
.rowNo(entries.size() + 1)
|
.subjectCode(subjectCode)
|
.subjectName(subject.getSubjectName())
|
.summary(StrUtil.isBlank(entry.getSummary())
|
? StrUtil.blankToDefault(voucherSummary, subject.getSubjectName()) : entry.getSummary())
|
.debit(debit)
|
.credit(credit)
|
.auxiliaryType(entry.getAuxiliaryType())
|
.auxiliaryId(entry.getAuxiliaryId())
|
.auxiliaryName(entry.getAuxiliaryName())
|
.build());
|
}
|
|
// 4. 至少一条有效分录
|
if (entries.isEmpty()) {
|
throw exception(VOUCHER_ENTRY_EMPTY);
|
}
|
// 5. 借贷平衡,且合计大于 0
|
BigDecimal debitTotal = sumAmount(entries, true);
|
BigDecimal creditTotal = sumAmount(entries, false);
|
if (!isPositive(debitTotal) || debitTotal.compareTo(creditTotal) != 0) {
|
throw exception(VOUCHER_ENTRY_UNBALANCED, formatAmount(debitTotal), formatAmount(creditTotal));
|
}
|
return entries;
|
}
|
|
private void insertVoucherEntryList(Long voucherId, List<FinVoucherEntryDO> entries) {
|
entries.forEach(entry -> entry.setVoucherId(voucherId));
|
voucherEntryMapper.insertBatch(entries);
|
}
|
|
private static BigDecimal sumAmount(List<FinVoucherEntryDO> entries, boolean debit) {
|
return entries.stream()
|
.map(entry -> debit ? entry.getDebit() : entry.getCredit())
|
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
}
|
|
/**
|
* 金额规范化:空值按 0 处理,统一保留 2 位小数
|
*/
|
private static BigDecimal normalizeAmount(BigDecimal amount) {
|
return amount == null ? BigDecimal.ZERO : amount.setScale(2, RoundingMode.HALF_UP);
|
}
|
|
private static boolean isPositive(BigDecimal amount) {
|
return amount != null && amount.compareTo(BigDecimal.ZERO) > 0;
|
}
|
|
private static String formatAmount(BigDecimal amount) {
|
return amount == null ? "0.00" : amount.setScale(2, RoundingMode.HALF_UP).toPlainString();
|
}
|
|
}
|