gaoluyang
昨天 90b3ad820eea45d021835197eac5bc3ad55b12ed
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
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();
    }
 
}