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 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 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 ids) { if (ids == null || ids.isEmpty()) { return; } // 校验状态:只要存在已过账的凭证,整批拒绝,避免部分成功 voucherMapper.selectListByIds(ids).forEach(this::validateVoucherDeletable); deleteVoucherList0(ids); } private void deleteVoucherList0(List 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 getVoucherPage(FinVoucherPageReqVO pageReqVO) { // 「制单人」搜索框里填的是昵称(列表展示的就是昵称),先解析成用户编号再过滤 Set 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 resolveCreatorIds(String creatorKeyword) { if (StrUtil.isBlank(creatorKeyword)) { return null; } String keyword = creatorKeyword.trim(); Set 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 getVoucherList(Collection ids) { if (ids == null || ids.isEmpty()) { return Collections.emptyList(); } return voucherMapper.selectListByIds(ids); } @Override public List getVoucherListByDateBetween(LocalDate startDate, LocalDate endDate) { return voucherMapper.selectListByDateBetween(startDate, endDate); } @Override public List getVoucherEntryListByVoucherId(Long voucherId) { return voucherEntryMapper.selectListByVoucherId(voucherId); } @Override public List getVoucherEntryListByVoucherIds(Collection 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 buildVoucherEntries(List entryReqVOs, String voucherSummary) { // 1. 一次性查询所有涉及的会计科目,避免逐行查库 Set subjectCodes = convertSet(entryReqVOs, entry -> StrUtil.trim(entry.getSubjectCode()), entry -> StrUtil.isNotBlank(entry.getSubjectCode())); Map subjectMap = accountSubjectService.getAccountSubjectMap(subjectCodes); // 2. 逐行校验 List 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 entries) { entries.forEach(entry -> entry.setVoucherId(voucherId)); voucherEntryMapper.insertBatch(entries); } private static BigDecimal sumAmount(List 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(); } }