gaoluyang
7 天以前 3688f07fd292812994dbf3c84bde18a440d8275c
feat(erp): 采购来票支持关联多张采购订单并按订单分摊金额

- 新增 erp_purchase_invoice_order 分摊表,来票可关联多张已审核采购订单
- 强制同一供应商:所选订单供应商不一致时报错,并列出各供应商及其订单号
- 创建/修改校验:分摊合计 = 来票金额、每张订单不超未开票金额、订单不可重复、订单必须已审核
- 来票主采购订单与供应商取分摊明细第一张
- 采购订单未开票金额、可来票登记筛选改为按分摊明细累计;来票列表支持按采购订单过滤
- 采购订单分页新增 invoiceId:编辑来票时剔除本张来票自身的分摊,把已分摊金额退回订单,剩余未开票金额保留在订单上
- 列表/详情返回 orderNos、orderCount、orderAllocations(含每张订单未开票金额),历史来票兜底按主订单展示
- 删除来票同时释放分摊额度;附迁移脚本 docs/sql/erp_purchase_invoice_order_20261104.sql

Co-Authored-By: Claude Code <noreply@anthropic.com>
已添加6个文件
已修改10个文件
659 ■■■■■ 文件已修改
docs/erp_purchase_invoice_multi_order_integration.md 107 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/sql/erp_purchase_invoice_order_20261104.sql 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/ErpPurchaseInvoiceController.java 65 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/ErpPurchaseOrderController.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/invoice/ErpPurchaseInvoiceOrderReqVO.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/invoice/ErpPurchaseInvoiceOrderRespVO.java 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/invoice/ErpPurchaseInvoiceRespVO.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/invoice/ErpPurchaseInvoiceSaveReqVO.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/order/ErpPurchaseOrderPageReqVO.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/dataobject/purchase/ErpPurchaseInvoiceOrderDO.java 54 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/purchase/ErpPurchaseInvoiceMapper.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/purchase/ErpPurchaseInvoiceOrderMapper.java 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/purchase/ErpPurchaseOrderMapper.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/enums/ErrorCodeConstants.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchaseInvoiceService.java 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchaseInvoiceServiceImpl.java 200 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/erp_purchase_invoice_multi_order_integration.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,107 @@
# é‡‡è´­æ¥ç¥¨å…³è”多张采购订单(分摊) - å‰ç«¯è”调方案
## èƒŒæ™¯
宁夏万通需求:采购来票中的「采购订单」由单选改为多选,识别录入同样支持。一张来票可以分摊到多张采购订单上,每张订单记录本次分摊金额,订单的「未开票金额」按分摊明细累计。
## æ¶‰åŠé¡µé¢
- é‡‡è´­ç®¡ç† - é‡‡è´­æ¥ç¥¨ï¼ˆæ–°å¢ž / ç¼–辑 / è¯¦æƒ…弹窗、列表)
- é‡‡è´­ç®¡ç† - é‡‡è´­æ¥ç¥¨è¯†åˆ«å½•入(识别结果进入同一个新增弹窗)
- é‡‡è´­ç®¡ç† - é‡‡è´­è®¢å•(列表「未开票金额」列、可来票登记筛选)
- é‡‡è´­ç®¡ç† - ä»˜æ¬¾å•(按采购订单选择来票时,走分摊明细)
## ä¸šåŠ¡æµç¨‹ä¸Žæ•°æ®å¸¦å…¥
1. æ–°å¢žæ¥ç¥¨ï¼šé€‰æ‹©é‡‡è´­è®¢å•(可多选,仅已审核通过、未开票金额大于 0、且与首张订单同一供应商的订单)-> ç³»ç»ŸæŒ‰æ‰€é€‰è®¢å•的未开票金额自动分摊来票金额,可手工调整。
2. æ¥ç¥¨é‡‘额变化 -> å·²é€‰è®¢å•按顺序重新自动分摊(每张先填满未开票金额,剩余顺延下一张)。
3. è¯†åˆ«å½•入(OCR)-> è¯†åˆ«å‡ºçš„金额带入来票金额,仍需人工选择采购订单并分摊。
4. ä¿å­˜åŽ -> é‡‡è´­è®¢å•的「未开票金额」= è®¢å•金额 âˆ’ è¯¥è®¢å•所有来票分摊合计;分摊完的订单不再出现在可选列表。
## API
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| POST | /erp/purchase-invoice/create | æ–°å¢žæ¥ç¥¨ï¼Œæ”¹ä¸ºä¼ åˆ†æ‘Šæ˜Žç»† orderAllocations |
| PUT | /erp/purchase-invoice/update | ä¿®æ”¹æ¥ç¥¨ï¼Œå¯æ›´æ¢è®¢å•、重设分摊 |
| GET | /erp/purchase-invoice/get | è¯¦æƒ…,返回分摊明细及每张订单的未开票金额 |
| GET | /erp/purchase-invoice/page | åˆ—表,支持按 purchaseOrderId è¿‡æ»¤ï¼Œè¿”回订单号汇总 |
| GET | /erp/purchase-order/page | é‡‡è´­è®¢å•列表,remainInvoicePrice æ”¹ä¸ºæŒ‰åˆ†æ‘Šæ˜Žç»†è®¡ç®—,remainPriceEnable åŒæ­¥ï¼›æ–°å¢ž invoiceId å‚æ•° |
**create / update è¯·æ±‚参数(变化部分):**
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|------|
| orderAllocations | Array | æ˜¯ | é‡‡è´­è®¢å•分摊明细,至少一行 |
| orderAllocations[].purchaseOrderId | Long | æ˜¯ | é‡‡è´­è®¢å•编号 |
| orderAllocations[].price | BigDecimal | æ˜¯ | æœ¬å¼ æ¥ç¥¨åˆ†æ‘Šåˆ°è¯¥è®¢å•的金额,不能小于 0 |
| purchaseOrderId | Long | å¦ | **不再由前端传**,后端取分摊明细第一行回填 |
| supplierId | Long | å¦ | **不再由前端传**,后端取第一张订单的供应商回填 |
| price | BigDecimal | æ˜¯ | æ¥ç¥¨é‡‘额,必须等于各行分摊金额之和 |
**page è¯·æ±‚参数新增:**
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|------|
| purchaseOrderId | Long | å¦ | æŒ‰é‡‡è´­è®¢å•过滤来票(命中分摊明细) |
| invoiceId | Long | å¦ | ç¼–辑来票时传本张来票编号:未开票金额与 remainPriceEnable è®¡ç®—时剔除该来票自身的分摊,相当于把它已分摊的金额退回订单(采购来票选订单弹窗使用) |
**create / update å“åº”:** `{ "code": 0, "data": 1 }`(来票编号)
**page / get å“åº”新增字段:**
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|------|------|------|
| orderNos | String | å…³è”采购订单号,多张以「、」拼接(列表展示) |
| orderCount | Integer | å…³è”采购订单数量 |
| orderAllocations | Array | åˆ†æ‘Šæ˜Žç»†ï¼Œå­—段见下表 |
**orderAllocations å­—段:**
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|------|------|------|
| purchaseOrderId | Long | é‡‡è´­è®¢å•编号 |
| orderNo | String | é‡‡è´­è®¢å•号 |
| supplierId / supplierName | Long / String | è®¢å•供应商 |
| orderTotalPrice | BigDecimal | è®¢å•金额(含税) |
| invoicedPrice | BigDecimal | è¯¥è®¢å•**其他**来票已占用金额(不含本张来票) |
| remainPrice | BigDecimal | è¯¥è®¢å•未开票金额(不含本张来票),即本次最多可分摊金额 |
| price | BigDecimal | æœ¬å¼ æ¥ç¥¨åˆ†æ‘Šåˆ°è¯¥è®¢å•的金额 |
**采购订单列表(/erp/purchase-order/page)字段口径变化:**
| å­—段 | è¯´æ˜Ž |
|------|------|
| remainInvoicePrice | è®¢å•金额 âˆ’ è¯¥è®¢å•所有来票分摊合计(原为订单金额 âˆ’ å•张来票金额) |
| remainPriceEnable=true | åªè¿”回未开票金额 > 0 çš„订单,口径同步改为按分摊明细计算 |
## å­—段展示规则
| å­—段 | å±•示位置 | è¯´æ˜Ž |
|------|----------|------|
| é‡‡è´­è®¢å• | æ¥ç¥¨æ–°å¢ž/编辑弹窗 | å¤šé€‰å¼¹çª—(已审核 + æœªå¼€ç¥¨é‡‘额大于 0),表格列:订单号、供应商、订单金额、未开票金额、本次分摊金额(可编辑)、移除 |
| åˆ†æ‘Šåˆè®¡ / å¾…分摊差额 | åˆ†æ‘Šè¡¨æ ¼ä¸‹æ–¹ | å®žæ—¶æ˜¾ç¤ºï¼Œå·®é¢ä¸ä¸º 0 æ—¶ä¿å­˜è¢«æ‹¦æˆª |
| ä¾›åº”商 | æ¥ç¥¨è¡¨å• | ç”±æ‰€é€‰è®¢å•供应商带出(同一供应商),只读 |
| é‡‡è´­è®¢å•编号 | æ¥ç¥¨åˆ—表 | å±•示 orderNos;历史数据回退到主订单号 |
| æœªå¼€ç¥¨é‡‘额 | é‡‡è´­è®¢å•列表 / é€‰æ‹©å¼¹çª— | æŒ‰åˆ†æ‘Šæ˜Žç»†å®žæ—¶è®¡ç®— |
## ä¸šåŠ¡è§„åˆ™è¯´æ˜Ž
| åœºæ™¯ | è§„则 |
|------|------|
| åˆ†æ‘Šåˆè®¡ | å¿…须等于来票金额,否则报错并列出来票金额、分摊合计和差额 |
| å•订单上限 | æ¯è¡Œåˆ†æ‘Šé‡‘额不得超过该订单「未开票金额」(订单金额 âˆ’ å…¶ä»–来票已占用) |
| è®¢å•重复 | åŒä¸€å¼ æ¥ç¥¨å†…同一订单只能出现一次 |
| è®¢å•状态 | åªæœ‰å·²å®¡æ ¸é€šè¿‡ï¼ˆstatus=20)的采购订单才能登记来票 |
| ä¾›åº”商 | **必须同一供应商**:所选采购订单的供应商不一致时保存被拦截,并列出各供应商及其订单号 |
| é¢åº¦å ç”¨ | æ¥ç¥¨åˆ›å»ºåŽå³å ç”¨è®¢å•未开票额度(无审核流程),删除后释放 |
| ç¼–辑 | å…è®¸æ›´æ¢è®¢å•、调整分摊;校验与弹窗未开票金额计算均排除本张来票自身的分摊,已分摊金额退回订单 |
| åˆ é™¤ | æœ‰æœ‰æ•ˆä»˜æ¬¾å•关联的来票不允许删除;删除同时释放分摊额度 |
## æ³¨æ„äº‹é¡¹
- æ•°æ®åº“需先执行 `docs/sql/erp_purchase_invoice_order_20261104.sql`(新建 `erp_purchase_invoice_order` åˆ†æ‘Šè¡¨ï¼Œå¹¶å›žå¡«åŽ†å²æ¥ç¥¨ï¼‰ã€‚
- åŽ†å²æ¥ç¥¨è‹¥æ²¡æœ‰åˆ†æ‘Šæ˜Žç»†ï¼Œåˆ—è¡¨/详情按主订单兜底展示;编辑保存后会按新规则重建分摊明细。
- ä»˜æ¬¾å•按采购订单选择来票时,改为按分摊明细匹配订单(一张来票可命中多张订单)。
- å‰ç«¯è‡ªåŠ¨åˆ†æ‘ŠæŒ‰ã€Œæ‰€é€‰è®¢å•çš„å‹¾é€‰é¡ºåºã€ä¾æ¬¡å¡«æ»¡ï¼Œè°ƒæ•´æŸè¡Œé‡‘é¢åŽå·®é¢ä¼šè‡ªåŠ¨é¡ºå»¶ç»™å…¶ä»–è®¢å•ï¼Œä»å—å„è‡ªä¸Šé™çº¦æŸã€‚
- å·²å¼€ç¥¨é‡‘额合计不会超过订单金额(订单未开票金额不会为负);来票金额小于订单金额时,差额保留在该订单的未开票金额中,下次开票仍可选择该订单。
docs/sql/erp_purchase_invoice_order_20261104.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,34 @@
-- =====================================================================
-- å®å¤ä¸‡é€šéœ€æ±‚:采购来票支持关联多张采购订单(分摊金额)
-- 2026-11-04
-- è¯´æ˜Žï¼š
--   1. æ–°å»ºæ¥ç¥¨-采购订单分摊表 erp_purchase_invoice_order;
--   2. åŽ†å²æ¥ç¥¨æŒ‰ã€Œpurchase_order_id + price」补一条分摊记录(等价于单订单);
--   3. erp_purchase_invoice.purchase_order_id ä¿ç•™ï¼Œå­˜åˆ†æ‘Šæ˜Žç»†çš„第一张订单(主订单)。
-- è„šæœ¬å¯é‡å¤æ‰§è¡Œã€‚
-- =====================================================================
-- 1. åˆ†æ‘Šè¡¨
CREATE TABLE IF NOT EXISTS `erp_purchase_invoice_order` (
    `id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号',
    `invoice_id` bigint NOT NULL COMMENT '来票编号,关联 erp_purchase_invoice.id',
    `purchase_order_id` bigint NOT NULL COMMENT '采购订单编号,关联 erp_purchase_order.id',
    `price` decimal(24, 6) NOT NULL DEFAULT 0 COMMENT '本次分摊到来票的金额,单位:元',
    `sort` int NOT NULL DEFAULT 0 COMMENT '排序',
    `creator` varchar(64) DEFAULT '' COMMENT '创建者',
    `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    `updater` varchar(64) DEFAULT '' COMMENT '更新者',
    `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
    PRIMARY KEY (`id`),
    KEY `idx_invoice_id` (`invoice_id`),
    KEY `idx_purchase_order_id` (`purchase_order_id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='ERP é‡‡è´­æ¥ç¥¨-采购订单分摊';
-- 2. åŽ†å²æ•°æ®å›žå¡«ï¼šæ¯å¼ è€æ¥ç¥¨è¡¥ä¸€æ¡åˆ†æ‘Šè®°å½•ï¼ˆå·²å›žå¡«çš„ä¸é‡å¤æ’å…¥ï¼‰
INSERT INTO `erp_purchase_invoice_order` (`invoice_id`, `purchase_order_id`, `price`, `sort`, `creator`, `updater`)
SELECT i.`id`, i.`purchase_order_id`, i.`price`, 0, i.`creator`, i.`updater`
FROM `erp_purchase_invoice` i
WHERE i.`deleted` = 0
  AND i.`purchase_order_id` IS NOT NULL
  AND NOT EXISTS (SELECT 1 FROM `erp_purchase_invoice_order` a WHERE a.`invoice_id` = i.`id`);
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/ErpPurchaseInvoiceController.java
@@ -1,6 +1,7 @@
package cn.iocoder.yudao.module.erp.controller.admin.purchase;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.iocoder.yudao.framework.apilog.core.annotation.ApiAccessLog;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
@@ -9,11 +10,13 @@
import cn.iocoder.yudao.framework.common.util.number.NumberUtils;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.framework.excel.core.util.ExcelUtils;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.invoice.ErpPurchaseInvoiceOrderRespVO;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.invoice.ErpPurchaseInvoicePageReqVO;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.invoice.ErpPurchaseInvoiceRespVO;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.invoice.ErpPurchaseInvoiceSaveReqVO;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.order.ErpPurchaseOrderRespVO;
import cn.iocoder.yudao.module.erp.dal.dataobject.purchase.ErpPurchaseInvoiceDO;
import cn.iocoder.yudao.module.erp.dal.dataobject.purchase.ErpPurchaseInvoiceOrderDO;
import cn.iocoder.yudao.module.erp.dal.dataobject.purchase.ErpPurchaseOrderDO;
import cn.iocoder.yudao.module.erp.service.finance.ErpFinancePaymentService;
import cn.iocoder.yudao.module.erp.service.purchase.ErpPurchaseInvoiceService;
@@ -38,14 +41,17 @@
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static cn.iocoder.yudao.framework.apilog.core.enums.OperateTypeEnum.EXPORT;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
import static cn.iocoder.yudao.framework.common.pojo.PageParam.PAGE_SIZE_NONE;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMultiMap;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
import static cn.iocoder.yudao.framework.common.util.collection.MapUtils.findAndThen;
@@ -138,10 +144,33 @@
        Map<Long, SrmSupplierRespDTO> supplierMap = srmSupplierApi.getSupplierList(
                convertSet(invoiceList, ErpPurchaseInvoiceDO::getSupplierId)).getCheckedData().stream()
                .collect(java.util.stream.Collectors.toMap(SrmSupplierRespDTO::getId, v -> v, (a, b) -> a));
        // 1.2 èŽ·å–é‡‡è´­è®¢å•åˆ—è¡¨
        // 1.2 èŽ·å–é‡‡è´­è®¢å•åˆ†æ‘Šæ˜Žç»†ï¼ˆä¸€å¼ æ¥ç¥¨å¯å…³è”å¤šå¼ è®¢å•ï¼‰
        Map<Long, List<ErpPurchaseInvoiceOrderDO>> allocationMap = convertMultiMap(
                invoiceService.getPurchaseInvoiceOrderListByInvoiceIds(
                        convertSet(invoiceList, ErpPurchaseInvoiceDO::getId)),
                ErpPurchaseInvoiceOrderDO::getInvoiceId);
        // 1.2.1 å…¼å®¹åŽ†å²æ•°æ®ï¼šæ²¡æœ‰åˆ†æ‘Šæ˜Žç»†çš„æ¥ç¥¨ï¼ŒæŒ‰ä¸»è®¢å• + æ¥ç¥¨é‡‘额视为一条分摊
        for (ErpPurchaseInvoiceDO invoice : invoiceList) {
            if (!allocationMap.containsKey(invoice.getId()) && invoice.getPurchaseOrderId() != null) {
                allocationMap.put(invoice.getId(), Collections.singletonList(
                        ErpPurchaseInvoiceOrderDO.builder().invoiceId(invoice.getId())
                                .purchaseOrderId(invoice.getPurchaseOrderId()).price(invoice.getPrice()).sort(0).build()));
            }
        }
        // 1.2.2 è¿™äº›è®¢å•的全部分摊明细,用于计算每张订单「其他来票已占用金额」
        Set<Long> orderIds = convertSet(allocationMap.values().stream().flatMap(List::stream).toList(),
                ErpPurchaseInvoiceOrderDO::getPurchaseOrderId);
        List<ErpPurchaseInvoiceOrderDO> orderAllocations = invoiceService.getPurchaseInvoiceOrderListByPurchaseOrderIds(orderIds);
        // 1.2.3 èŽ·å–é‡‡è´­è®¢å•åˆ—è¡¨ï¼ˆå«ä¸»è®¢å•ï¼Œå…¼å®¹åŽ†å²æ•°æ®ï¼‰
        Set<Long> orderIdsAll = new HashSet<>(orderIds);
        invoiceList.stream().map(ErpPurchaseInvoiceDO::getPurchaseOrderId)
                .filter(java.util.Objects::nonNull).forEach(orderIdsAll::add);
        Map<Long, ErpPurchaseOrderDO> orderMap = convertMap(
                purchaseOrderService.getPurchaseOrderList(convertList(invoiceList, ErpPurchaseInvoiceDO::getPurchaseOrderId)),
                ErpPurchaseOrderDO::getId);
                purchaseOrderService.getPurchaseOrderList(new ArrayList<>(orderIdsAll)), ErpPurchaseOrderDO::getId);
        // 1.2.4 èŽ·å–åˆ†æ‘Šè®¢å•çš„ä¾›åº”å•†åˆ—è¡¨ï¼ˆä¸€å¼ æ¥ç¥¨å¯èƒ½æ¶‰åŠå¤šä¸ªä¾›åº”å•†ï¼‰
        Map<Long, SrmSupplierRespDTO> orderSupplierMap = srmSupplierApi.getSupplierList(
                        convertSet(orderMap.values(), ErpPurchaseOrderDO::getSupplierId)).getCheckedData().stream()
                .collect(java.util.stream.Collectors.toMap(SrmSupplierRespDTO::getId, v -> v, (a, b) -> a));
        // 1.3 èŽ·å–åˆ›å»ºäººåˆ—è¡¨
        Map<Long, AdminUserRespDTO> userMap = adminUserApi.getUserMap(
                convertSet(invoiceList, invoice -> NumberUtils.parseLong(invoice.getCreator())));
@@ -152,9 +181,37 @@
        return BeanUtils.toBean(invoiceList, ErpPurchaseInvoiceRespVO.class, (invoiceVO) -> {
            // 2.1 æ‹¼æŽ¥ä¾›åº”商名称
            findAndThen(supplierMap, invoiceVO.getSupplierId(), supplier -> invoiceVO.setSupplierName(supplier.getName()));
            // 2.2 æ‹¼æŽ¥é‡‡è´­è®¢å•信息
            // 2.2 æ‹¼æŽ¥é‡‡è´­è®¢å•信息(主订单)
            findAndThen(orderMap, invoiceVO.getPurchaseOrderId(), order ->
                    invoiceVO.setOrder(BeanUtils.toBean(order, ErpPurchaseOrderRespVO.class)));
            // 2.2.1 æ‹¼æŽ¥é‡‡è´­è®¢å•分摊明细
            List<ErpPurchaseInvoiceOrderDO> rows = allocationMap.getOrDefault(invoiceVO.getId(), Collections.emptyList());
            List<ErpPurchaseInvoiceOrderRespVO> allocationVOs = new ArrayList<>(rows.size());
            for (ErpPurchaseInvoiceOrderDO row : rows) {
                ErpPurchaseInvoiceOrderRespVO allocationVO = new ErpPurchaseInvoiceOrderRespVO()
                        .setPurchaseOrderId(row.getPurchaseOrderId()).setPrice(row.getPrice());
                ErpPurchaseOrderDO order = orderMap.get(row.getPurchaseOrderId());
                if (order != null) {
                    allocationVO.setOrderNo(order.getNo()).setSupplierId(order.getSupplierId())
                            .setOrderTotalPrice(order.getTotalPrice());
                    findAndThen(orderSupplierMap, order.getSupplierId(),
                            supplier -> allocationVO.setSupplierName(supplier.getName()));
                }
                // è¯¥è®¢å•其他来票已占用金额 = å…¨éƒ¨åˆ†æ‘Š - æœ¬å¼ æ¥ç¥¨çš„分摊
                BigDecimal otherInvoicedPrice = orderAllocations.stream()
                        .filter(other -> ObjectUtil.equal(other.getPurchaseOrderId(), row.getPurchaseOrderId())
                                && !ObjectUtil.equal(other.getInvoiceId(), invoiceVO.getId()))
                        .map(ErpPurchaseInvoiceOrderDO::getPrice)
                        .reduce(BigDecimal.ZERO, BigDecimal::add);
                allocationVO.setInvoicedPrice(otherInvoicedPrice);
                if (order != null) {
                    allocationVO.setRemainPrice(order.getTotalPrice().subtract(otherInvoicedPrice));
                }
                allocationVOs.add(allocationVO);
            }
            invoiceVO.setOrderAllocations(allocationVOs);
            invoiceVO.setOrderCount(allocationVOs.size());
            invoiceVO.setOrderNos(CollUtil.join(allocationVOs, "、", ErpPurchaseInvoiceOrderRespVO::getOrderNo));
            // 2.3 æ‹¼æŽ¥åˆ›å»ºäººåç§°
            MapUtils.findAndThen(userMap, NumberUtils.parseLong(invoiceVO.getCreator()),
                    user -> invoiceVO.setCreatorName(user.getNickname()));
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/ErpPurchaseOrderController.java
@@ -157,7 +157,7 @@
    @PreAuthorize("@ss.hasPermission('erp:purchase-order:query')")
    public CommonResult<PageResult<ErpPurchaseOrderRespVO>> getPurchaseOrderPage(@Valid ErpPurchaseOrderPageReqVO pageReqVO) {
        PageResult<ErpPurchaseOrderDO> pageResult = purchaseOrderService.getPurchaseOrderPage(pageReqVO);
        return success(buildPurchaseOrderVOPageResult(pageResult));
        return success(buildPurchaseOrderVOPageResult(pageResult, pageReqVO.getInvoiceId()));
    }
    @GetMapping("/export-excel")
@@ -167,7 +167,7 @@
    public void exportPurchaseOrderExcel(@Valid ErpPurchaseOrderPageReqVO pageReqVO,
                                    HttpServletResponse response) throws IOException {
        pageReqVO.setPageSize(PageParam.PAGE_SIZE_NONE);
        List<ErpPurchaseOrderRespVO> list = buildPurchaseOrderVOPageResult(purchaseOrderService.getPurchaseOrderPage(pageReqVO)).getList();
        List<ErpPurchaseOrderRespVO> list = buildPurchaseOrderVOPageResult(purchaseOrderService.getPurchaseOrderPage(pageReqVO), null).getList();
        // å¯¼å‡º Excel
        ExcelUtils.write(response, "采购订单.xls", "数据", ErpPurchaseOrderRespVO.class, list);
    }
@@ -220,7 +220,8 @@
        return success(true);
    }
    private PageResult<ErpPurchaseOrderRespVO> buildPurchaseOrderVOPageResult(PageResult<ErpPurchaseOrderDO> pageResult) {
    private PageResult<ErpPurchaseOrderRespVO> buildPurchaseOrderVOPageResult(PageResult<ErpPurchaseOrderDO> pageResult,
                                                                                  Long excludeInvoiceId) {
        if (CollUtil.isEmpty(pageResult.getList())) {
            return PageResult.empty(pageResult.getTotal());
        }
@@ -243,7 +244,7 @@
                convertSet(purchaseOrderItemList, ErpPurchaseOrderItemDO::getProductUnitId));
        // 1.6 å·²æ¥ç¥¨é‡‘额:按采购订单统计,用于计算剩余可开票金额
        Map<Long, BigDecimal> invoicePriceSumMap = purchaseInvoiceService
                .getPurchaseInvoicePriceSumMapByOrderIds(convertSet(pageResult.getList(), ErpPurchaseOrderDO::getId));
                .getPurchaseInvoicePriceSumMapByOrderIds(convertSet(pageResult.getList(), ErpPurchaseOrderDO::getId), excludeInvoiceId);
        // 2. å¼€å§‹æ‹¼æŽ¥
        return BeanUtils.toBean(pageResult, ErpPurchaseOrderRespVO.class, purchaseOrder -> {
            purchaseOrder.setItems(BeanUtils.toBean(
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/invoice/ErpPurchaseInvoiceOrderReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,21 @@
package cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.invoice;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.math.BigDecimal;
@Schema(description = "管理后台 - ERP é‡‡è´­æ¥ç¥¨çš„采购订单分摊 Request VO")
@Data
public class ErpPurchaseInvoiceOrderReqVO {
    @Schema(description = "采购订单编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "17386")
    @NotNull(message = "采购订单编号不能为空")
    private Long purchaseOrderId;
    @Schema(description = "本次分摊到的来票金额,单位:元", requiredMode = Schema.RequiredMode.REQUIRED, example = "9000")
    @NotNull(message = "分摊金额不能为空")
    private BigDecimal price;
}
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/invoice/ErpPurchaseInvoiceOrderRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,29 @@
package cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.invoice;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
@Schema(description = "管理后台 - ERP é‡‡è´­æ¥ç¥¨çš„采购订单分摊 Response VO")
@Data
public class ErpPurchaseInvoiceOrderRespVO {
    @Schema(description = "采购订单编号", example = "17386")
    private Long purchaseOrderId;
    @Schema(description = "采购订单号", example = "CGDD20260101001")
    private String orderNo;
    @Schema(description = "供应商编号", example = "29399")
    private Long supplierId;
    @Schema(description = "供应商名称", example = "小番茄公司")
    private String supplierName;
    @Schema(description = "采购订单金额,单位:元", example = "20000.00")
    private BigDecimal orderTotalPrice;
    @Schema(description = "该订单其他来票已占用金额,单位:元", example = "8000.00")
    private BigDecimal invoicedPrice;
    @Schema(description = "该订单剩余未开票金额,单位:元", example = "12000.00")
    private BigDecimal remainPrice;
    @Schema(description = "本张来票本次分摊到该订单的金额,单位:元", example = "9000.00")
    private BigDecimal price;
}
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/invoice/ErpPurchaseInvoiceRespVO.java
@@ -30,12 +30,20 @@
    @ExcelProperty("供应商名称")
    private String supplierName;
    @Schema(description = "采购订单编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "17386")
    @Schema(description = "采购订单编号(主订单,即分摊明细第一张)", example = "17386")
    @ExcelProperty("采购订单编号")
    private Long purchaseOrderId;
    @Schema(description = "采购订单信息")
    private ErpPurchaseOrderRespVO order;
    @Schema(description = "关联采购订单号(多张时以 ã€ æ‹¼æŽ¥ï¼‰", example = "CGDD20260101001、CGDD20260101002")
    @ExcelProperty("采购订单号")
    private String orderNos;
    @Schema(description = "关联采购订单数量", example = "2")
    private Integer orderCount;
    @Schema(description = "采购订单分摊明细")
    private List<ErpPurchaseInvoiceOrderRespVO> orderAllocations;
    @Schema(description = "发票号码", example = "02564178")
    @ExcelProperty("发票号码")
    private String invoiceNo;
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/invoice/ErpPurchaseInvoiceSaveReqVO.java
@@ -1,6 +1,7 @@
package cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.invoice;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@@ -21,10 +22,14 @@
    @Schema(description = "供应商编号", example = "29399")
    private Long supplierId; // è¯¥å­—段不通过前端传递,而是 purchaseOrderId æŸ¥è¯¢å‡ºæ¥è®¾ç½®è¿›åŽ»
    @Schema(description = "采购订单编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "17386")
    @NotNull(message = "采购订单编号不能为空")
    @Schema(description = "采购订单编号(主订单,取分摊明细中的第一张,由后端回填)", example = "17386")
    private Long purchaseOrderId;
    @Schema(description = "采购订单分摊明细:一张来票可关联多张采购订单,分摊金额合计必须等于来票金额",
            requiredMode = Schema.RequiredMode.REQUIRED)
    @NotEmpty(message = "请至少选择一张采购订单")
    private List<ErpPurchaseInvoiceOrderReqVO> orderAllocations;
    @Schema(description = "发票号码", example = "02564178")
    private String invoiceNo;
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/order/ErpPurchaseOrderPageReqVO.java
@@ -86,4 +86,13 @@
    @Schema(description = "是否仅返回剩余可开票金额大于 0 çš„订单(采购来票登记使用)", example = "true")
    private Boolean remainPriceEnable;
    /**
     * éœ€æŽ’除的来票编号(编辑来票时传入)
     *
     * ç¼–辑来票重新选择采购订单时,把本张来票已分摊的金额退回订单,未开票金额按「订单金额 - å…¶ä»–来票已分摊」计算,
     * é¿å…æŠŠæœ¬å¼ æ¥ç¥¨è‡ªèº«çš„分摊当成已占用额度,导致重新分摊时额度被凭空扣减。
     */
    @Schema(description = "排除的来票编号(编辑来票时传,计算未开票金额时剔除该来票自身的分摊)", example = "1024")
    private Long invoiceId;
}
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/dataobject/purchase/ErpPurchaseInvoiceOrderDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,54 @@
package cn.iocoder.yudao.module.erp.dal.dataobject.purchase;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
import java.math.BigDecimal;
/**
 * ERP é‡‡è´­æ¥ç¥¨-采购订单分摊 DO
 *
 * ä¸€å¼ æ¥ç¥¨å¯ä»¥å…³è”多张采购订单,本表记录每张订单本次分摊到的来票金额。
 *
 * @author è¶…级管理员
 */
@TableName("erp_purchase_invoice_order")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ErpPurchaseInvoiceOrderDO extends BaseDO {
    /**
     * ç¼–号
     */
    @TableId
    private Long id;
    /**
     * æ¥ç¥¨ç¼–号
     *
     * å…³è” {@link ErpPurchaseInvoiceDO#getId()}
     */
    private Long invoiceId;
    /**
     * é‡‡è´­è®¢å•编号
     *
     * å…³è” {@link ErpPurchaseOrderDO#getId()}
     */
    private Long purchaseOrderId;
    /**
     * æœ¬æ¬¡åˆ†æ‘Šåˆ°æ¥ç¥¨çš„金额,单位:元
     *
     * åŒä¸€å¼ æ¥ç¥¨ä¸‹æ‰€æœ‰åˆ†æ‘Šé‡‘额之和 = æ¥ç¥¨é‡‘额
     */
    private BigDecimal price;
    /**
     * æŽ’序
     */
    private Integer sort;
}
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/purchase/ErpPurchaseInvoiceMapper.java
@@ -7,7 +7,6 @@
import cn.iocoder.yudao.module.erp.dal.dataobject.purchase.ErpPurchaseInvoiceDO;
import org.apache.ibatis.annotations.Mapper;
import java.util.Collection;
import java.util.List;
/**
@@ -26,23 +25,16 @@
        return selectPage(reqVO, new LambdaQueryWrapperX<ErpPurchaseInvoiceDO>()
                .likeIfPresent(ErpPurchaseInvoiceDO::getNo, reqVO.getNo())
                .eqIfPresent(ErpPurchaseInvoiceDO::getSupplierId, reqVO.getSupplierId())
                .eqIfPresent(ErpPurchaseInvoiceDO::getPurchaseOrderId, reqVO.getPurchaseOrderId())
                // æŒ‰é‡‡è´­è®¢å•过滤:一张来票可分摊到多张订单,需查分摊明细
                .apply(reqVO.getPurchaseOrderId() != null,
                        "erp_purchase_invoice.id IN (SELECT a.invoice_id FROM erp_purchase_invoice_order a "
                                + "WHERE a.purchase_order_id = {0} AND a.deleted = 0)", reqVO.getPurchaseOrderId())
                .orderByDesc(ErpPurchaseInvoiceDO::getId));
    }
    default List<ErpPurchaseInvoiceDO> selectListByOrderId(Long orderId) {
        return selectList(new LambdaQueryWrapperX<ErpPurchaseInvoiceDO>()
                .eq(ErpPurchaseInvoiceDO::getPurchaseOrderId, orderId));
    }
    default List<ErpPurchaseInvoiceDO> selectListBySupplierId(Long supplierId) {
        return selectList(new LambdaQueryWrapperX<ErpPurchaseInvoiceDO>()
                .eq(ErpPurchaseInvoiceDO::getSupplierId, supplierId));
    }
    default List<ErpPurchaseInvoiceDO> selectListByOrderIds(Collection<Long> orderIds) {
        return selectList(new LambdaQueryWrapperX<ErpPurchaseInvoiceDO>()
                .in(ErpPurchaseInvoiceDO::getPurchaseOrderId, orderIds));
    }
}
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/purchase/ErpPurchaseInvoiceOrderMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,46 @@
package cn.iocoder.yudao.module.erp.dal.mysql.purchase;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.erp.dal.dataobject.purchase.ErpPurchaseInvoiceOrderDO;
import org.apache.ibatis.annotations.Mapper;
import java.util.Collection;
import java.util.List;
/**
 * ERP é‡‡è´­æ¥ç¥¨-采购订单分摊 Mapper
 *
 * @author è¶…级管理员
 */
@Mapper
public interface ErpPurchaseInvoiceOrderMapper extends BaseMapperX<ErpPurchaseInvoiceOrderDO> {
    default List<ErpPurchaseInvoiceOrderDO> selectListByInvoiceId(Long invoiceId) {
        return selectList(new LambdaQueryWrapperX<ErpPurchaseInvoiceOrderDO>()
                .eq(ErpPurchaseInvoiceOrderDO::getInvoiceId, invoiceId)
                .orderByAsc(ErpPurchaseInvoiceOrderDO::getSort));
    }
    default List<ErpPurchaseInvoiceOrderDO> selectListByInvoiceIds(Collection<Long> invoiceIds) {
        return selectList(new LambdaQueryWrapperX<ErpPurchaseInvoiceOrderDO>()
                .in(ErpPurchaseInvoiceOrderDO::getInvoiceId, invoiceIds)
                .orderByAsc(ErpPurchaseInvoiceOrderDO::getSort));
    }
    default List<ErpPurchaseInvoiceOrderDO> selectListByPurchaseOrderId(Long purchaseOrderId) {
        return selectList(new LambdaQueryWrapperX<ErpPurchaseInvoiceOrderDO>()
                .eq(ErpPurchaseInvoiceOrderDO::getPurchaseOrderId, purchaseOrderId));
    }
    default List<ErpPurchaseInvoiceOrderDO> selectListByPurchaseOrderIds(Collection<Long> purchaseOrderIds) {
        return selectList(new LambdaQueryWrapperX<ErpPurchaseInvoiceOrderDO>()
                .in(ErpPurchaseInvoiceOrderDO::getPurchaseOrderId, purchaseOrderIds));
    }
    default int deleteByInvoiceId(Long invoiceId) {
        return delete(new LambdaQueryWrapperX<ErpPurchaseInvoiceOrderDO>()
                .eq(ErpPurchaseInvoiceOrderDO::getInvoiceId, invoiceId));
    }
}
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/purchase/ErpPurchaseOrderMapper.java
@@ -59,8 +59,15 @@
        }
        // å¯æ¥ç¥¨ç™»è®°ï¼ˆå‰©ä½™å¯å¼€ç¥¨é‡‘额 > 0):金额已开完的订单不展示
        if (Boolean.TRUE.equals(reqVO.getRemainPriceEnable())) {
            query.apply("t.total_price > (SELECT IFNULL(SUM(i.price), 0) FROM erp_purchase_invoice i "
                    + "WHERE i.purchase_order_id = t.id AND i.deleted = 0)");
            // ç¼–辑来票时(invoiceId éžç©ºï¼‰å‰”除本张来票自身的分摊,相当于把它已分摊的金额退回订单
            String sql = "t.total_price > (SELECT IFNULL(SUM(a.price), 0) FROM erp_purchase_invoice_order a "
                    + "INNER JOIN erp_purchase_invoice i ON i.id = a.invoice_id AND i.deleted = 0 "
                    + "WHERE a.purchase_order_id = t.id AND a.deleted = 0";
            if (reqVO.getInvoiceId() != null) {
                query.apply(sql + " AND a.invoice_id <> {0})", reqVO.getInvoiceId());
            } else {
                query.apply(sql + ")");
            }
        }
        if (reqVO.getProductId() != null) {
            query.leftJoin(ErpPurchaseOrderItemDO.class, ErpPurchaseOrderItemDO::getOrderId, ErpPurchaseOrderDO::getId)
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/enums/ErrorCodeConstants.java
@@ -62,6 +62,11 @@
    ErrorCode PURCHASE_INVOICE_CREATE_FAIL_ORDER_NOT_APPROVE = new ErrorCode(1_030_105_006, "创建来票失败,原因:采购订单不是审核通过状态");
    ErrorCode PURCHASE_INVOICE_CREATE_FAIL_SUPPLIER_MISMATCH = new ErrorCode(1_030_105_007, "创建来票失败,原因:来票供应商与采购订单不一致");
    ErrorCode PURCHASE_INVOICE_CREATE_FAIL_PRICE_EXCEEDS_LIMIT = new ErrorCode(1_030_105_008, "创建来票失败,原因:来票金额超出采购订单金额,目前可开票:{} å…ƒ");
    ErrorCode PURCHASE_INVOICE_ALLOCATION_EMPTY = new ErrorCode(1_030_105_009, "请至少选择一张采购订单,并把来票金额分摊到订单上");
    ErrorCode PURCHASE_INVOICE_ALLOCATION_ORDER_DUPLICATE = new ErrorCode(1_030_105_010, "同一张来票内的采购订单不能重复选择");
    ErrorCode PURCHASE_INVOICE_ALLOCATION_PRICE_INVALID = new ErrorCode(1_030_105_011, "分摊金额必须填写且不能小于 0");
    ErrorCode PURCHASE_INVOICE_ALLOCATION_SUM_MISMATCH = new ErrorCode(1_030_105_012, "各采购订单的分摊金额合计必须等于来票金额");
    ErrorCode PURCHASE_INVOICE_ALLOCATION_SUPPLIER_MISMATCH = new ErrorCode(1_030_105_013, "同一张来票只能关联同一供应商的采购订单");
    // ========== ERP é‡‡è´­é€€è´§ï¼ˆ1-030-103-000) ==========
    ErrorCode PURCHASE_RETURN_NOT_EXISTS = new ErrorCode(1_030_103_000, "采购退货单不存在");
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchaseInvoiceService.java
@@ -4,6 +4,7 @@
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.ErpPurchaseInvoiceOrderDO;
import jakarta.validation.Valid;
import java.math.BigDecimal;
@@ -98,6 +99,37 @@
     * @param orderIds é‡‡è´­è®¢å•编号列表
     * @return Map<采购订单编号, æ¥ç¥¨é‡‘额合计>
     */
    Map<Long, BigDecimal> getPurchaseInvoicePriceSumMapByOrderIds(Collection<Long> orderIds);
    /**
     * èŽ·å¾—é‡‡è´­è®¢å•çš„å·²æ¥ç¥¨é‡‘é¢åˆè®¡
     *
     * @param orderIds          é‡‡è´­è®¢å•编号集合
     * @param excludeInvoiceId  éœ€æŽ’除的来票编号(编辑来票时为该来票自身,可为空)
     * @return é‡‡è´­è®¢å•编号 -> å·²æ¥ç¥¨é‡‘额
     */
    Map<Long, BigDecimal> getPurchaseInvoicePriceSumMapByOrderIds(Collection<Long> orderIds, Long excludeInvoiceId);
    /**
     * èŽ·å¾—æ¥ç¥¨çš„é‡‡è´­è®¢å•åˆ†æ‘Šæ˜Žç»†
     *
     * @param invoiceId æ¥ç¥¨ç¼–号
     * @return åˆ†æ‘Šæ˜Žç»†åˆ—表
     */
    List<ErpPurchaseInvoiceOrderDO> getPurchaseInvoiceOrderListByInvoiceId(Long invoiceId);
    /**
     * èŽ·å¾—æ¥ç¥¨çš„é‡‡è´­è®¢å•åˆ†æ‘Šæ˜Žç»†ï¼ˆæ‰¹é‡ï¼‰
     *
     * @param invoiceIds æ¥ç¥¨ç¼–号列表
     * @return åˆ†æ‘Šæ˜Žç»†åˆ—表
     */
    List<ErpPurchaseInvoiceOrderDO> getPurchaseInvoiceOrderListByInvoiceIds(Collection<Long> invoiceIds);
    /**
     * èŽ·å¾—é‡‡è´­è®¢å•çš„åˆ†æ‘Šæ˜Žç»†ï¼ˆæ‰¹é‡ï¼Œç”¨äºŽç»Ÿè®¡è®¢å•å·²æ¥ç¥¨é‡‘é¢ï¼‰
     *
     * @param purchaseOrderIds é‡‡è´­è®¢å•编号列表
     * @return åˆ†æ‘Šæ˜Žç»†åˆ—表
     */
    List<ErpPurchaseInvoiceOrderDO> getPurchaseInvoiceOrderListByPurchaseOrderIds(Collection<Long> purchaseOrderIds);
}
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchaseInvoiceServiceImpl.java
@@ -4,15 +4,21 @@
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.ErpPurchaseInvoiceOrderReqVO;
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.ErpPurchaseInvoiceOrderDO;
import cn.iocoder.yudao.module.erp.dal.dataobject.purchase.ErpPurchaseOrderDO;
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.mysql.purchase.ErpPurchaseInvoiceOrderMapper;
import cn.iocoder.yudao.module.erp.dal.redis.no.ErpNoRedisDAO;
import cn.iocoder.yudao.module.srm.api.supplier.SrmSupplierApi;
import cn.iocoder.yudao.module.srm.api.supplier.dto.SrmSupplierRespDTO;
import cn.iocoder.yudao.module.system.api.storage.StorageAttachmentApi;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
@@ -20,11 +26,16 @@
import org.springframework.validation.annotation.Validated;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.getSumValue;
@@ -48,6 +59,9 @@
    private ErpPurchaseInvoiceMapper invoiceMapper;
    @Resource
    private ErpPurchaseInvoiceOrderMapper invoiceOrderMapper;
    @Resource
    private ErpFinancePaymentMapper financePaymentMapper;
    @Resource
@@ -57,15 +71,18 @@
    private ErpPurchaseOrderService purchaseOrderService;
    @Resource
    private SrmSupplierApi srmSupplierApi;
    @Resource
    private StorageAttachmentApi storageAttachmentApi;
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long createPurchaseInvoice(ErpPurchaseInvoiceSaveReqVO createReqVO) {
        // 1.1 æ ¡éªŒå…³è”数据存在
        validateRelationDataExists(createReqVO);
        // 1.2 æ ¡éªŒå¯å¼€ç¥¨é‡‘额超过上限
        validatePurchaseInvoicePriceExceedsLimit(createReqVO);
        // 1.1 æ ¡éªŒé‡‡è´­è®¢å•、分摊明细(分摊合计 = æ¥ç¥¨é‡‘额)
        List<ErpPurchaseOrderDO> orders = validateOrderAllocations(createReqVO);
        // 1.2 æ ¡éªŒæ¯å¼ è®¢å•的分摊金额不超过该订单剩余未开票金额
        validateAllocationPriceExceedsLimit(createReqVO, orders);
        // 1.3 ç¡®å®šæ¥ç¥¨ç¼–号(前端填写则用自定义单号,留空则自动生成),并校验唯一性
        String customNo = StrUtil.trim(createReqVO.getNo());
        String no = StrUtil.isNotBlank(customNo) ? customNo
@@ -74,40 +91,122 @@
            throw exception(PURCHASE_INVOICE_NO_EXISTS);
        }
        // 2.1 æ’入来票(创建后即为有效状态,无需审核)
        // 2.1 æ’入来票(创建后即为有效状态,无需审核);主订单、供应商取分摊明细第一张
        ErpPurchaseInvoiceDO invoice = BeanUtils.toBean(createReqVO, ErpPurchaseInvoiceDO.class).setNo(no);
        invoiceMapper.insert(invoice);
        // 2.2 ç»‘定发票附件
        // 2.2 æ’入采购订单分摊明细
        insertOrderAllocations(invoice.getId(), createReqVO);
        // 2.3 ç»‘定发票附件
        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 ä¸­
     *
     * @return åˆ†æ‘Šæ˜Žç»†ä¸­çš„采购订单列表(顺序与明细一致)
     */
    private List<ErpPurchaseOrderDO> validateOrderAllocations(ErpPurchaseInvoiceSaveReqVO reqVO) {
        List<ErpPurchaseInvoiceOrderReqVO> allocations = reqVO.getOrderAllocations();
        if (CollUtil.isEmpty(allocations)) {
            throw exception(PURCHASE_INVOICE_ALLOCATION_EMPTY);
        }
        reqVO.setSupplierId(purchaseOrder.getSupplierId()); // ä¾›åº”商以采购订单为准
        // 1. æ ¡éªŒè®¢å•不重复、必须审核通过
        Set<Long> orderIds = new HashSet<>();
        List<ErpPurchaseOrderDO> orders = new ArrayList<>();
        for (int i = 0; i < allocations.size(); i++) {
            ErpPurchaseInvoiceOrderReqVO allocation = allocations.get(i);
            if (!orderIds.add(allocation.getPurchaseOrderId())) {
                throw new ServiceException(PURCHASE_INVOICE_ALLOCATION_ORDER_DUPLICATE.getCode(),
                        StrUtil.format("第 {} è¡Œçš„采购订单重复选择,同一张来票内每张采购订单只能出现一次,请删除重复行后重试", i + 1));
            }
            if (allocation.getPrice() == null || allocation.getPrice().compareTo(BigDecimal.ZERO) < 0) {
                throw new ServiceException(PURCHASE_INVOICE_ALLOCATION_PRICE_INVALID.getCode(),
                        StrUtil.format("第 {} è¡Œçš„分摊金额不能为空且不能小于 0,请填写该订单本次分摊到的来票金额", i + 1));
            }
            orders.add(purchaseOrderService.validatePurchaseOrder(allocation.getPurchaseOrderId()));
        }
        // 1.2 æ ¡éªŒæ‰€æœ‰é‡‡è´­è®¢å•必须是同一供应商(来票对应一个供应商)
        validateSameSupplier(orders);
        // 2. æ ¡éªŒåˆ†æ‘Šåˆè®¡ = æ¥ç¥¨é‡‘额
        BigDecimal allocationTotal = allocations.stream().map(ErpPurchaseInvoiceOrderReqVO::getPrice)
                .reduce(BigDecimal.ZERO, BigDecimal::add);
        if (allocationTotal.compareTo(reqVO.getPrice()) != 0) {
            BigDecimal diff = reqVO.getPrice().subtract(allocationTotal);
            String detail = diff.compareTo(BigDecimal.ZERO) > 0
                    ? StrUtil.format("还有 {} å…ƒæœªåˆ†æ‘Šåˆ°è®¢å•,请为订单填写分摊金额或改选订单", diff.stripTrailingZeros().toPlainString())
                    : StrUtil.format("分摊合计比来票金额多 {} å…ƒï¼Œè¯·å‡å°‘分摊金额", diff.negate().stripTrailingZeros().toPlainString());
            throw new ServiceException(PURCHASE_INVOICE_ALLOCATION_SUM_MISMATCH.getCode(),
                    StrUtil.format("来票金额 {} å…ƒï¼Œå„订单分摊合计 {} å…ƒï¼Œä¸¤è€…必须相等(差额:{})。",
                            reqVO.getPrice().stripTrailingZeros().toPlainString(),
                            allocationTotal.stripTrailingZeros().toPlainString(), detail));
        }
        // 3. ä¸»è®¢å•、供应商取分摊明细第一张(一张来票涉及多个供应商时,列表/付款沿用第一家)
        reqVO.setPurchaseOrderId(allocations.get(0).getPurchaseOrderId());
        reqVO.setSupplierId(orders.get(0).getSupplierId());
        return orders;
    }
    private void validatePurchaseInvoicePriceExceedsLimit(ErpPurchaseInvoiceSaveReqVO reqVO) {
        // 1. è®¡ç®—剩余可开票金额,不包括 reqVO è‡ªèº«ï¼ˆæ‰€æœ‰æœ‰æ•ˆæ¥ç¥¨éƒ½å ç”¨é¢åº¦ï¼Œé˜²æ­¢ç´¯è®¡æ¥ç¥¨è¶…过采购订单金额)
        ErpPurchaseOrderDO purchaseOrder = purchaseOrderService.getPurchaseOrder(reqVO.getPurchaseOrderId());
        List<ErpPurchaseInvoiceDO> invoices = invoiceMapper.selectListByOrderId(reqVO.getPurchaseOrderId());
        if (reqVO.getId() != null) {
            invoices.removeIf(invoice -> ObjectUtil.equal(invoice.getId(), reqVO.getId()));
    /**
     * æ ¡éªŒåˆ†æ‘Šæ˜Žç»†ä¸­çš„采购订单是否属于同一供应商:来票只能对应一个供应商
     */
    private void validateSameSupplier(List<ErpPurchaseOrderDO> orders) {
        ErpPurchaseOrderDO firstOrder = orders.get(0);
        for (int i = 1; i < orders.size(); i++) {
            ErpPurchaseOrderDO order = orders.get(i);
            if (ObjectUtil.notEqual(order.getSupplierId(), firstOrder.getSupplierId())) {
                throw new ServiceException(PURCHASE_INVOICE_ALLOCATION_SUPPLIER_MISMATCH.getCode(),
                        StrUtil.format("第 {} è¡Œçš„采购订单[{}]供应商为[{}],与第 1 è¡Œé‡‡è´­è®¢å•[{}]的供应商[{}]不一致。"
                                        + "一张来票只能关联同一供应商的采购订单,请移除该订单,或改选该供应商的其他采购订单后重试。",
                                i + 1, order.getNo(), getSupplierName(order.getSupplierId()),
                                firstOrder.getNo(), getSupplierName(firstOrder.getSupplierId())));
            }
        }
        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);
    }
    private String getSupplierName(Long supplierId) {
        SrmSupplierRespDTO supplier = srmSupplierApi.getSupplier(supplierId).getCheckedData();
        return supplier == null ? "ID=" + supplierId : supplier.getName();
    }
    private void validateAllocationPriceExceedsLimit(ErpPurchaseInvoiceSaveReqVO reqVO, List<ErpPurchaseOrderDO> orders) {
        List<ErpPurchaseInvoiceOrderReqVO> allocations = reqVO.getOrderAllocations();
        for (int i = 0; i < allocations.size(); i++) {
            ErpPurchaseInvoiceOrderReqVO allocation = allocations.get(i);
            ErpPurchaseOrderDO order = orders.get(i);
            // 1. è¯¥è®¢å•其他来票已占用的金额(编辑时排除本张来票自身的分摊)
            List<ErpPurchaseInvoiceOrderDO> existed = invoiceOrderMapper.selectListByPurchaseOrderId(order.getId());
            if (reqVO.getId() != null) {
                existed.removeIf(row -> ObjectUtil.equal(row.getInvoiceId(), reqVO.getId()));
            }
            BigDecimal invoicedPrice = getSumValue(existed, ErpPurchaseInvoiceOrderDO::getPrice, BigDecimal::add, BigDecimal.ZERO);
            BigDecimal remainPrice = order.getTotalPrice().subtract(invoicedPrice);
            // 2. æ ¡éªŒé‡‘额是否超过
            if (allocation.getPrice().compareTo(remainPrice) > 0) {
                throw new ServiceException(PURCHASE_INVOICE_CREATE_FAIL_PRICE_EXCEEDS_LIMIT.getCode(),
                        StrUtil.format("采购订单[{}]的分摊金额超出该订单未开票金额:订单金额 {} å…ƒï¼Œå…¶ä»–来票已占用 {} å…ƒï¼Œ"
                                        + "剩余未开票 {} å…ƒï¼Œæœ¬æ¬¡åˆ†æ‘Š {} å…ƒã€‚请调整该订单的分摊金额,或改选其他采购订单后重试。",
                                order.getNo(), order.getTotalPrice().stripTrailingZeros().toPlainString(),
                                invoicedPrice.stripTrailingZeros().toPlainString(),
                                remainPrice.stripTrailingZeros().toPlainString(),
                                allocation.getPrice().stripTrailingZeros().toPlainString()));
            }
        }
    }
    private void insertOrderAllocations(Long invoiceId, ErpPurchaseInvoiceSaveReqVO reqVO) {
        List<ErpPurchaseInvoiceOrderDO> rows = new ArrayList<>();
        for (int i = 0; i < reqVO.getOrderAllocations().size(); i++) {
            ErpPurchaseInvoiceOrderReqVO allocation = reqVO.getOrderAllocations().get(i);
            rows.add(ErpPurchaseInvoiceOrderDO.builder().invoiceId(invoiceId)
                    .purchaseOrderId(allocation.getPurchaseOrderId())
                    .price(allocation.getPrice()).sort(i).build());
        }
        rows.forEach(invoiceOrderMapper::insert);
    }
    @Override
@@ -116,15 +215,19 @@
        Assert.notNull(updateReqVO.getId(), "来票编号不能为空");
        // 1.1 æ ¡éªŒå­˜åœ¨
        validatePurchaseInvoiceExists(updateReqVO.getId());
        // 1.2 æ ¡éªŒå…³è”数据 + å¯å¼€ç¥¨é‡‘额
        validateRelationDataExists(updateReqVO);
        validatePurchaseInvoicePriceExceedsLimit(updateReqVO);
        // 1.2 æ ¡éªŒé‡‡è´­è®¢å•、分摊明细、金额上限
        List<ErpPurchaseOrderDO> orders = validateOrderAllocations(updateReqVO);
        validateAllocationPriceExceedsLimit(updateReqVO, orders);
        // 2. æ›´æ–°æ¥ç¥¨
        ErpPurchaseInvoiceDO updateObj = BeanUtils.toBean(updateReqVO, ErpPurchaseInvoiceDO.class);
        invoiceMapper.updateById(updateObj);
        // 3. å¢žé‡æ›´æ–°å‘票附件
        // 3. é‡å»ºé‡‡è´­è®¢å•分摊明细
        invoiceOrderMapper.deleteByInvoiceId(updateReqVO.getId());
        insertOrderAllocations(updateReqVO.getId(), updateReqVO);
        // 4. å¢žé‡æ›´æ–°å‘票附件
        storageAttachmentApi.updateAttachments("file", PURCHASE_INVOICE_RECORD_TYPE, updateReqVO.getId(), updateReqVO.getBlobIds());
    }
@@ -143,6 +246,8 @@
        storageAttachmentApi.deleteAttachmentsByRecord(PURCHASE_INVOICE_RECORD_TYPE, id);
        // 2.2 åˆ é™¤æ¥ç¥¨
        invoiceMapper.deleteById(id);
        // 2.3 åˆ é™¤é‡‡è´­è®¢å•分摊明细(释放订单的已来票金额)
        invoiceOrderMapper.deleteByInvoiceId(id);
    }
    private ErpPurchaseInvoiceDO validatePurchaseInvoiceExists(Long id) {
@@ -173,7 +278,11 @@
    @Override
    public List<ErpPurchaseInvoiceDO> getPurchaseInvoiceListByOrderId(Long orderId) {
        return invoiceMapper.selectListByOrderId(orderId);
        List<ErpPurchaseInvoiceOrderDO> allocations = invoiceOrderMapper.selectListByPurchaseOrderId(orderId);
        if (CollUtil.isEmpty(allocations)) {
            return Collections.emptyList();
        }
        return invoiceMapper.selectByIds(convertSet(allocations, ErpPurchaseInvoiceOrderDO::getInvoiceId));
    }
    @Override
@@ -182,13 +291,38 @@
    }
    @Override
    public Map<Long, BigDecimal> getPurchaseInvoicePriceSumMapByOrderIds(Collection<Long> orderIds) {
    public Map<Long, BigDecimal> getPurchaseInvoicePriceSumMapByOrderIds(Collection<Long> orderIds, Long excludeInvoiceId) {
        if (CollUtil.isEmpty(orderIds)) {
            return Collections.emptyMap();
        }
        List<ErpPurchaseInvoiceDO> invoices = invoiceMapper.selectListByOrderIds(orderIds);
        return invoices.stream().collect(Collectors.groupingBy(ErpPurchaseInvoiceDO::getPurchaseOrderId,
                Collectors.mapping(ErpPurchaseInvoiceDO::getPrice, Collectors.reducing(BigDecimal.ZERO, BigDecimal::add))));
        List<ErpPurchaseInvoiceOrderDO> allocations = invoiceOrderMapper.selectListByPurchaseOrderIds(orderIds);
        if (excludeInvoiceId != null) {
            // ç¼–辑来票时把本张来票已分摊的金额退回订单
            allocations.removeIf(row -> ObjectUtil.equal(row.getInvoiceId(), excludeInvoiceId));
        }
        return allocations.stream().collect(Collectors.groupingBy(ErpPurchaseInvoiceOrderDO::getPurchaseOrderId,
                Collectors.mapping(ErpPurchaseInvoiceOrderDO::getPrice, Collectors.reducing(BigDecimal.ZERO, BigDecimal::add))));
    }
    @Override
    public List<ErpPurchaseInvoiceOrderDO> getPurchaseInvoiceOrderListByInvoiceId(Long invoiceId) {
        return invoiceOrderMapper.selectListByInvoiceId(invoiceId);
    }
    @Override
    public List<ErpPurchaseInvoiceOrderDO> getPurchaseInvoiceOrderListByInvoiceIds(Collection<Long> invoiceIds) {
        if (CollUtil.isEmpty(invoiceIds)) {
            return Collections.emptyList();
        }
        return invoiceOrderMapper.selectListByInvoiceIds(invoiceIds);
    }
    @Override
    public List<ErpPurchaseInvoiceOrderDO> getPurchaseInvoiceOrderListByPurchaseOrderIds(Collection<Long> purchaseOrderIds) {
        if (CollUtil.isEmpty(purchaseOrderIds)) {
            return Collections.emptyList();
        }
        return invoiceOrderMapper.selectListByPurchaseOrderIds(purchaseOrderIds);
    }
}