package cn.iocoder.yudao.module.erp.service.purchase; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.lang.Assert; import cn.hutool.core.util.ObjectUtil; import cn.hutool.core.util.StrUtil; import cn.iocoder.yudao.framework.common.exception.ServiceException; import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.common.util.object.BeanUtils; import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.invoice.ErpPurchaseInvoiceItemReceiptRespVO; import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.invoice.ErpPurchaseInvoicePageReqVO; import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.invoice.ErpPurchaseInvoiceSaveReqVO; import cn.iocoder.yudao.module.erp.dal.dataobject.purchase.ErpPurchaseInvoiceDO; import cn.iocoder.yudao.module.erp.dal.dataobject.purchase.ErpPurchaseOrderDO; import cn.iocoder.yudao.module.erp.dal.dataobject.purchase.ErpPurchaseOrderItemDO; import cn.iocoder.yudao.module.mes.api.itemreceipt.MesWmItemReceiptApi; import cn.iocoder.yudao.module.mes.api.itemreceipt.dto.MesWmItemReceiptLineRespDTO; import cn.iocoder.yudao.module.mes.api.itemreceipt.dto.MesWmItemReceiptRespDTO; import cn.iocoder.yudao.module.erp.dal.mysql.finance.ErpFinancePaymentMapper; import cn.iocoder.yudao.module.erp.dal.mysql.purchase.ErpPurchaseInvoiceMapper; import cn.iocoder.yudao.module.erp.dal.redis.no.ErpNoRedisDAO; import cn.iocoder.yudao.module.system.api.storage.StorageAttachmentApi; 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.util.Collection; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.stream.Collectors; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.getSumValue; import static cn.iocoder.yudao.module.erp.enums.ErrorCodeConstants.*; /** * ERP 采购来票 Service 实现类 * * @author 超级管理员 */ @Service @Validated public class ErpPurchaseInvoiceServiceImpl implements ErpPurchaseInvoiceService { /** * 来票附件的业务记录类型,对应 {@link cn.iocoder.yudao.module.system.enums.storage.StorageRecordTypeEnum#ERP_PURCHASE_INVOICE} */ public static final String PURCHASE_INVOICE_RECORD_TYPE = "erp_purchase_invoice"; @Resource private ErpPurchaseInvoiceMapper invoiceMapper; @Resource private ErpFinancePaymentMapper financePaymentMapper; @Resource private ErpNoRedisDAO noRedisDAO; @Resource private ErpPurchaseOrderService purchaseOrderService; @Resource private MesWmItemReceiptApi mesWmItemReceiptApi; @Resource private StorageAttachmentApi storageAttachmentApi; /** * MES 入库单已完成状态,见 MesWmItemReceiptStatusEnum.FINISHED */ private static final Integer ITEM_RECEIPT_STATUS_FINISHED = 4; /** * MES 入库单已取消状态,见 MesWmItemReceiptStatusEnum.CANCELED */ private static final Integer ITEM_RECEIPT_STATUS_CANCELED = 5; @Override @Transactional(rollbackFor = Exception.class) public Long createPurchaseInvoice(ErpPurchaseInvoiceSaveReqVO createReqVO) { // 1.1 校验关联数据存在 validateRelationDataExists(createReqVO); // 1.2 校验可开票金额超过上限 validatePurchaseInvoicePriceExceedsLimit(createReqVO); // 1.3 生成来票编号 String no = noRedisDAO.generate(ErpNoRedisDAO.PURCHASE_INVOICE_NO_PREFIX); if (invoiceMapper.selectByNo(no) != null) { throw exception(PURCHASE_INVOICE_NO_EXISTS); } // 2.1 插入来票(创建后即为有效状态,无需审核) ErpPurchaseInvoiceDO invoice = BeanUtils.toBean(createReqVO, ErpPurchaseInvoiceDO.class).setNo(no); applyItemReceiptCode(invoice); invoiceMapper.insert(invoice); // 2.2 绑定发票附件 if (CollUtil.isNotEmpty(createReqVO.getBlobIds())) { storageAttachmentApi.bindAttachments("file", PURCHASE_INVOICE_RECORD_TYPE, invoice.getId(), createReqVO.getBlobIds()); } return invoice.getId(); } private void validateRelationDataExists(ErpPurchaseInvoiceSaveReqVO reqVO) { // 校验采购订单,必须审核通过 ErpPurchaseOrderDO purchaseOrder = purchaseOrderService.validatePurchaseOrder(reqVO.getPurchaseOrderId()); // 校验供应商与采购订单一致(防御脏数据:来票必须属于该采购订单的供应商) if (reqVO.getSupplierId() != null && ObjectUtil.notEqual(purchaseOrder.getSupplierId(), reqVO.getSupplierId())) { throw exception(PURCHASE_INVOICE_CREATE_FAIL_SUPPLIER_MISMATCH); } reqVO.setSupplierId(purchaseOrder.getSupplierId()); // 供应商以采购订单为准 // 校验入库单,并回填入库单号快照 validateItemReceipt(reqVO, purchaseOrder); } /** * 校验来票绑定的入库单:必须存在、已完成入库、属于当前采购订单和供应商 */ private void validateItemReceipt(ErpPurchaseInvoiceSaveReqVO reqVO, ErpPurchaseOrderDO purchaseOrder) { if (reqVO.getItemReceiptId() == null) { return; // 存量/未绑定时允许为空 } MesWmItemReceiptRespDTO receipt = mesWmItemReceiptApi.getItemReceipt(reqVO.getItemReceiptId()); if (receipt == null) { throw new ServiceException(PURCHASE_INVOICE_CREATE_FAIL_RECEIPT_NOT_EXISTS.getCode(), "来票关联的入库单不存在:入库单 ID=" + reqVO.getItemReceiptId() + "。请重新选择该采购订单下的入库单;若列表中没有可选项,请先在 MES 完成入库。"); } if (!ITEM_RECEIPT_STATUS_FINISHED.equals(receipt.getStatus())) { throw new ServiceException(PURCHASE_INVOICE_CREATE_FAIL_RECEIPT_NOT_FINISHED.getCode(), "来票关联的入库单[" + receipt.getCode() + "]当前状态为「" + itemReceiptStatusName(receipt.getStatus()) + "」,只有「已完成」的入库单才能来票。请先在 MES 完成入库后再选择该入库单。"); } if (ObjectUtil.notEqual(receipt.getPurchaseOrderId(), reqVO.getPurchaseOrderId())) { throw new ServiceException(PURCHASE_INVOICE_CREATE_FAIL_RECEIPT_ORDER_MISMATCH.getCode(), "所选入库单[" + receipt.getCode() + "]属于采购订单[" + StrUtil.blankToDefault(receipt.getPurchaseOrderCode(), "ID=" + receipt.getPurchaseOrderId()) + "],与当前选择的采购订单[" + purchaseOrder.getNo() + "]不一致。请重新选择该采购订单下的入库单,或先核对采购订单号。"); } if (ObjectUtil.notEqual(receipt.getVendorId(), purchaseOrder.getSupplierId())) { throw new ServiceException(PURCHASE_INVOICE_CREATE_FAIL_RECEIPT_SUPPLIER_MISMATCH.getCode(), "所选入库单[" + receipt.getCode() + "]的供应商(ID=" + receipt.getVendorId() + ")与采购订单[" + purchaseOrder.getNo() + "]的供应商(ID=" + purchaseOrder.getSupplierId() + ")不一致。请核对入库单来源,或改选该采购订单下的入库单。"); } } /** * 保存入库单号快照,列表与导出直接展示,避免联表 */ private void applyItemReceiptCode(ErpPurchaseInvoiceDO invoice) { if (invoice.getItemReceiptId() == null) { invoice.setItemReceiptCode(null); return; } MesWmItemReceiptRespDTO receipt = mesWmItemReceiptApi.getItemReceipt(invoice.getItemReceiptId()); invoice.setItemReceiptCode(receipt != null ? receipt.getCode() : null); } private String itemReceiptStatusName(Integer status) { if (status == null) { return "未知"; } return switch (status) { case 0 -> "草稿"; case 2 -> "待上架"; case 3 -> "待执行入库"; case 4 -> "已完成"; case 5 -> "已取消"; default -> "状态" + status; }; } private void validatePurchaseInvoicePriceExceedsLimit(ErpPurchaseInvoiceSaveReqVO reqVO) { // 1. 计算剩余可开票金额,不包括 reqVO 自身(所有有效来票都占用额度,防止累计来票超过采购订单金额) ErpPurchaseOrderDO purchaseOrder = purchaseOrderService.getPurchaseOrder(reqVO.getPurchaseOrderId()); List invoices = invoiceMapper.selectListByOrderId(reqVO.getPurchaseOrderId()); if (reqVO.getId() != null) { invoices.removeIf(invoice -> ObjectUtil.equal(invoice.getId(), reqVO.getId())); } BigDecimal remainPrice = purchaseOrder.getTotalPrice().subtract( getSumValue(invoices, ErpPurchaseInvoiceDO::getPrice, BigDecimal::add, BigDecimal.ZERO)); // 2. 校验金额是否超过采购订单可开票金额 if (reqVO.getPrice().compareTo(remainPrice) > 0) { throw exception(PURCHASE_INVOICE_CREATE_FAIL_PRICE_EXCEEDS_LIMIT, remainPrice); } // 3. 绑定了入库单时,还要校验不超过入库单可开票金额(入库金额 - 该入库单已来票金额) if (reqVO.getItemReceiptId() != null) { MesWmItemReceiptRespDTO receipt = mesWmItemReceiptApi.getItemReceipt(reqVO.getItemReceiptId()); BigDecimal receiptTotalPrice = calcItemReceiptTotalPrice(receipt); BigDecimal invoicedPrice = getItemReceiptInvoicedPrice(reqVO.getItemReceiptId(), reqVO.getId()); BigDecimal receiptRemainPrice = receiptTotalPrice.subtract(invoicedPrice); if (reqVO.getPrice().compareTo(receiptRemainPrice) > 0) { throw new ServiceException(PURCHASE_INVOICE_CREATE_FAIL_RECEIPT_PRICE_EXCEEDS_LIMIT.getCode(), "本次来票金额 " + reqVO.getPrice().stripTrailingZeros().toPlainString() + " 元,超过所选入库单[" + (receipt != null ? receipt.getCode() : "ID=" + reqVO.getItemReceiptId()) + "]的可开票金额 " + receiptRemainPrice.stripTrailingZeros().toPlainString() + " 元(入库金额 " + receiptTotalPrice.stripTrailingZeros().toPlainString() + " 元,已来票 " + invoicedPrice.stripTrailingZeros().toPlainString() + " 元)。请调整来票金额,或改选其他入库单;如需按整单开票,请先核对入库金额。"); } } } /** * 计算入库单金额:优先取入库行金额快照,其次按行单价 × 入库数量,最后按采购订单行单价兜底 */ private BigDecimal calcItemReceiptTotalPrice(MesWmItemReceiptRespDTO receipt) { if (receipt == null || CollUtil.isEmpty(receipt.getLines())) { return BigDecimal.ZERO; } Map orderItemPriceMap = purchaseOrderService .getPurchaseOrderItemListByOrderId(receipt.getPurchaseOrderId()).stream() .filter(item -> item.getProductPrice() != null) .collect(Collectors.toMap(ErpPurchaseOrderItemDO::getId, ErpPurchaseOrderItemDO::getProductPrice, (a, b) -> a)); BigDecimal total = BigDecimal.ZERO; for (MesWmItemReceiptLineRespDTO line : receipt.getLines()) { BigDecimal amount = line.getTotalPrice(); if (amount == null && line.getPrice() != null && line.getReceivedQuantity() != null) { amount = line.getPrice().multiply(line.getReceivedQuantity()); } if (amount == null && line.getPurchaseOrderItemId() != null && line.getReceivedQuantity() != null) { BigDecimal orderItemPrice = orderItemPriceMap.get(line.getPurchaseOrderItemId()); if (orderItemPrice != null) { amount = orderItemPrice.multiply(line.getReceivedQuantity()); } } if (amount != null) { total = total.add(amount); } } return total; } /** * 计算某入库单已来票金额(可按来票编号排除自身) */ private BigDecimal getItemReceiptInvoicedPrice(Long itemReceiptId, Long excludeInvoiceId) { List invoices = invoiceMapper.selectListByItemReceiptId(itemReceiptId); if (excludeInvoiceId != null) { invoices.removeIf(invoice -> Objects.equals(invoice.getId(), excludeInvoiceId)); } return getSumValue(invoices, ErpPurchaseInvoiceDO::getPrice, BigDecimal::add, BigDecimal.ZERO); } @Override @Transactional(rollbackFor = Exception.class) public void updatePurchaseInvoice(ErpPurchaseInvoiceSaveReqVO updateReqVO) { Assert.notNull(updateReqVO.getId(), "来票编号不能为空"); // 1.1 校验存在 validatePurchaseInvoiceExists(updateReqVO.getId()); // 1.2 校验关联数据 + 可开票金额 validateRelationDataExists(updateReqVO); validatePurchaseInvoicePriceExceedsLimit(updateReqVO); // 2. 更新来票 ErpPurchaseInvoiceDO updateObj = BeanUtils.toBean(updateReqVO, ErpPurchaseInvoiceDO.class); applyItemReceiptCode(updateObj); invoiceMapper.updateById(updateObj); // 3. 增量更新发票附件 storageAttachmentApi.updateAttachments("file", PURCHASE_INVOICE_RECORD_TYPE, updateReqVO.getId(), updateReqVO.getBlobIds()); } @Override @Transactional(rollbackFor = Exception.class) public void deletePurchaseInvoice(Long id) { // 1.1 校验存在 validatePurchaseInvoiceExists(id); // 1.2 已有有效付款单关联的来票,不允许删除(保持来票与付款的关联完整性) Map paidMap = financePaymentMapper.selectPaymentPriceMapByInvoiceId(List.of(id)); if (paidMap.getOrDefault(id, BigDecimal.ZERO).compareTo(BigDecimal.ZERO) > 0) { throw exception(PURCHASE_INVOICE_DELETE_FAIL_HAS_PAYMENT); } // 2.1 删除发票附件 storageAttachmentApi.deleteAttachmentsByRecord(PURCHASE_INVOICE_RECORD_TYPE, id); // 2.2 删除来票 invoiceMapper.deleteById(id); } private ErpPurchaseInvoiceDO validatePurchaseInvoiceExists(Long id) { ErpPurchaseInvoiceDO invoice = invoiceMapper.selectById(id); if (invoice == null) { throw exception(PURCHASE_INVOICE_NOT_EXISTS); } return invoice; } @Override public ErpPurchaseInvoiceDO getPurchaseInvoice(Long id) { return invoiceMapper.selectById(id); } @Override public List getPurchaseInvoiceList(Collection ids) { if (CollUtil.isEmpty(ids)) { return List.of(); } return invoiceMapper.selectByIds(ids); } @Override public PageResult getPurchaseInvoicePage(ErpPurchaseInvoicePageReqVO pageReqVO) { return invoiceMapper.selectPage(pageReqVO); } @Override public List getPurchaseInvoiceListByOrderId(Long orderId) { return invoiceMapper.selectListByOrderId(orderId); } @Override public List getPurchaseInvoiceListBySupplierId(Long supplierId) { return invoiceMapper.selectListBySupplierId(supplierId); } @Override public Map getPurchaseInvoicePriceSumMapByOrderIds(Collection orderIds) { if (CollUtil.isEmpty(orderIds)) { return Collections.emptyMap(); } List invoices = invoiceMapper.selectListByOrderIds(orderIds); return invoices.stream().collect(Collectors.groupingBy(ErpPurchaseInvoiceDO::getPurchaseOrderId, Collectors.mapping(ErpPurchaseInvoiceDO::getPrice, Collectors.reducing(BigDecimal.ZERO, BigDecimal::add)))); } @Override public List getItemReceiptListForInvoice(Long purchaseOrderId, Long excludeInvoiceId) { if (purchaseOrderId == null) { return List.of(); } List receipts = mesWmItemReceiptApi.getItemReceiptListByPurchaseOrderId(purchaseOrderId); return receipts.stream() .filter(receipt -> !ITEM_RECEIPT_STATUS_CANCELED.equals(receipt.getStatus())) .map(receipt -> { BigDecimal totalPrice = calcItemReceiptTotalPrice(receipt); BigDecimal invoicedPrice = getItemReceiptInvoicedPrice(receipt.getId(), excludeInvoiceId); return new ErpPurchaseInvoiceItemReceiptRespVO() .setId(receipt.getId()) .setCode(receipt.getCode()) .setName(receipt.getName()) .setReceiptDate(receipt.getReceiptDate()) .setStatus(receipt.getStatus()) .setTotalPrice(totalPrice) .setInvoicedPrice(invoicedPrice) .setRemainInvoicePrice(totalPrice.subtract(invoicedPrice)); }) .toList(); } }