gongchunyi
2 天以前 6ef4265f1859e88e3e5ff22ef1848e12fa849e26
feat: 扫码出库修改为扫码发货
已添加2个文件
已修改5个文件
694 ■■■■■ 文件已修改
src/main/java/com/ruoyi/approve/service/impl/ApproveNodeServiceImpl.java 40 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/controller/SalesLedgerController.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/dto/SalesScanShipDto.java 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/service/ISalesLedgerService.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/service/impl/CommonFileServiceImpl.java 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/service/impl/SalesLedgerServiceImpl.java 303 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/test/java/com/ruoyi/sales/InvoiceLedgerReceiptIncomeRebuildBatchTest.java 264 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/approve/service/impl/ApproveNodeServiceImpl.java
@@ -259,26 +259,28 @@
        }
        // å‡ºåº“审批修改 (订单级别)
        if(approveProcess.getApproveType().equals(7)){
            String[] split = approveProcess.getApproveReason().split(":");
            if (split.length > 1) {
                String identifier = split[1];
                // æŸ¥æ‰¾é”€å”®å°è´¦
                SalesLedger salesLedger = salesLedgerMapper.selectOne(new LambdaQueryWrapper<SalesLedger>()
                        .eq(SalesLedger::getSalesContractNo, identifier)
                        .last("limit 1"));
            String scanRemark = approveProcess.getApproveRemark();
            if (org.springframework.util.StringUtils.hasText(scanRemark) && scanRemark.startsWith("SCAN_SHIP_DELIVERY_JSON:")) {
                salesLedgerService.onScanShipDeliveryApproveOutcome(approveProcess, status);
            } else {
                String[] split = approveProcess.getApproveReason().split(":");
                if (split.length > 1) {
                    String identifier = split[1];
                    SalesLedger salesLedger = salesLedgerMapper.selectOne(new LambdaQueryWrapper<SalesLedger>()
                            .eq(SalesLedger::getSalesContractNo, identifier)
                            .last("limit 1"));
                if (salesLedger != null) {
                    if(status.equals(2)){
                        // å®¡æ‰¹å®Œæˆ -> ä¿®æ”¹çŠ¶æ€ä¸ºå®¡æ ¸é€šè¿‡ï¼Œä¸æ‰£é™¤åº“å­˜ï¼ˆæ‰£é™¤åº“å­˜åœ¨å‘è´§å°è´¦è¡¥å……ä¿¡æ¯ï¼‰
                        updateSalesLedgerDeliveryStatus(salesLedger.getId(), 4);
                        updateShippingInfoStatusByOrder(salesLedger.getId(), "审核通过");
                    } else if(status.equals(3)){
                        updateSalesLedgerDeliveryStatus(salesLedger.getId(), 3);
                        // æ›´æ–°å…³è”的发货记录为审核拒绝
                        updateShippingInfoStatusByOrder(salesLedger.getId(), "审核拒绝");
                    } else if(status.equals(1)){
                        updateSalesLedgerDeliveryStatus(salesLedger.getId(), 2);
                        updateShippingInfoStatusByOrder(salesLedger.getId(), "审核中");
                    if (salesLedger != null) {
                        if(status.equals(2)){
                            updateSalesLedgerDeliveryStatus(salesLedger.getId(), 4);
                            updateShippingInfoStatusByOrder(salesLedger.getId(), "审核通过");
                        } else if(status.equals(3)){
                            updateSalesLedgerDeliveryStatus(salesLedger.getId(), 3);
                            updateShippingInfoStatusByOrder(salesLedger.getId(), "审核拒绝");
                        } else if(status.equals(1)){
                            updateSalesLedgerDeliveryStatus(salesLedger.getId(), 2);
                            updateShippingInfoStatusByOrder(salesLedger.getId(), "审核中");
                        }
                    }
                }
            }
src/main/java/com/ruoyi/sales/controller/SalesLedgerController.java
@@ -342,6 +342,13 @@
        return AjaxResult.success();
    }
    @PostMapping("/scanShipApply")
    @ApiOperation("销售订单扫码-发起发货审批(填写车牌/快递、审批人、附件;通过后自动扣库存并标记已发货)")
    public AjaxResult scanShipApply(@RequestBody SalesScanShipDto dto) {
        salesLedgerService.scanShipApply(dto);
        return AjaxResult.success("发货审批已发起");
    }
    @PostMapping("/scanOutboundUnqualified")
    @ApiOperation("销售订单扫码-不合格出库")
    public AjaxResult scanOutboundUnqualified(@RequestBody SalesScanInboundDto dto) {
src/main/java/com/ruoyi/sales/dto/SalesScanShipDto.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,37 @@
package com.ruoyi.sales.dto;
import com.ruoyi.sales.pojo.SalesLedgerProduct;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.util.List;
/**
 * APP æ‰«ç å‘货:填写发货信息、审批人、附件后发起发货审批;审批通过后自动扣库存并标记已发货。
 */
@Data
@ApiModel(value = "SalesScanShipDto", description = "销售扫码发货(发起审批)")
public class SalesScanShipDto {
    @ApiModelProperty("销售订单 Id")
    private Long salesLedgerId;
    @ApiModelProperty("本次发货数量")
    private List<SalesLedgerProduct> salesLedgerProductList;
    @ApiModelProperty(value = "审批人 userId,逗号分隔", required = true)
    private String approveUserIds;
    @ApiModelProperty("发货类型:货车 / å¿«é€’")
    private String shipType;
    @ApiModelProperty("车牌号")
    private String shippingCarNumber;
    @ApiModelProperty("快递单号")
    private String expressNumber;
    @ApiModelProperty("临时文件 id åˆ—表")
    private List<String> tempFileIds;
}
src/main/java/com/ruoyi/sales/service/ISalesLedgerService.java
@@ -5,6 +5,7 @@
import com.baomidou.mybatisplus.extension.service.IService;
import com.ruoyi.basic.pojo.Customer;
import com.ruoyi.common.enums.SaleEnum;
import com.ruoyi.approve.pojo.ApproveProcess;
import com.ruoyi.sales.dto.*;
import com.ruoyi.sales.pojo.SalesLedger;
import com.ruoyi.sales.pojo.SalesLedgerProcessRoute;
@@ -85,6 +86,18 @@
    void scanOutboundUnqualified(SalesScanInboundDto dto);
    /**
     * APP æ‰«ç å‘货:发起发货审批(审批通过后自动扣库存、发货台账与订单状态为已发货)
     */
    void scanShipApply(SalesScanShipDto dto);
    /**
     * å‘货审批(类型 7)节点状态变更:扫码发货流程 {@code approveRemark} ä»¥ {@code SCAN_SHIP_DELIVERY_JSON:} å¼€å¤´æ—¶å›žè°ƒã€‚
     *
     * @param outcomeStatus å®¡æ‰¹æµçŠ¶æ€ï¼š1 å®¡æ ¸ä¸­ 2 é€šè¿‡ 3 æ‹’绝
     */
    void onScanShipDeliveryApproveOutcome(ApproveProcess approveProcess, Integer outcomeStatus);
    void shippingImport(MultipartFile file);
    void notShippingImport(MultipartFile file);
src/main/java/com/ruoyi/sales/service/impl/CommonFileServiceImpl.java
@@ -2,6 +2,7 @@
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.CollectionUtils;
import com.ruoyi.common.enums.FileNameType;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.ruoyi.common.constant.Constants;
import com.ruoyi.common.utils.StringUtils;
@@ -30,6 +31,7 @@
import java.util.Arrays;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
@@ -173,6 +175,34 @@
        }
    }
    /**
     * å°†å®¡æ‰¹å•上的附件记录复制为多条发货台账附件(同一文件路径,每条发货台账各一条记录)。
     */
    public void copyApproveProcessShipAttachmentsToShippingInfos(Long approveProcessId, List<Long> shippingInfoIds) {
        if (approveProcessId == null || CollectionUtils.isEmpty(shippingInfoIds)) {
            return;
        }
        List<CommonFile> files = commonFileMapper.selectList(new LambdaQueryWrapper<CommonFile>()
                .eq(CommonFile::getCommonId, approveProcessId)
                .eq(CommonFile::getType, FileNameType.ApproveProcess.getValue()));
        if (CollectionUtils.isEmpty(files)) {
            return;
        }
        List<Long> distinctTargets = shippingInfoIds.stream().filter(java.util.Objects::nonNull).distinct().collect(Collectors.toList());
        for (Long sid : distinctTargets) {
            for (CommonFile f : files) {
                CommonFile copy = new CommonFile();
                copy.setCommonId(sid);
                copy.setName(f.getName());
                copy.setUrl(f.getUrl());
                copy.setLink(f.getLink());
                copy.setType(FileNameType.SHIP.getValue());
                copy.setCreateTime(LocalDateTime.now());
                commonFileMapper.insert(copy);
            }
        }
    }
    private String buildAccessLink(Path formalFilePath) {
        String normalizedPath = formalFilePath.toString().replace("\\", "/");
        String profile = RuoYiConfig.getProfile();
src/main/java/com/ruoyi/sales/service/impl/SalesLedgerServiceImpl.java
@@ -1,6 +1,7 @@
package com.ruoyi.sales.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.CollectionUtils;
@@ -11,6 +12,9 @@
import com.ruoyi.account.service.AccountIncomeService;
import com.ruoyi.approve.service.IApproveProcessService;
import com.ruoyi.approve.vo.ApproveProcessVO;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.ruoyi.approve.pojo.ApproveProcess;
import com.ruoyi.common.enums.ApproveTypeEnum;
import com.ruoyi.basic.mapper.CustomerMapper;
@@ -3012,4 +3016,303 @@
        }
        return productModel.getUnit();
    }
    private static final String SCAN_SHIP_REMARK_PREFIX = "SCAN_SHIP_DELIVERY_JSON:";
    private static final class ScanShipPayload {
        private String shippingNo;
        private Long ledgerId;
        private String car;
        private String express;
        private String shipType;
        private Map<Long, BigDecimal> linesQty = new LinkedHashMap<>();
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void scanShipApply(SalesScanShipDto dto) {
        if (dto == null || dto.getSalesLedgerId() == null) {
            throw new ServiceException("扫码发货失败,订单不能为空");
        }
        if (StringUtils.isEmpty(dto.getApproveUserIds())) {
            throw new ServiceException("请选择审批人");
        }
        if (CollectionUtils.isEmpty(dto.getSalesLedgerProductList())) {
            throw new ServiceException("请填写发货产品行");
        }
        String shipType = StringUtils.hasText(dto.getShipType()) ? dto.getShipType().trim() : "货车";
        if ("货车".equals(shipType)) {
            if (!StringUtils.hasText(dto.getShippingCarNumber())) {
                throw new ServiceException("请填写车牌号");
            }
        } else if ("快递".equals(shipType)) {
            if (!StringUtils.hasText(dto.getExpressNumber())) {
                throw new ServiceException("请填写快递单号");
            }
        }
        SalesLedger salesLedger = baseMapper.selectById(dto.getSalesLedgerId());
        if (salesLedger == null) {
            throw new ServiceException("销售订单不存在");
        }
        if (salesLedger.getDeliveryStatus() != null && salesLedger.getDeliveryStatus() == 5) {
            throw new ServiceException("该销售订单已发货");
        }
        if (salesLedger.getDeliveryStatus() != null && salesLedger.getDeliveryStatus() == 2) {
            throw new ServiceException("该销售订单已发起发货审批,请先完成审批");
        }
        List<SalesLedgerProduct> notStocked = salesLedgerProductMapper.selectList(new LambdaQueryWrapper<SalesLedgerProduct>()
                .eq(SalesLedgerProduct::getSalesLedgerId, salesLedger.getId())
                .eq(SalesLedgerProduct::getType, 1)
                .ne(SalesLedgerProduct::getProductStockStatus, 2));
        if (org.apache.commons.collections4.CollectionUtils.isNotEmpty(notStocked)) {
            throw new ServiceException("发货失败,该销售订单存在未入库产品,请先完成全部入库后再发货");
        }
        int saleType = SaleEnum.SALE.getCode();
        Map<Long, BigDecimal> shipQtyByLineId = new LinkedHashMap<>();
        for (SalesLedgerProduct line : dto.getSalesLedgerProductList()) {
            if (line == null || line.getId() == null) {
                throw new ServiceException("产品信息不完整");
            }
            BigDecimal q = line.getStockedQuantity();
            if (q == null) {
                throw new ServiceException("发货数量不能为空");
            }
            if (q.compareTo(BigDecimal.ZERO) < 0) {
                throw new ServiceException("发货数量不能为负数");
            }
            shipQtyByLineId.merge(line.getId(), q, BigDecimal::add);
        }
        boolean anyPositive = shipQtyByLineId.values().stream().anyMatch(v -> v.compareTo(BigDecimal.ZERO) > 0);
        if (!anyPositive) {
            throw new ServiceException("请至少填写一行大于 0 çš„发货数量");
        }
        Long ledgerId = salesLedger.getId();
        for (Map.Entry<Long, BigDecimal> entry : shipQtyByLineId.entrySet()) {
            if (entry.getValue().compareTo(BigDecimal.ZERO) == 0) {
                continue;
            }
            SalesLedgerProduct dbProduct = salesLedgerProductMapper.selectById(entry.getKey());
            if (dbProduct == null) {
                throw new ServiceException("销售产品不存在");
            }
            if (!Objects.equals(dbProduct.getSalesLedgerId(), ledgerId) || !Objects.equals(dbProduct.getType(), saleType)) {
                throw new ServiceException("销售产品与订单不匹配");
            }
            if (dbProduct.getProductModelId() == null) {
                throw new ServiceException("产品规格未维护,无法发货");
            }
            BigDecimal orderQty = defaultDecimal(dbProduct.getQuantity());
            BigDecimal prevShipped = defaultDecimal(dbProduct.getShippedQuantity());
            if (prevShipped.add(entry.getValue()).compareTo(orderQty) > 0) {
                throw new ServiceException("累计发货数量不能大于该产品订单数量");
            }
            stockUtils.assertQualifiedAvailable(dbProduct.getProductModelId(), entry.getValue());
        }
        String shNo = OrderUtils.countTodayByCreateTime(shippingInfoMapper, "SH");
        Map<Long, BigDecimal> positiveLines = new LinkedHashMap<>();
        for (Map.Entry<Long, BigDecimal> e : shipQtyByLineId.entrySet()) {
            if (e.getValue().compareTo(BigDecimal.ZERO) > 0) {
                positiveLines.put(e.getKey(), e.getValue());
            }
        }
        for (Map.Entry<Long, BigDecimal> e : positiveLines.entrySet()) {
            ShippingInfo si = new ShippingInfo();
            si.setSalesLedgerId(ledgerId);
            si.setSalesLedgerProductId(e.getKey());
            si.setShippingNo(shNo);
            si.setStatus("待审核");
            si.setType(shipType);
            if ("货车".equals(shipType)) {
                si.setShippingCarNumber(dto.getShippingCarNumber().trim());
            }
            if ("快递".equals(shipType)) {
                si.setExpressNumber(dto.getExpressNumber().trim());
            }
            shippingInfoMapper.insert(si);
        }
        String remarkJson = buildScanShipRemarkJson(shNo, ledgerId, dto, positiveLines);
        LoginUser loginUser = SecurityUtils.getLoginUser();
        ApproveProcessVO approveProcessVO = new ApproveProcessVO();
        approveProcessVO.setApproveType(7);
        approveProcessVO.setApproveDeptId(loginUser.getCurrentDeptId());
        approveProcessVO.setApproveReason("发货审批:" + salesLedger.getSalesContractNo());
        approveProcessVO.setApproveRemark(remarkJson);
        approveProcessVO.setApproveUserIds(dto.getApproveUserIds().trim());
        approveProcessVO.setApproveUser(loginUser.getUserId());
        approveProcessVO.setApproveTime(LocalDate.now().toString());
        approveProcessVO.setTempFileIds(dto.getTempFileIds());
        try {
            approveProcessService.addApprove(approveProcessVO);
        } catch (Exception e) {
            throw new ServiceException("发起发货审批失败: " + e.getMessage());
        }
        salesLedger.setDeliveryStatus(2);
        baseMapper.updateById(salesLedger);
    }
    private String buildScanShipRemarkJson(String shippingNo, Long ledgerId, SalesScanShipDto dto, Map<Long, BigDecimal> lines) {
        try {
            ObjectMapper om = new ObjectMapper();
            ObjectNode root = om.createObjectNode();
            root.put("shippingNo", shippingNo);
            root.put("ledgerId", ledgerId);
            root.put("car", dto.getShippingCarNumber() == null ? "" : dto.getShippingCarNumber().trim());
            root.put("express", dto.getExpressNumber() == null ? "" : dto.getExpressNumber().trim());
            root.put("shipType", dto.getShipType() == null ? "货车" : dto.getShipType().trim());
            ObjectNode linesNode = om.createObjectNode();
            for (Map.Entry<Long, BigDecimal> e : lines.entrySet()) {
                linesNode.put(String.valueOf(e.getKey()), e.getValue().stripTrailingZeros().toPlainString());
            }
            root.set("lines", linesNode);
            return SCAN_SHIP_REMARK_PREFIX + om.writeValueAsString(root);
        } catch (Exception e) {
            throw new ServiceException("构建发货审批参数失败");
        }
    }
    private ScanShipPayload parseScanShipPayload(String remark) {
        if (!StringUtils.hasText(remark) || !remark.startsWith(SCAN_SHIP_REMARK_PREFIX)) {
            return null;
        }
        try {
            String json = remark.substring(SCAN_SHIP_REMARK_PREFIX.length());
            ObjectMapper om = new ObjectMapper();
            JsonNode n = om.readTree(json);
            ScanShipPayload p = new ScanShipPayload();
            p.shippingNo = n.path("shippingNo").asText(null);
            p.ledgerId = n.path("ledgerId").asLong(0L);
            p.car = n.path("car").asText("");
            p.express = n.path("express").asText("");
            p.shipType = n.path("shipType").asText("货车");
            JsonNode lines = n.path("lines");
            p.linesQty = new LinkedHashMap<>();
            if (lines.isObject()) {
                Iterator<String> it = lines.fieldNames();
                while (it.hasNext()) {
                    String k = it.next();
                    p.linesQty.put(Long.valueOf(k), new BigDecimal(lines.get(k).asText()));
                }
            }
            return p;
        } catch (Exception e) {
            log.warn("解析扫码发货审批备注失败: {}", e.getMessage());
            return null;
        }
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void onScanShipDeliveryApproveOutcome(ApproveProcess approveProcess, Integer outcomeStatus) {
        if (approveProcess == null) {
            return;
        }
        ScanShipPayload ctx = parseScanShipPayload(approveProcess.getApproveRemark());
        if (ctx == null || ctx.ledgerId == null || ctx.ledgerId <= 0 || !StringUtils.hasText(ctx.shippingNo)) {
            return;
        }
        if (outcomeStatus != null && outcomeStatus == 2) {
            executeScanShipDeliveryApproved(approveProcess, ctx);
        } else if (outcomeStatus != null && outcomeStatus == 3) {
            updateScanShipBatchShippingStatus(ctx.ledgerId, ctx.shippingNo, "审核拒绝");
            SalesLedger sl = baseMapper.selectById(ctx.ledgerId);
            if (sl != null) {
                sl.setDeliveryStatus(3);
                baseMapper.updateById(sl);
            }
        } else if (outcomeStatus != null && outcomeStatus == 1) {
            updateScanShipBatchShippingStatus(ctx.ledgerId, ctx.shippingNo, "审核中");
        }
    }
    private void updateScanShipBatchShippingStatus(Long ledgerId, String shippingNo, String statusText) {
        if (ledgerId == null || !StringUtils.hasText(shippingNo)) {
            return;
        }
        shippingInfoMapper.update(null, new UpdateWrapper<ShippingInfo>().lambda()
                .set(ShippingInfo::getStatus, statusText)
                .eq(ShippingInfo::getSalesLedgerId, ledgerId)
                .eq(ShippingInfo::getShippingNo, shippingNo));
    }
    private void executeScanShipDeliveryApproved(ApproveProcess approveProcess, ScanShipPayload ctx) {
        int saleType = SaleEnum.SALE.getCode();
        Date now = new Date();
        List<ShippingInfo> batch = shippingInfoMapper.selectList(Wrappers.<ShippingInfo>lambdaQuery()
                .eq(ShippingInfo::getSalesLedgerId, ctx.ledgerId)
                .eq(ShippingInfo::getShippingNo, ctx.shippingNo));
        if (CollectionUtils.isEmpty(batch)) {
            log.warn("扫码发货审批通过但未找到发货台账 batch ledgerId={} shippingNo={}", ctx.ledgerId, ctx.shippingNo);
            return;
        }
        for (Map.Entry<Long, BigDecimal> entry : ctx.linesQty.entrySet()) {
            Long productLineId = entry.getKey();
            BigDecimal shipQty = entry.getValue();
            if (shipQty == null || shipQty.compareTo(BigDecimal.ZERO) <= 0) {
                continue;
            }
            SalesLedgerProduct dbProduct = salesLedgerProductMapper.selectById(productLineId);
            if (dbProduct == null) {
                throw new ServiceException("销售产品不存在");
            }
            ShippingInfo row = batch.stream()
                    .filter(si -> Objects.equals(si.getSalesLedgerProductId(), productLineId))
                    .findFirst()
                    .orElse(null);
            if (row == null) {
                throw new ServiceException("未找到对应发货台账行");
            }
            if (!"已发货".equals(row.getStatus())) {
                stockUtils.substractStock(ctx.ledgerId, productLineId, dbProduct.getProductModelId(), shipQty,
                        StockOutQualifiedRecordTypeEnum.SALE_SHIP_STOCK_OUT.getCode(), row.getId());
                BigDecimal oldShipped = defaultDecimal(dbProduct.getShippedQuantity());
                dbProduct.setShippedQuantity(oldShipped.add(shipQty));
                dbProduct.fillRemainingQuantity();
                salesLedgerProductMapper.updateById(dbProduct);
            }
            row.setStatus("已发货");
            row.setShippingDate(now);
            if (StringUtils.hasText(ctx.car)) {
                row.setShippingCarNumber(ctx.car.trim());
            }
            if (StringUtils.hasText(ctx.express)) {
                row.setExpressNumber(ctx.express.trim());
            }
            if (StringUtils.hasText(ctx.shipType)) {
                row.setType(ctx.shipType.trim());
            }
            shippingInfoMapper.updateById(row);
        }
        List<Long> shippingIds = batch.stream().map(ShippingInfo::getId).filter(Objects::nonNull).collect(Collectors.toList());
        commonFileService.copyApproveProcessShipAttachmentsToShippingInfos(approveProcess.getId(), shippingIds);
        List<SalesLedgerProduct> ledgerAllProducts = salesLedgerProductMapper.selectList(
                Wrappers.<SalesLedgerProduct>lambdaQuery().eq(SalesLedgerProduct::getSalesLedgerId, ctx.ledgerId));
        SalesLedger salesLedger = baseMapper.selectById(ctx.ledgerId);
        if (salesLedger == null) {
            return;
        }
        boolean anyInbound = ledgerAllProducts.stream().anyMatch(p -> {
            BigDecimal sq = p.getStockedQuantity();
            return sq != null && sq.compareTo(BigDecimal.ZERO) > 0;
        });
        boolean allLinesFull = ledgerAllProducts.stream().allMatch(p -> Objects.equals(p.getProductStockStatus(), 2));
        salesLedger.setStockStatus(allLinesFull ? 2 : (anyInbound ? 1 : 0));
        List<SalesLedgerProduct> saleLines = ledgerAllProducts.stream()
                .filter(p -> Objects.equals(p.getType(), saleType))
                .collect(Collectors.toList());
        boolean allDelivered = !saleLines.isEmpty() && saleLines.stream().allMatch(p -> {
            BigDecimal q = defaultDecimal(p.getQuantity());
            BigDecimal s = defaultDecimal(p.getShippedQuantity());
            return q.compareTo(BigDecimal.ZERO) <= 0 || s.compareTo(q) >= 0;
        });
        if (allDelivered) {
            salesLedger.setDeliveryStatus(5);
        } else {
            boolean anyLineShipped = saleLines.stream()
                    .anyMatch(p -> defaultDecimal(p.getShippedQuantity()).compareTo(BigDecimal.ZERO) > 0);
            if (anyLineShipped) {
                salesLedger.setDeliveryStatus(6);
            }
        }
        baseMapper.updateById(salesLedger);
    }
}
src/test/java/com/ruoyi/sales/InvoiceLedgerReceiptIncomeRebuildBatchTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,264 @@
package com.ruoyi.sales;
import com.ruoyi.RuoYiApplication;
import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.test.annotation.Rollback;
import org.springframework.transaction.annotation.Transactional;
import javax.sql.DataSource;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
 * <b>业务口径</b>:历史里 {@code receipt_payment.invoice_ledger_id} å¸¸è¢«å­˜æˆ <b>销售产品行 id</b>({@code sales_ledger_product_id}),
 * ä¸ŽçœŸå®ž {@code invoice_ledger.id} ä¸ä¸€è‡´ã€‚同步时应以 <b>开票台账</b> ä¸ºå‡†ï¼šå‡¡æ˜¯ã€Œå·²ç»å¼€ç¥¨ã€çš„销售台账产品行,
 * å°±å¿…须有与之对应的回款流水与回款收入,且<strong>一行开票台账 â†’ ä¸€è¡Œå›žæ¬¾ â†’ ä¸€è¡Œæ”¶å…¥</strong>。
 * <p>
 * <b>推荐:{@link #syncReceiptByInvoicedSalesProducts_hbtmblcSchema()}</b>
 * <ol>
 *   <li>查出所有在开票台账里出现过的 {@code sales_ledger_product_id}({@code invoice_ledger} â†’ {@code invoice_registration_product})。</li>
 *   <li>删除这些<strong>销售产品行</strong>上现有的 {@code receipt_payment},并删除 {@code business_type=1} ä¸”挂在这些回款上的 {@code account_income}。</li>
 *   <li>按 {@code invoice_ledger} é€è¡Œæ’入新的 {@code receipt_payment}:{@code sales_ledger_id}、{@code sales_ledger_product_id} æ¥è‡ªç™»è®°äº§å“è¡Œï¼Œ
 *       {@code invoice_ledger_id = il.id}(真实台账主键),金额/日期/开票人与开票台账一致。</li>
 *   <li>按新回款主键插入 {@code account_income}(与 {@link com.ruoyi.sales.service.impl.ReceiptPaymentServiceImpl} ä¸€è‡´ï¼š{@code business_id=receipt_payment.id})。</li>
 * </ol>
 * æœªåœ¨å¼€ç¥¨é‡Œå‡ºçŽ°çš„é”€å”®äº§å“ï¼Œå…¶å›žæ¬¾è®°å½•<strong>不删不改</strong>。
 * <p>
 * <b>慎用:{@link #fullRebuildReceiptFromInvoiceLedger_hbtmblcSchema()}</b> â€” æ¸…空<b>全部</b>回款与<b>全部</b>回款类收入后重建,
 * ä»…用于整库以开票为唯一事实源的场景。
 * <p>
 * <b>写库开关</b>:{@code -Druoyi.invoiceReceiptRebuild.commit=true} æˆ– {@code RUOYI_INVOICE_RECEIPT_REBUILD_COMMIT=true}。
 */
@SpringBootTest(classes = RuoYiApplication.class)
class InvoiceLedgerReceiptIncomeRebuildBatchTest {
    private static final Logger log = LoggerFactory.getLogger(InvoiceLedgerReceiptIncomeRebuildBatchTest.class);
    private static final Long TENANT_ID = null;
    private static final String DEFAULT_REGISTRANT = "樊志英";
    private static final String RECEIPT_PAYMENT_TYPE = "0";
    static boolean commitSwitchEnabled() {
        String p = System.getProperty("ruoyi.invoiceReceiptRebuild.commit");
        if (p != null && "true".equalsIgnoreCase(p.trim())) {
            return true;
        }
        String e = System.getenv("RUOYI_INVOICE_RECEIPT_REBUILD_COMMIT");
        return e != null && "true".equalsIgnoreCase(e.trim());
    }
    static MapSqlParameterSource tenantParams() {
        return new MapSqlParameterSource("tenantId", TENANT_ID);
    }
    static String tenantCondIl() {
        return "(:tenantId IS NULL OR COALESCE(irp.tenant_id, il.tenant_id) = :tenantId)";
    }
    static String tenantRp(String alias) {
        return "(:tenantId IS NULL OR " + alias + ".tenant_id = :tenantId)";
    }
    /**
     * å‡ºçŽ°åœ¨å¼€ç¥¨é“¾è·¯é‡Œçš„é”€å”®äº§å“ id é›†åˆï¼ˆå­æŸ¥è¯¢ç‰‡æ®µï¼Œä¸å«å¤–层括号)。
     */
    static String invoicedProductIdSubquery() {
        return "SELECT DISTINCT irp.sales_ledger_product_id "
                + "FROM invoice_ledger il "
                + "INNER JOIN invoice_registration_product irp ON irp.id = il.invoice_registration_product_id "
                + "WHERE irp.sales_ledger_product_id IS NOT NULL "
                + "AND (" + tenantCondIl() + ")";
    }
    @Autowired
    private DataSource dataSource;
    /**
     * æŒ‰ã€Œå·²å¼€ç¥¨çš„销售台账产品行」重建回款与收入(推荐)。
     */
    @Test
    @Transactional
    void syncReceiptByInvoicedSalesProducts_hbtmblcSchema() {
        NamedParameterJdbcTemplate named = new NamedParameterJdbcTemplate(dataSource);
        MapSqlParameterSource p = tenantParams();
        String deleteIncome =
                "DELETE ai FROM account_income ai "
                        + "INNER JOIN receipt_payment rp ON ai.business_id = rp.id AND ai.business_type = 1 "
                        + "WHERE rp.sales_ledger_product_id IN (" + invoicedProductIdSubquery() + ") "
                        + "AND (" + tenantRp("rp") + ")";
        int delInc = named.update(deleteIncome, p);
        log.warn("已删除「已开票产品」关联的回款收入 account_income è¡Œæ•°: {}", delInc);
        String deleteReceipt =
                "DELETE rp FROM receipt_payment rp "
                        + "WHERE rp.sales_ledger_product_id IN (" + invoicedProductIdSubquery() + ") "
                        + "AND (" + tenantRp("rp") + ")";
        int delRp = named.update(deleteReceipt, p);
        log.warn("已删除「已开票产品」上的旧回款 receipt_payment è¡Œæ•°: {}", delRp);
        MapSqlParameterSource insRp = tenantParams().addValue("rpType", RECEIPT_PAYMENT_TYPE);
        String insertReceipt =
                "INSERT INTO receipt_payment ("
                        + "sales_ledger_id, sales_ledger_product_id, invoice_ledger_id, "
                        + "receipt_payment_type, receipt_payment_amount, registrant, receipt_payment_date, "
                        + "create_time, create_user, update_time, update_user, tenant_id) "
                        + "SELECT "
                        + "  CAST(irp.sales_ledger_id AS SIGNED), "
                        + "  CAST(irp.sales_ledger_product_id AS SIGNED), "
                        + "  il.id, "
                        + "  :rpType, "
                        + "  il.invoice_total, "
                        + "  NULLIF(TRIM(il.invoice_person), ''), "
                        + "  DATE(il.invoice_date), "
                        + "  CAST(DATE(il.invoice_date) AS DATETIME), "
                        + "  il.create_user, "
                        + "  CAST(DATE(il.invoice_date) AS DATETIME), "
                        + "  il.update_user, "
                        + "  COALESCE(irp.tenant_id, il.tenant_id) "
                        + "FROM invoice_ledger il "
                        + "INNER JOIN invoice_registration_product irp ON irp.id = il.invoice_registration_product_id "
                        + "WHERE irp.sales_ledger_product_id IS NOT NULL "
                        + "AND (" + tenantCondIl() + ")";
        int insRpCnt = named.update(insertReceipt, insRp);
        log.warn("已按开票台账插入 receipt_payment è¡Œæ•°: {}", insRpCnt);
        MapSqlParameterSource regP = tenantParams().addValue("defReg", DEFAULT_REGISTRANT);
        named.update(
                "UPDATE receipt_payment rp SET rp.registrant = :defReg "
                        + "WHERE (rp.registrant IS NULL OR rp.registrant = '') AND (" + tenantRp("rp") + ")",
                regP);
        MapSqlParameterSource insAi = tenantParams()
                .addValue("rpType", RECEIPT_PAYMENT_TYPE)
                .addValue("defReg", DEFAULT_REGISTRANT);
        String insertIncome =
                "INSERT INTO account_income ("
                        + "income_date, income_type, customer_name, income_money, income_described, income_method, "
                        + "invoice_number, input_user, input_time, business_id, business_type, tenant_id, "
                        + "create_time, create_user, update_time, update_user) "
                        + "SELECT "
                        + "  DATE(il.invoice_date), "
                        + "  '3', "
                        + "  sl.customer_name, "
                        + "  rp.receipt_payment_amount, "
                        + "  '回款收入', "
                        + "  :rpType, "
                        + "  il.invoice_no, "
                        + "  COALESCE(NULLIF(TRIM(rp.registrant), ''), :defReg), "
                        + "  DATE(il.invoice_date), "
                        + "  rp.id, "
                        + "  1, "
                        + "  rp.tenant_id, "
                        + "  CAST(DATE(il.invoice_date) AS DATETIME), "
                        + "  rp.create_user, "
                        + "  CAST(DATE(il.invoice_date) AS DATETIME), "
                        + "  rp.update_user "
                        + "FROM receipt_payment rp "
                        + "INNER JOIN invoice_ledger il ON il.id = rp.invoice_ledger_id "
                        + "INNER JOIN invoice_registration_product irp ON irp.id = il.invoice_registration_product_id "
                        + "INNER JOIN sales_ledger sl ON sl.id = irp.sales_ledger_id "
                        + "WHERE (" + tenantCondIl() + ") "
                        + "AND (" + tenantRp("rp") + ") "
                        + "AND NOT EXISTS ("
                        + "  SELECT 1 FROM account_income x WHERE x.business_id = rp.id AND x.business_type = 1"
                        + ")";
        int insAiCnt = named.update(insertIncome, insAi);
        log.warn("已插入回款收入 account_income è¡Œæ•°: {}", insAiCnt);
        assertTrue(insRpCnt >= 0 && insAiCnt >= 0);
    }
    /**
     * æ¸…空<b>全部</b>回款与回款类收入后,仅按开票台账重建(会影响未开票产品的回款,慎用)。
     */
    @Test
    @Transactional
    @Rollback(false)
    void fullRebuildReceiptFromInvoiceLedger_hbtmblcSchema() {
        NamedParameterJdbcTemplate named = new NamedParameterJdbcTemplate(dataSource);
        MapSqlParameterSource insertParams = tenantParams().addValue("rpType", RECEIPT_PAYMENT_TYPE);
        int deletedIncome = named.update("DELETE FROM account_income WHERE business_type = 1", new MapSqlParameterSource());
        log.warn("fullRebuild: å·²åˆ é™¤å…¨éƒ¨å›žæ¬¾ç±»æ”¶å…¥è¡Œæ•°: {}", deletedIncome);
        int deletedReceipt = named.update("DELETE FROM receipt_payment", new MapSqlParameterSource());
        log.warn("fullRebuild: å·²æ¸…空 receipt_payment è¡Œæ•°: {}", deletedReceipt);
        String insertReceipt =
                "INSERT INTO receipt_payment ("
                        + "sales_ledger_id, sales_ledger_product_id, invoice_ledger_id, "
                        + "receipt_payment_type, receipt_payment_amount, registrant, receipt_payment_date, "
                        + "create_time, create_user, update_time, update_user, tenant_id) "
                        + "SELECT "
                        + "  CAST(irp.sales_ledger_id AS SIGNED), "
                        + "  CAST(irp.sales_ledger_product_id AS SIGNED), "
                        + "  il.id, "
                        + "  :rpType, "
                        + "  il.invoice_total, "
                        + "  NULLIF(TRIM(il.invoice_person), ''), "
                        + "  DATE(il.invoice_date), "
                        + "  CAST(DATE(il.invoice_date) AS DATETIME), "
                        + "  il.create_user, "
                        + "  CAST(DATE(il.invoice_date) AS DATETIME), "
                        + "  il.update_user, "
                        + "  COALESCE(irp.tenant_id, il.tenant_id) "
                        + "FROM invoice_ledger il "
                        + "INNER JOIN invoice_registration_product irp ON irp.id = il.invoice_registration_product_id "
                        + "WHERE " + tenantCondIl();
        int insertedReceipt = named.update(insertReceipt, insertParams);
        log.warn("fullRebuild: å·²æ’å…¥ receipt_payment è¡Œæ•°: {}", insertedReceipt);
        named.update(
                "UPDATE receipt_payment SET registrant = :defReg WHERE registrant IS NULL OR registrant = ''",
                new MapSqlParameterSource("defReg", DEFAULT_REGISTRANT));
        insertParams.addValue("defReg", DEFAULT_REGISTRANT);
        String insertIncome =
                "INSERT INTO account_income ("
                        + "income_date, income_type, customer_name, income_money, income_described, income_method, "
                        + "invoice_number, input_user, input_time, business_id, business_type, tenant_id, "
                        + "create_time, create_user, update_time, update_user) "
                        + "SELECT "
                        + "  DATE(il.invoice_date), "
                        + "  '3', "
                        + "  sl.customer_name, "
                        + "  rp.receipt_payment_amount, "
                        + "  '回款收入', "
                        + "  :rpType, "
                        + "  il.invoice_no, "
                        + "  COALESCE(NULLIF(TRIM(rp.registrant), ''), :defReg), "
                        + "  DATE(il.invoice_date), "
                        + "  rp.id, "
                        + "  1, "
                        + "  rp.tenant_id, "
                        + "  CAST(DATE(il.invoice_date) AS DATETIME), "
                        + "  rp.create_user, "
                        + "  CAST(DATE(il.invoice_date) AS DATETIME), "
                        + "  rp.update_user "
                        + "FROM receipt_payment rp "
                        + "INNER JOIN invoice_ledger il ON il.id = rp.invoice_ledger_id "
                        + "INNER JOIN invoice_registration_product irp ON irp.id = il.invoice_registration_product_id "
                        + "INNER JOIN sales_ledger sl ON sl.id = irp.sales_ledger_id "
                        + "WHERE " + tenantCondIl();
        int insertedIncome = named.update(insertIncome, insertParams);
        log.warn("fullRebuild: å·²æ’å…¥ account_income è¡Œæ•°: {}", insertedIncome);
        assertTrue(insertedReceipt >= 0 && insertedIncome >= 0);
    }
}