云
5 天以前 a82d847bbb39e2417d4c9773ebc4fc51fdb0b8fc
feat(crm): 完善投标保证金与报价单关联及作废功能

- 新增管理端撤销运行中流程实例接口
- 投标保证金新增关联报价单及双向绑定逻辑
- 增加投标中标时必须关联报价单的业务校验
- 销售报价单新增是否投标及关联保证金字段
- 新增报价单手动作废及轮次流水列表查询功能
- 新增投标保证金与报价单相关数据的导出接口
- 完善报价单列表中关联投标中标结果的展示
已添加12个文件
已修改20个文件
1861 ■■■■■ 文件已修改
sql/mysql/20261110_crm_quotation_bid_revision.sql 145 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/api/task/BpmProcessInstanceApi.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/api/task/BpmProcessInstanceApiImpl.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/bidbond/CrmBidBondController.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/bidbond/vo/CrmBidBondPageReqVO.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/bidbond/vo/CrmBidBondRespVO.java 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/bidbond/vo/CrmBidBondSaveReqVO.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/quotation/CrmSaleQuotationController.java 71 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/quotation/vo/CrmSaleQuotationOptionRespVO.java 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/quotation/vo/CrmSaleQuotationPageReqVO.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/quotation/vo/CrmSaleQuotationRespVO.java 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/quotation/vo/CrmSaleQuotationRevisionRespVO.java 90 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/quotation/vo/CrmSaleQuotationSaveReqVO.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/bidbond/CrmBidBondDO.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/quotation/CrmSaleQuotationDO.java 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/quotation/CrmSaleQuotationRevisionDO.java 110 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/bidbond/CrmBidBondMapper.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/quotation/CrmSaleQuotationMapper.java 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/quotation/CrmSaleQuotationRevisionMapper.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/enums/ErrorCodeConstants.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/enums/quotation/CrmSaleQuotationStatusEnum.java 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/enums/quotation/CrmSaleQuotationVoidTypeEnum.java 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/bidbond/CrmBidBondServiceImpl.java 102 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/quotation/CrmSaleQuotationExpiryHelper.java 155 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/quotation/CrmSaleQuotationService.java 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/quotation/CrmSaleQuotationServiceImpl.java 295 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/test/java/cn/iocoder/yudao/module/crm/service/bidbond/CrmBidBondQuotationBindingTest.java 140 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/test/java/cn/iocoder/yudao/module/crm/service/quotation/CrmSaleQuotationConvertGateTest.java 103 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/test/java/cn/iocoder/yudao/module/crm/service/quotation/CrmSaleQuotationExpiryHelperTest.java 118 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/test/java/cn/iocoder/yudao/module/crm/service/quotation/CrmSaleQuotationQuantityTest.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/test/java/cn/iocoder/yudao/module/crm/service/quotation/CrmSaleQuotationRevisionTest.java 88 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/test/java/cn/iocoder/yudao/module/crm/service/quotation/CrmSaleQuotationVoidTest.java 114 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/20261110_crm_quotation_bid_revision.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,145 @@
-- =====================================================
-- æŠ¥ä»·ä¸ŽæŠ•标闭环
-- 1. crm_bid_bond å¢žåŠ å…³è”æŠ¥ä»·å•å­—æ®µï¼ˆæŠ•æ ‡ç»‘å®šæŠ¥ä»·ï¼‰
-- 2. crm_sale_quotation å¢žåŠ æ˜¯å¦éœ€è¦æŠ•æ ‡ã€æŠ•æ ‡å…³è”ã€æŠ¥ä»·è½®æ¬¡ã€ä½œåºŸå­—æ®µ
-- 3. æ–°å»º crm_sale_quotation_revision(每轮报价流水快照)
-- 4. èœå•:销售报价-作废、投标保证金-导出
-- å¹‚等:可重复执行;不含 tenant_id
-- =====================================================
-- ---------- 1. crm_bid_bond ----------
SET @exists = (SELECT COUNT(*) FROM information_schema.columns
               WHERE table_schema = DATABASE() AND table_name = 'crm_bid_bond' AND column_name = 'quotation_id');
SET @sql = IF(@exists = 0,
    'ALTER TABLE `crm_bid_bond` ADD COLUMN `quotation_id` BIGINT NULL COMMENT ''关联报价单ID'' AFTER `tender_no`',
    'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @exists = (SELECT COUNT(*) FROM information_schema.columns
               WHERE table_schema = DATABASE() AND table_name = 'crm_bid_bond' AND column_name = 'quotation_no');
SET @sql = IF(@exists = 0,
    'ALTER TABLE `crm_bid_bond` ADD COLUMN `quotation_no` VARCHAR(32) NULL COMMENT ''关联报价单号快照'' AFTER `quotation_id`',
    'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @exists = (SELECT COUNT(*) FROM information_schema.columns
               WHERE table_schema = DATABASE() AND table_name = 'crm_bid_bond' AND column_name = 'quotation_round');
SET @sql = IF(@exists = 0,
    'ALTER TABLE `crm_bid_bond` ADD COLUMN `quotation_round` INT NOT NULL DEFAULT 0 COMMENT ''当前报价轮次'' AFTER `quotation_no`',
    'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @exists = (SELECT COUNT(*) FROM information_schema.statistics
               WHERE table_schema = DATABASE() AND table_name = 'crm_bid_bond' AND index_name = 'idx_quotation_id');
SET @sql = IF(@exists = 0,
    'ALTER TABLE `crm_bid_bond` ADD INDEX `idx_quotation_id` (`quotation_id`)',
    'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ---------- 2. crm_sale_quotation ----------
SET @exists = (SELECT COUNT(*) FROM information_schema.columns
               WHERE table_schema = DATABASE() AND table_name = 'crm_sale_quotation' AND column_name = 'need_bid');
SET @sql = IF(@exists = 0,
    'ALTER TABLE `crm_sale_quotation` ADD COLUMN `need_bid` TINYINT(1) NOT NULL DEFAULT 1 COMMENT ''是否需要投标:1是 0否'' AFTER `contract_no`',
    'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @exists = (SELECT COUNT(*) FROM information_schema.columns
               WHERE table_schema = DATABASE() AND table_name = 'crm_sale_quotation' AND column_name = 'bid_bond_id');
SET @sql = IF(@exists = 0,
    'ALTER TABLE `crm_sale_quotation` ADD COLUMN `bid_bond_id` BIGINT NULL COMMENT ''关联投标(保证金)ID'' AFTER `contract_no`',
    'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @exists = (SELECT COUNT(*) FROM information_schema.columns
               WHERE table_schema = DATABASE() AND table_name = 'crm_sale_quotation' AND column_name = 'bid_bond_no');
SET @sql = IF(@exists = 0,
    'ALTER TABLE `crm_sale_quotation` ADD COLUMN `bid_bond_no` VARCHAR(32) NULL COMMENT ''投标单号快照'' AFTER `bid_bond_id`',
    'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @exists = (SELECT COUNT(*) FROM information_schema.columns
               WHERE table_schema = DATABASE() AND table_name = 'crm_sale_quotation' AND column_name = 'revision_no');
SET @sql = IF(@exists = 0,
    'ALTER TABLE `crm_sale_quotation` ADD COLUMN `revision_no` INT NOT NULL DEFAULT 0 COMMENT ''已提交报价轮次'' AFTER `bid_bond_no`',
    'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @exists = (SELECT COUNT(*) FROM information_schema.columns
               WHERE table_schema = DATABASE() AND table_name = 'crm_sale_quotation' AND column_name = 'void_type');
SET @sql = IF(@exists = 0,
    'ALTER TABLE `crm_sale_quotation` ADD COLUMN `void_type` TINYINT NULL COMMENT ''作废类型:1手动作废 2超期作废'' AFTER `revision_no`',
    'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @exists = (SELECT COUNT(*) FROM information_schema.columns
               WHERE table_schema = DATABASE() AND table_name = 'crm_sale_quotation' AND column_name = 'void_reason');
SET @sql = IF(@exists = 0,
    'ALTER TABLE `crm_sale_quotation` ADD COLUMN `void_reason` VARCHAR(500) NULL COMMENT ''作废原因'' AFTER `void_type`',
    'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @exists = (SELECT COUNT(*) FROM information_schema.columns
               WHERE table_schema = DATABASE() AND table_name = 'crm_sale_quotation' AND column_name = 'void_time');
SET @sql = IF(@exists = 0,
    'ALTER TABLE `crm_sale_quotation` ADD COLUMN `void_time` DATETIME NULL COMMENT ''作废时间'' AFTER `void_reason`',
    'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @exists = (SELECT COUNT(*) FROM information_schema.columns
               WHERE table_schema = DATABASE() AND table_name = 'crm_sale_quotation' AND column_name = 'void_by');
SET @sql = IF(@exists = 0,
    'ALTER TABLE `crm_sale_quotation` ADD COLUMN `void_by` BIGINT NULL COMMENT ''作废人用户ID'' AFTER `void_time`',
    'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @exists = (SELECT COUNT(*) FROM information_schema.statistics
               WHERE table_schema = DATABASE() AND table_name = 'crm_sale_quotation' AND index_name = 'idx_bid_bond_id');
SET @sql = IF(@exists = 0,
    'ALTER TABLE `crm_sale_quotation` ADD INDEX `idx_bid_bond_id` (`bid_bond_id`)',
    'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ---------- 3. crm_sale_quotation_revision ----------
CREATE TABLE IF NOT EXISTS `crm_sale_quotation_revision` (
    `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '编号',
    `quotation_id` BIGINT NOT NULL COMMENT '报价单ID',
    `quotation_no` VARCHAR(32) NOT NULL COMMENT '报价单号快照',
    `revision_no` INT NOT NULL COMMENT '报价轮次',
    `bid_bond_id` BIGINT DEFAULT NULL COMMENT '当时绑定的投标ID',
    `bid_bond_no` VARCHAR(32) DEFAULT NULL COMMENT '当时绑定的投标单号',
    `customer_id` BIGINT DEFAULT NULL COMMENT '客户ID',
    `name` VARCHAR(255) DEFAULT NULL COMMENT '报价单名称快照',
    `quotation_time` DATETIME DEFAULT NULL COMMENT '报价日期',
    `valid_until` DATETIME DEFAULT NULL COMMENT '有效期至',
    `total_product_price` DECIMAL(24,6) DEFAULT NULL COMMENT '产品总金额',
    `discount_percent` DECIMAL(24,6) DEFAULT NULL COMMENT '整单折扣',
    `discount_price` DECIMAL(24,6) DEFAULT NULL COMMENT '优惠金额',
    `total_price` DECIMAL(24,6) DEFAULT NULL COMMENT '报价总金额',
    `tax_rate` DECIMAL(24,6) DEFAULT NULL COMMENT '税率',
    `tax_price` DECIMAL(24,6) DEFAULT NULL COMMENT '税额',
    `items_snapshot` LONGTEXT COMMENT '明细JSON快照',
    `status` TINYINT DEFAULT NULL COMMENT '提交时状态',
    `process_instance_id` VARCHAR(64) DEFAULT NULL COMMENT '流程实例编号',
    `submit_user_id` BIGINT DEFAULT NULL COMMENT '提交人用户ID',
    `submit_time` DATETIME DEFAULT NULL 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`),
    UNIQUE KEY `uk_quotation_revision` (`quotation_id`, `revision_no`),
    KEY `idx_quotation_id` (`quotation_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='CRM销售报价轮次流水';
-- ---------- 4. èœå• ----------
INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`,
                           `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`,
                           `creator`, `create_time`, `updater`, `update_time`, `deleted`)
VALUES
(602650, '销售报价-作废', 'crm:sale-quotation:void', 3, 10, 602512, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
(602651, '投标保证金-导出', 'crm:bid-bond:export', 3, 9, 602630, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0')
ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `permission` = VALUES(`permission`),
                        `parent_id` = VALUES(`parent_id`), `sort` = VALUES(`sort`),
                        `status` = VALUES(`status`), `deleted` = b'0';
yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/api/task/BpmProcessInstanceApi.java
@@ -33,6 +33,15 @@
     */
    void cancelProcessInstanceByStartUser(Long userId, String processInstanceId, String reason);
    /**
     * ç”±ç®¡ç†ç«¯æ’¤é”€è¿è¡Œä¸­çš„æµç¨‹å®žä¾‹ï¼ˆä¸æ ¡éªŒå‘起人)。
     *
     * @param userId            æ“ä½œäººç”¨æˆ·ç¼–号
     * @param processInstanceId æµç¨‹å®žä¾‹ç¼–号
     * @param reason            æ’¤é”€åŽŸå› 
     */
    void cancelProcessInstanceByAdmin(Long userId, String processInstanceId, String reason);
}
yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/api/task/BpmProcessInstanceApiImpl.java
@@ -43,4 +43,12 @@
        processInstanceService.cancelProcessInstanceByStartUser(userId, reqVO);
    }
    @Override
    public void cancelProcessInstanceByAdmin(Long userId, String processInstanceId, String reason) {
        BpmProcessInstanceCancelReqVO reqVO = new BpmProcessInstanceCancelReqVO();
        reqVO.setId(processInstanceId);
        reqVO.setReason(reason);
        processInstanceService.cancelProcessInstanceByAdmin(userId, reqVO);
    }
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/bidbond/CrmBidBondController.java
@@ -1,7 +1,10 @@
package cn.iocoder.yudao.module.crm.controller.admin.bidbond;
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;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.excel.core.util.ExcelUtils;
import cn.iocoder.yudao.module.crm.controller.admin.bidbond.vo.CrmBidBondPageReqVO;
import cn.iocoder.yudao.module.crm.controller.admin.bidbond.vo.CrmBidBondRespVO;
import cn.iocoder.yudao.module.crm.controller.admin.bidbond.vo.CrmBidBondSaleOrderRespVO;
@@ -11,13 +14,16 @@
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.util.List;
import static cn.iocoder.yudao.framework.apilog.core.enums.OperateTypeEnum.EXPORT;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
@Tag(name = "管理后台 - CRM æŠ•标保证金")
@@ -68,6 +74,17 @@
        return success(bidBondService.getBidBondPage(pageReqVO));
    }
    @GetMapping("/export-excel")
    @Operation(summary = "导出投标保证金 Excel")
    @PreAuthorize("@ss.hasPermission('crm:bid-bond:export')")
    @ApiAccessLog(operateType = EXPORT)
    public void exportBidBondExcel(@Valid CrmBidBondPageReqVO pageReqVO,
                                   HttpServletResponse response) throws IOException {
        pageReqVO.setPageSize(PageParam.PAGE_SIZE_NONE);
        List<CrmBidBondRespVO> list = bidBondService.getBidBondPage(pageReqVO).getList();
        ExcelUtils.write(response, "投标保证金.xls", "数据", CrmBidBondRespVO.class, list);
    }
    @GetMapping("/sale-order-list")
    @Operation(summary = "获取客户下可关联的销售订单(销售台账)")
    @Parameter(name = "customerId", description = "客户编号", required = true)
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/bidbond/vo/CrmBidBondPageReqVO.java
@@ -32,6 +32,9 @@
    @Schema(description = "关联销售订单号", example = "XSDD202611050001")
    private String saleOrderNo;
    @Schema(description = "关联报价单号", example = "BJ202611050001")
    private String quotationNo;
    @Schema(description = "中标结果:0 å¾…开标、1 å·²ä¸­æ ‡ã€2 æœªä¸­æ ‡", example = "2")
    private Integer bidResult;
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/bidbond/vo/CrmBidBondRespVO.java
@@ -1,5 +1,7 @@
package cn.iocoder.yudao.module.crm.controller.admin.bidbond.vo;
import cn.idev.excel.annotation.ExcelIgnoreUnannotated;
import cn.idev.excel.annotation.ExcelProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@@ -9,53 +11,84 @@
@Schema(description = "管理后台 - CRM æŠ•标保证金 Response VO")
@Data
@ExcelIgnoreUnannotated
public class CrmBidBondRespVO {
    @Schema(description = "保证金记录编号", example = "1024")
    private Long id;
    @ExcelProperty("保证金单号")
    @Schema(description = "保证金单号", example = "BZ202611050001")
    private String no;
    @Schema(description = "客户编号(招标方)", example = "1024")
    private Long customerId;
    @ExcelProperty("客户名称")
    @Schema(description = "客户名称", example = "某某医院")
    private String customerName;
    @ExcelProperty("项目名称")
    @Schema(description = "项目名称", example = "某某医院设备采购项目")
    private String projectName;
    @ExcelProperty("招标编号")
    @Schema(description = "招标编号", example = "ZB-2026-001")
    private String tenderNo;
    @Schema(description = "关联销售订单编号(销售台账)", example = "2048")
    private Long saleOrderId;
    @ExcelProperty("关联销售订单号")
    @Schema(description = "关联销售订单号", example = "XSDD202611050001")
    private String saleOrderNo;
    @ExcelProperty("关联销售订单金额")
    @Schema(description = "关联销售订单金额", example = "500000.00")
    private BigDecimal saleOrderAmount;
    @Schema(description = "关联报价单编号", example = "1024")
    private Long quotationId;
    @ExcelProperty("关联报价单号")
    @Schema(description = "关联报价单号", example = "BJ202611050001")
    private String quotationNo;
    @ExcelProperty("报价轮次")
    @Schema(description = "关联报价单当前轮次", example = "2")
    private Integer quotationRound;
    @Schema(description = "关联报价单金额", example = "100000.00")
    private BigDecimal quotationTotalPrice;
    @Schema(description = "关联报价单状态:0 è‰ç¨¿ã€1 å®¡æ‰¹ä¸­ã€2 å®¡æ‰¹é€šè¿‡ã€3 å®¡æ‰¹ä¸é€šè¿‡ã€4 å·²å–消、5 å·²ä½œåºŸã€10 å·²è½¬åˆåŒ", example = "2")
    private Integer quotationStatus;
    @ExcelProperty("保证金金额")
    @Schema(description = "保证金金额", example = "50000.00")
    private BigDecimal bondAmount;
    @ExcelProperty("缴纳日期")
    @Schema(description = "缴纳日期", example = "2026-11-05")
    private LocalDate payDate;
    @ExcelProperty("中标结果")
    @Schema(description = "中标结果:0 å¾…开标、1 å·²ä¸­æ ‡ã€2 æœªä¸­æ ‡", example = "2")
    private Integer bidResult;
    @ExcelProperty("退回状态")
    @Schema(description = "退回状态:0 æœªé€€å›žã€1 éƒ¨åˆ†é€€å›žã€2 å·²é€€å›žã€3 æ— éœ€é€€å›ž", example = "0")
    private Integer refundStatus;
    @ExcelProperty("退回金额")
    @Schema(description = "退回金额", example = "50000.00")
    private BigDecimal refundAmount;
    @ExcelProperty("退回日期")
    @Schema(description = "退回日期", example = "2026-12-01")
    private LocalDate refundDate;
    @ExcelProperty("备注")
    @Schema(description = "备注")
    private String remark;
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/bidbond/vo/CrmBidBondSaveReqVO.java
@@ -30,6 +30,9 @@
    @Schema(description = "关联销售订单ID(销售台账)", example = "2048")
    private Long saleOrderId;
    @Schema(description = "关联报价单编号", example = "1024")
    private Long quotationId;
    @Schema(description = "保证金金额", requiredMode = Schema.RequiredMode.REQUIRED, example = "50000.00")
    @NotNull(message = "保证金金额不能为空,请填写本次向客户缴纳的保证金金额")
    private BigDecimal bondAmount;
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/quotation/CrmSaleQuotationController.java
@@ -8,13 +8,18 @@
import cn.iocoder.yudao.framework.common.util.collection.MapUtils;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.framework.excel.core.util.ExcelUtils;
import cn.iocoder.yudao.module.crm.controller.admin.quotation.vo.CrmSaleQuotationOptionRespVO;
import cn.iocoder.yudao.module.crm.controller.admin.quotation.vo.CrmSaleQuotationPageReqVO;
import cn.iocoder.yudao.module.crm.controller.admin.quotation.vo.CrmSaleQuotationRespVO;
import cn.iocoder.yudao.module.crm.controller.admin.quotation.vo.CrmSaleQuotationRevisionRespVO;
import cn.iocoder.yudao.module.crm.controller.admin.quotation.vo.CrmSaleQuotationSaveReqVO;
import cn.iocoder.yudao.module.crm.dal.dataobject.bidbond.CrmBidBondDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.quotation.CrmSaleQuotationDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.quotation.CrmSaleQuotationProductDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.quotation.CrmSaleQuotationRevisionDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.business.CrmBusinessDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.customer.CrmCustomerDO;
import cn.iocoder.yudao.module.crm.dal.mysql.bidbond.CrmBidBondMapper;
import cn.iocoder.yudao.module.crm.service.business.CrmBusinessService;
import cn.iocoder.yudao.module.crm.service.customer.CrmCustomerService;
import cn.iocoder.yudao.module.crm.enums.quotation.CrmSaleQuotationStatusEnum;
@@ -46,8 +51,10 @@
import java.nio.charset.StandardCharsets;
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.apilog.core.enums.OperateTypeEnum.UPDATE;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMultiMap;
@@ -66,6 +73,9 @@
    @Resource
    private CrmSaleQuotationService saleQuotationService;
    @Resource
    private CrmBidBondMapper bidBondMapper;
    @Resource
    private AdminUserApi adminUserApi;
@@ -229,6 +239,54 @@
        return success(saleQuotationService.convertToContract(id, getLoginUserId()));
    }
    @PutMapping("/void")
    @Operation(summary = "作废销售报价单")
    @Parameter(name = "id", description = "报价单编号", required = true)
    @Parameter(name = "reason", description = "作废原因", required = true)
    @PreAuthorize("@ss.hasPermission('crm:sale-quotation:void')")
    @ApiAccessLog(operateType = UPDATE)
    public CommonResult<Boolean> voidSaleQuotation(@RequestParam("id") Long id,
                                                   @RequestParam("reason") String reason) {
        saleQuotationService.voidSaleQuotation(id, reason);
        return success(true);
    }
    @GetMapping("/revision/list")
    @Operation(summary = "获得报价单轮次流水列表")
    @Parameter(name = "quotationId", description = "报价单编号", required = true)
    @PreAuthorize("@ss.hasPermission('crm:sale-quotation:query')")
    public CommonResult<List<CrmSaleQuotationRevisionRespVO>> getRevisionList(
            @RequestParam("quotationId") Long quotationId) {
        List<CrmSaleQuotationRevisionDO> list = saleQuotationService.getSaleQuotationRevisionList(quotationId);
        return success(BeanUtils.toBean(list, CrmSaleQuotationRevisionRespVO.class));
    }
    @GetMapping("/revision/export-excel")
    @Operation(summary = "导出报价单轮次流水 Excel")
    @Parameter(name = "quotationId", description = "报价单编号", required = true)
    @PreAuthorize("@ss.hasPermission('crm:sale-quotation:export')")
    @ApiAccessLog(operateType = EXPORT)
    public void exportRevisionExcel(@RequestParam("quotationId") Long quotationId,
                                    HttpServletResponse response) throws IOException {
        List<CrmSaleQuotationRevisionRespVO> list = BeanUtils.toBean(
                saleQuotationService.getSaleQuotationRevisionList(quotationId),
                CrmSaleQuotationRevisionRespVO.class);
        ExcelUtils.write(response, "报价轮次流水.xls", "数据", CrmSaleQuotationRevisionRespVO.class, list);
    }
    @GetMapping("/bindable-list")
    @Operation(summary = "获得客户下可绑定投标的报价单列表")
    @Parameter(name = "customerId", description = "客户编号", required = true)
    @Parameter(name = "bidBondId", description = "当前投标保证金编号(编辑时用于保留自身绑定)", required = false)
    @PreAuthorize("@ss.hasPermission('crm:sale-quotation:query')")
    public CommonResult<List<CrmSaleQuotationOptionRespVO>> getBindableList(
            @RequestParam("customerId") Long customerId,
            @RequestParam(value = "bidBondId", required = false) Long bidBondId) {
        return success(BeanUtils.toBean(
                saleQuotationService.getBindableQuotationList(customerId, bidBondId),
                CrmSaleQuotationOptionRespVO.class));
    }
    // ==================== æž„建响应 VO ====================
    private CrmSaleQuotationRespVO buildSaleQuotationRespVO(CrmSaleQuotationDO saleQuotation, List<CrmSaleQuotationProductDO> productList) {
@@ -276,6 +334,13 @@
        }
        // 3. å¡«å……关联信息
        fillSaleQuotationRelatedFields(respVO);
        // 3.1 å¡«å……关联投标的中标结果
        if (saleQuotation.getBidBondId() != null) {
            CrmBidBondDO bidBond = bidBondMapper.selectById(saleQuotation.getBidBondId());
            if (bidBond != null) {
                respVO.setBidResult(bidBond.getBidResult());
            }
        }
        // 4. å¡«å……附件列表
        respVO.setAttachmentList(storageAttachmentApi.listAttachments("crm_sale_quotation", saleQuotation.getId()));
        return respVO;
@@ -312,6 +377,11 @@
        // 1.6 å•†æœºä¿¡æ¯ï¼ˆè¿‡æ»¤ null,businessId ä¸ºå¯é€‰å­—段)
        Map<Long, CrmBusinessDO> businessMap = businessService.getBusinessMap(
                convertSet(pageResult.getList(), CrmSaleQuotationDO::getBusinessId, vo -> vo.getBusinessId() != null));
        // 1.7 æŠ•标信息(过滤 null,bidBondId ä¸ºå¯é€‰å­—段)
        Set<Long> bidBondIds = convertSet(pageResult.getList(), CrmSaleQuotationDO::getBidBondId,
                vo -> vo.getBidBondId() != null);
        Map<Long, CrmBidBondDO> bidBondMap = bidBondIds.isEmpty() ? Map.of()
                : convertMap(bidBondMapper.selectByIds(bidBondIds), CrmBidBondDO::getId);
        // 2. å¼€å§‹æ‹¼æŽ¥
        return BeanUtils.toBean(pageResult, CrmSaleQuotationRespVO.class, vo -> {
@@ -332,6 +402,7 @@
            MapUtils.findAndThen(userMap, vo.getOwnerUserId(), user -> vo.setOwnerUserName(user.getNickname()));
            MapUtils.findAndThen(deptMap, vo.getOwnerUserId(), dept -> vo.setOwnerUserDeptName(dept.getName()));
            MapUtils.findAndThen(businessMap, vo.getBusinessId(), business -> vo.setBusinessName(business.getName()));
            MapUtils.findAndThen(bidBondMap, vo.getBidBondId(), bidBond -> vo.setBidResult(bidBond.getBidResult()));
        });
    }
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/quotation/vo/CrmSaleQuotationOptionRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,30 @@
package cn.iocoder.yudao.module.crm.controller.admin.quotation.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
@Schema(description = "管理后台 - CRM é”€å”®æŠ¥ä»·å•下拉选项 Response VO")
@Data
public class CrmSaleQuotationOptionRespVO {
    @Schema(description = "报价单编号", example = "1024")
    private Long id;
    @Schema(description = "报价单号", example = "BJ202611050001")
    private String no;
    @Schema(description = "报价单名称", example = "某某医院设备报价")
    private String name;
    @Schema(description = "报价总金额", example = "100000.00")
    private BigDecimal totalPrice;
    @Schema(description = "报价状态:0 è‰ç¨¿ã€2 å®¡æ‰¹é€šè¿‡ã€3 å®¡æ‰¹ä¸é€šè¿‡", example = "2")
    private Integer status;
    @Schema(description = "已提交报价轮次", example = "2")
    private Integer revisionNo;
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/quotation/vo/CrmSaleQuotationPageReqVO.java
@@ -35,4 +35,7 @@
    @InEnum(CrmSaleQuotationStatusEnum.class)
    private Integer status;
    @Schema(description = "关联投标单号", example = "BZ202611050001")
    private String bidBondNo;
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/quotation/vo/CrmSaleQuotationRespVO.java
@@ -104,6 +104,35 @@
    @ExcelProperty("关联合同单号")
    private String contractNo;
    @Schema(description = "是否需要投标", example = "true")
    private Boolean needBid;
    @Schema(description = "关联投标保证金编号", example = "2048")
    private Long bidBondId;
    @Schema(description = "关联投标保证金单号", example = "BZ202611050001")
    @ExcelProperty("关联投标单号")
    private String bidBondNo;
    @Schema(description = "关联投标的中标结果:0 å¾…开标、1 å·²ä¸­æ ‡ã€2 æœªä¸­æ ‡", example = "1")
    private Integer bidResult;
    @Schema(description = "已提交报价轮次", example = "2")
    @ExcelProperty("报价轮次")
    private Integer revisionNo;
    @Schema(description = "作废类型:1 æ‰‹åŠ¨ä½œåºŸã€2 è¶…期作废", example = "1")
    private Integer voidType;
    @Schema(description = "作废原因")
    @ExcelProperty("作废原因")
    private String voidReason;
    @Schema(description = "作废时间")
    @ExcelProperty("作废时间")
    private LocalDateTime voidTime;
    @Schema(description = "作废人用户编号", example = "25682")
    private Long voidBy;
    @Schema(description = "作废人名字", example = "张三")
    @ExcelProperty("作废人")
    private String voidByName;
    @Schema(description = "附件列表")
    private List<StorageBlobRespDTO> attachmentList;
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/quotation/vo/CrmSaleQuotationRevisionRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,90 @@
package cn.iocoder.yudao.module.crm.controller.admin.quotation.vo;
import cn.idev.excel.annotation.ExcelIgnoreUnannotated;
import cn.idev.excel.annotation.ExcelProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Schema(description = "管理后台 - CRM é”€å”®æŠ¥ä»·è½®æ¬¡æµæ°´ Response VO")
@Data
@ExcelIgnoreUnannotated
public class CrmSaleQuotationRevisionRespVO {
    @Schema(description = "流水编号", example = "1024")
    private Long id;
    @Schema(description = "报价单编号", example = "1024")
    private Long quotationId;
    @ExcelProperty("报价单号")
    @Schema(description = "报价单号", example = "BJ202611050001")
    private String quotationNo;
    @ExcelProperty("报价轮次")
    @Schema(description = "报价轮次,从 1 å¼€å§‹", example = "2")
    private Integer revisionNo;
    @ExcelProperty("关联投标单号")
    @Schema(description = "当时绑定的投标单号", example = "BZ202611050001")
    private String bidBondNo;
    @Schema(description = "客户编号", example = "1024")
    private Long customerId;
    @ExcelProperty("报价单名称")
    @Schema(description = "报价单名称快照")
    private String name;
    @ExcelProperty("报价日期")
    @Schema(description = "报价日期")
    private LocalDateTime quotationTime;
    @ExcelProperty("有效期至")
    @Schema(description = "有效期至")
    private LocalDateTime validUntil;
    @ExcelProperty("产品总金额")
    @Schema(description = "产品总金额", example = "19510")
    private BigDecimal totalProductPrice;
    @ExcelProperty("整单折扣")
    @Schema(description = "整单折扣", example = "10.00")
    private BigDecimal discountPercent;
    @ExcelProperty("优惠金额")
    @Schema(description = "优惠金额", example = "1951")
    private BigDecimal discountPrice;
    @ExcelProperty("报价总金额")
    @Schema(description = "报价总金额", example = "17559")
    private BigDecimal totalPrice;
    @ExcelProperty("税率")
    @Schema(description = "税率", example = "13.00")
    private BigDecimal taxRate;
    @ExcelProperty("税额")
    @Schema(description = "税额", example = "2282")
    private BigDecimal taxPrice;
    @ExcelProperty("提交人编号")
    @Schema(description = "提交人用户编号", example = "25682")
    private Long submitUserId;
    @ExcelProperty("提交时间")
    @Schema(description = "提交时间")
    private LocalDateTime submitTime;
    @Schema(description = "明细 JSON å¿«ç…§")
    private String itemsSnapshot;
    @Schema(description = "提交时状态", example = "1")
    private Integer status;
    @Schema(description = "流程实例编号")
    private String processInstanceId;
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/quotation/vo/CrmSaleQuotationSaveReqVO.java
@@ -58,6 +58,9 @@
    @Schema(description = "税率", example = "13.00")
    private BigDecimal taxRate;
    @Schema(description = "是否需要投标:1需要 0不需要,不传默认需要", example = "true")
    private Boolean needBid;
    @Schema(description = "附件blobId列表", example = "[1, 2, 3]")
    private List<Long> blobIds;
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/bidbond/CrmBidBondDO.java
@@ -49,6 +49,21 @@
    private String tenderNo;
    /**
     * å…³è”报价单编号
     */
    private Long quotationId;
    /**
     * å…³è”报价单号快照
     */
    private String quotationNo;
    /**
     * å½“前报价轮次
     */
    private Integer quotationRound;
    /**
     * å…³è”销售订单 ID(销售台账)
     */
    private Long saleOrderId;
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/quotation/CrmSaleQuotationDO.java
@@ -124,6 +124,44 @@
     * å…³è”合同单号
     */
    private String contractNo;
    /**
     * æ˜¯å¦éœ€è¦æŠ•æ ‡
     *
     * éœ€è¦æŠ•标的报价单,必须绑定投标且投标已中标后才能转合同
     */
    private Boolean needBid;
    /**
     * å…³è”投标(保证金)编号
     */
    private Long bidBondId;
    /**
     * å…³è”投标单号快照
     */
    private String bidBondNo;
    /**
     * å·²æäº¤æŠ¥ä»·è½®æ¬¡
     */
    private Integer revisionNo;
    /**
     * ä½œåºŸç±»åž‹
     *
     * æžšä¸¾ {@link cn.iocoder.yudao.module.crm.enums.quotation.CrmSaleQuotationVoidTypeEnum}
     */
    private Integer voidType;
    /**
     * ä½œåºŸåŽŸå› 
     */
    private String voidReason;
    /**
     * ä½œåºŸæ—¶é—´
     */
    private LocalDateTime voidTime;
    /**
     * ä½œåºŸäººç”¨æˆ·ç¼–号
     */
    private Long voidBy;
    /**
     * å¤‡æ³¨
     */
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/quotation/CrmSaleQuotationRevisionDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,110 @@
package cn.iocoder.yudao.module.crm.dal.dataobject.quotation;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
 * CRM é”€å”®æŠ¥ä»·è½®æ¬¡æµæ°´ DO
 *
 * æ¯è½®æäº¤å®¡æ‰¹æ—¶è®°å½•一份报价快照,用于二次/三次报价的历史追溯。
 */
@TableName("crm_sale_quotation_revision")
@KeySequence("crm_sale_quotation_revision_seq")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CrmSaleQuotationRevisionDO extends BaseDO {
    @TableId
    private Long id;
    /**
     * æŠ¥ä»·å•编号
     */
    private Long quotationId;
    /**
     * æŠ¥ä»·å•号快照
     */
    private String quotationNo;
    /**
     * æŠ¥ä»·è½®æ¬¡ï¼Œä»Ž 1 å¼€å§‹
     */
    private Integer revisionNo;
    /**
     * å½“时绑定的投标编号
     */
    private Long bidBondId;
    /**
     * å½“时绑定的投标单号
     */
    private String bidBondNo;
    /**
     * å®¢æˆ·ç¼–号
     */
    private Long customerId;
    /**
     * æŠ¥ä»·å•名称快照
     */
    private String name;
    /**
     * æŠ¥ä»·æ—¥æœŸ
     */
    private LocalDateTime quotationTime;
    /**
     * æœ‰æ•ˆæœŸè‡³
     */
    private LocalDateTime validUntil;
    /**
     * äº§å“æ€»é‡‘额
     */
    private BigDecimal totalProductPrice;
    /**
     * æ•´å•折扣
     */
    private BigDecimal discountPercent;
    /**
     * ä¼˜æƒ é‡‘额
     */
    private BigDecimal discountPrice;
    /**
     * æŠ¥ä»·æ€»é‡‘额
     */
    private BigDecimal totalPrice;
    /**
     * ç¨Žçއ
     */
    private BigDecimal taxRate;
    /**
     * ç¨Žé¢
     */
    private BigDecimal taxPrice;
    /**
     * æ˜Žç»† JSON å¿«ç…§
     */
    private String itemsSnapshot;
    /**
     * æäº¤æ—¶æŠ¥ä»·çŠ¶æ€
     */
    private Integer status;
    /**
     * æµç¨‹å®žä¾‹ç¼–号
     */
    private String processInstanceId;
    /**
     * æäº¤äººç”¨æˆ·ç¼–号
     */
    private Long submitUserId;
    /**
     * æäº¤æ—¶é—´
     */
    private LocalDateTime submitTime;
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/bidbond/CrmBidBondMapper.java
@@ -20,6 +20,7 @@
                .eqIfPresent(CrmBidBondDO::getCustomerId, reqVO.getCustomerId())
                .likeIfPresent(CrmBidBondDO::getProjectName, reqVO.getProjectName())
                .likeIfPresent(CrmBidBondDO::getTenderNo, reqVO.getTenderNo())
                .likeIfPresent(CrmBidBondDO::getQuotationNo, reqVO.getQuotationNo())
                .likeIfPresent(CrmBidBondDO::getSaleOrderNo, reqVO.getSaleOrderNo())
                .eqIfPresent(CrmBidBondDO::getBidResult, reqVO.getBidResult())
                .eqIfPresent(CrmBidBondDO::getRefundStatus, reqVO.getRefundStatus())
@@ -34,6 +35,10 @@
    default List<CrmBidBondDO> selectListByCustomerId(Long customerId) {
        return selectList(CrmBidBondDO::getCustomerId, customerId);
    }
    default CrmBidBondDO selectByQuotationId(Long quotationId) {
        return selectOne(CrmBidBondDO::getQuotationId, quotationId);
    }
    default List<CrmBidBondDO> selectListBySaleOrderIds(Collection<Long> saleOrderIds) {
@@ -52,6 +57,9 @@
                .eq(CrmBidBondDO::getId, updateObj.getId())
                .set(CrmBidBondDO::getSaleOrderId, updateObj.getSaleOrderId())
                .set(CrmBidBondDO::getSaleOrderNo, updateObj.getSaleOrderNo())
                .set(CrmBidBondDO::getQuotationId, updateObj.getQuotationId())
                .set(CrmBidBondDO::getQuotationNo, updateObj.getQuotationNo())
                .set(CrmBidBondDO::getQuotationRound, updateObj.getQuotationRound())
                .set(CrmBidBondDO::getRefundAmount, updateObj.getRefundAmount())
                .set(CrmBidBondDO::getRefundDate, updateObj.getRefundDate()));
    }
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/quotation/CrmSaleQuotationMapper.java
@@ -15,6 +15,7 @@
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import java.util.Collection;
import java.util.List;
/**
@@ -59,6 +60,7 @@
                .eqIfPresent(CrmSaleQuotationDO::getCustomerId, pageReqVO.getCustomerId())
                .eqIfPresent(CrmSaleQuotationDO::getBusinessId, pageReqVO.getBusinessId())
                .eqIfPresent(CrmSaleQuotationDO::getStatus, pageReqVO.getStatus())
                .likeIfPresent(CrmSaleQuotationDO::getBidBondNo, pageReqVO.getBidBondNo())
                .orderByDesc(CrmSaleQuotationDO::getId);
        // å®¢æˆ·æƒé™è¿‡æ»¤
@@ -92,6 +94,14 @@
        return selectList(CrmSaleQuotationDO::getCustomerId, customerId);
    }
    default List<CrmSaleQuotationDO> selectListByCustomerIdAndStatuses(Long customerId,
                                                                      Collection<Integer> statuses) {
        return selectList(new LambdaQueryWrapperX<CrmSaleQuotationDO>()
                .eq(CrmSaleQuotationDO::getCustomerId, customerId)
                .in(CrmSaleQuotationDO::getStatus, statuses)
                .orderByDesc(CrmSaleQuotationDO::getId));
    }
    default List<CrmSaleQuotationDO> selectListByBusinessId(Long businessId) {
        return selectList(CrmSaleQuotationDO::getBusinessId, businessId);
    }
@@ -117,4 +127,24 @@
                .set(CrmSaleQuotationDO::getContractNo, null));
    }
    /**
     * ç»‘定投标;bidBondId ä¸º null æ—¶è¡¨ç¤ºè§£ç»‘
     */
    default void bindBidBond(Long quotationId, Long bidBondId, String bidBondNo) {
        update(new LambdaUpdateWrapper<CrmSaleQuotationDO>()
                .eq(CrmSaleQuotationDO::getId, quotationId)
                .set(CrmSaleQuotationDO::getBidBondId, bidBondId)
                .set(CrmSaleQuotationDO::getBidBondNo, bidBondNo));
    }
    /**
     * é‡æ–°æŠ¥ä»·ï¼šé‡ç½®ä¸ºè‰ç¨¿å¹¶æ¸…空流程实例
     */
    default void resetToDraft(Long quotationId) {
        update(new LambdaUpdateWrapper<CrmSaleQuotationDO>()
                .eq(CrmSaleQuotationDO::getId, quotationId)
                .set(CrmSaleQuotationDO::getStatus, CrmSaleQuotationStatusEnum.DRAFT.getStatus())
                .set(CrmSaleQuotationDO::getProcessInstanceId, null));
    }
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/quotation/CrmSaleQuotationRevisionMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,25 @@
package cn.iocoder.yudao.module.crm.dal.mysql.quotation;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.crm.dal.dataobject.quotation.CrmSaleQuotationRevisionDO;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
@Mapper
public interface CrmSaleQuotationRevisionMapper extends BaseMapperX<CrmSaleQuotationRevisionDO> {
    default List<CrmSaleQuotationRevisionDO> selectListByQuotationId(Long quotationId) {
        return selectList(new LambdaQueryWrapperX<CrmSaleQuotationRevisionDO>()
                .eq(CrmSaleQuotationRevisionDO::getQuotationId, quotationId)
                .orderByAsc(CrmSaleQuotationRevisionDO::getRevisionNo));
    }
    default CrmSaleQuotationRevisionDO selectByQuotationIdAndRevisionNo(Long quotationId, Integer revisionNo) {
        return selectOne(new LambdaQueryWrapperX<CrmSaleQuotationRevisionDO>()
                .eq(CrmSaleQuotationRevisionDO::getQuotationId, quotationId)
                .eq(CrmSaleQuotationRevisionDO::getRevisionNo, revisionNo));
    }
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/enums/ErrorCodeConstants.java
@@ -133,6 +133,17 @@
    ErrorCode SALE_QUOTATION_UPDATE_AUDIT_STATUS_FAIL_NOT_PROCESS = new ErrorCode(1_020_015_008, "销售报价单({})不处于审批中,无法更新审批结果");
    ErrorCode SALE_QUOTATION_BPM_PROCESS_DEFINITION_NOT_EXISTS = new ErrorCode(1_020_015_009, "销售报价审批流程未配置,请先在流程管理中设计并启用「销售报价审批」流程");
    ErrorCode SALE_QUOTATION_EXPORT_FAIL_NOT_APPROVE = new ErrorCode(1_020_015_010, "销售报价单({})当前状态为[{}],只有审批通过后才能导出带章报价单");
    ErrorCode SALE_QUOTATION_VOID_REASON_REQUIRED = new ErrorCode(1_020_015_011, "作废原因不能为空,请填写作废说明");
    ErrorCode SALE_QUOTATION_VOID_FAIL_STATUS = new ErrorCode(1_020_015_012, "销售报价单({})当前状态为[{}],不能作废");
    ErrorCode SALE_QUOTATION_VOID_FAIL_WON = new ErrorCode(1_020_015_013, "销售报价单({})已中标,可继续转合同,不能作废;如需终止请先处理投标结果");
    ErrorCode SALE_QUOTATION_EXPIRED = new ErrorCode(1_020_015_014, "销售报价单({})有效期至[{}]已过,已自动作废,请重新报价后再提交");
    ErrorCode SALE_QUOTATION_REQUOTE_FAIL_NOT_LOST = new ErrorCode(1_020_015_015, "销售报价单({})当前状态为[{}],只有绑定投标且投标结果为「未中标」时才可修改原报价单重新报价");
    ErrorCode SALE_QUOTATION_REVISION_EXISTS = new ErrorCode(1_020_015_016, "销售报价单({})第[{}]轮报价记录已存在,请刷新后重试");
    ErrorCode SALE_QUOTATION_CONVERT_FAIL_BID_NOT_WON = new ErrorCode(1_020_015_017, "销售报价单({})关联的投标[{}]当前结果为[{}],只有投标已中标后才能转合同");
    ErrorCode SALE_QUOTATION_CONVERT_FAIL_BID_QUOTATION_MISMATCH = new ErrorCode(1_020_015_018, "销售报价单({})不是投标[{}]当前绑定的报价单,无法转合同;请使用投标最新绑定的报价单");
    ErrorCode SALE_QUOTATION_VOIDED = new ErrorCode(1_020_015_019, "销售报价单({})已作废,原因:[{}],不能继续操作;如需继续请重新报价");
    ErrorCode SALE_QUOTATION_DELETE_FAIL_BOUND = new ErrorCode(1_020_015_020, "销售报价单({})已绑定投标[{}],不能删除;请先在投标保证金中解除关联");
    ErrorCode SALE_QUOTATION_CONVERT_FAIL_BID_REQUIRED = new ErrorCode(1_020_015_021, "销售报价单({})标记为需要投标,但尚未关联投标;请先在投标保证金中绑定该报价单、完成投标并登记中标结果后再转合同,或将报价单改为不需要投标");
    // ========== å¼€ç¥¨ 1_020_016_000 ==========
    ErrorCode INVOICE_NOT_EXISTS = new ErrorCode(1_020_016_000, "开票不存在");
@@ -183,6 +194,12 @@
    ErrorCode BID_BOND_SALE_ORDER_NOT_APPROVE = new ErrorCode(1_020_018_012, "关联销售订单不是已审核状态");
    ErrorCode BID_BOND_SALE_ORDER_CUSTOMER_MISMATCH = new ErrorCode(1_020_018_013, "关联销售订单与保证金客户不一致");
    ErrorCode BID_BOND_LOST_CANNOT_LINK_ORDER = new ErrorCode(1_020_018_014, "未中标时不能关联销售订单");
    ErrorCode BID_BOND_QUOTATION_NOT_EXISTS = new ErrorCode(1_020_018_015, "关联报价单不存在");
    ErrorCode BID_BOND_QUOTATION_CUSTOMER_MISMATCH = new ErrorCode(1_020_018_016, "关联报价单与保证金客户不一致");
    ErrorCode BID_BOND_QUOTATION_STATUS_INVALID = new ErrorCode(1_020_018_017, "关联报价单状态不允许绑定投标");
    ErrorCode BID_BOND_QUOTATION_ALREADY_BOUND = new ErrorCode(1_020_018_018, "关联报价单已绑定其他投标");
    ErrorCode BID_BOND_WON_QUOTATION_REQUIRED = new ErrorCode(1_020_018_019, "中标结果选择「已中标」时必须关联报价单");
    ErrorCode BID_BOND_DELETE_FAIL_WON = new ErrorCode(1_020_018_020, "投标保证金已中标且关联报价单,不能删除;请先处理关联关系或投标结果");
    ErrorCode CRM_SALES_TARGET_NOT_EXISTS = new ErrorCode(1_020_017_000, "销售目标不存在");
    ErrorCode CRM_SALES_TARGET_PERIOD_VALUE_INVALID = new ErrorCode(1_020_017_001, "周期值【{}】格式不正确,应为 {}");
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/enums/quotation/CrmSaleQuotationStatusEnum.java
@@ -20,6 +20,7 @@
    APPROVE(2, "审批通过"),
    REJECT(3, "审批不通过"),
    CANCEL(4, "已取消"),
    VOID(5, "已作废"),
    CONVERTED(10, "已转合同");
    private final Integer status;
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/enums/quotation/CrmSaleQuotationVoidTypeEnum.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,28 @@
package cn.iocoder.yudao.module.crm.enums.quotation;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import java.util.Arrays;
/**
 * CRM é”€å”®æŠ¥ä»·ä½œåºŸç±»åž‹æžšä¸¾
 */
@RequiredArgsConstructor
@Getter
public enum CrmSaleQuotationVoidTypeEnum {
    MANUAL(1, "手动作废"),
    EXPIRED(2, "超期作废");
    private final Integer type;
    private final String name;
    public static CrmSaleQuotationVoidTypeEnum valueOf(Integer type) {
        return Arrays.stream(values())
                .filter(item -> item.getType().equals(type))
                .findFirst()
                .orElse(null);
    }
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/bidbond/CrmBidBondServiceImpl.java
@@ -11,10 +11,13 @@
import cn.iocoder.yudao.module.crm.controller.admin.bidbond.vo.CrmBidBondSaveReqVO;
import cn.iocoder.yudao.module.crm.dal.dataobject.bidbond.CrmBidBondDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.customer.CrmCustomerDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.quotation.CrmSaleQuotationDO;
import cn.iocoder.yudao.module.crm.dal.mysql.bidbond.CrmBidBondMapper;
import cn.iocoder.yudao.module.crm.dal.mysql.quotation.CrmSaleQuotationMapper;
import cn.iocoder.yudao.module.crm.enums.ErrorCodeConstants;
import cn.iocoder.yudao.module.crm.enums.bidbond.CrmBidBondRefundStatusEnum;
import cn.iocoder.yudao.module.crm.enums.bidbond.CrmBidResultEnum;
import cn.iocoder.yudao.module.crm.enums.quotation.CrmSaleQuotationStatusEnum;
import cn.iocoder.yudao.module.crm.service.customer.CrmCustomerService;
import cn.iocoder.yudao.module.erp.api.sale.ErpSaleOrderApi;
import cn.iocoder.yudao.module.erp.api.sale.dto.ErpSaleOrderRespDTO;
@@ -48,6 +51,8 @@
    @Resource
    private CrmBidBondMapper bidBondMapper;
    @Resource
    private CrmSaleQuotationMapper saleQuotationMapper;
    @Resource
    private CrmCustomerService customerService;
    @Resource
    private ErpSaleOrderApi erpSaleOrderApi;
@@ -59,23 +64,42 @@
        bond.setId(null);
        bond.setNo(generateBondNo());
        bidBondMapper.insert(bond);
        // å›žå†™æŠ¥ä»·å•绑定关系
        if (bond.getQuotationId() != null) {
            saleQuotationMapper.bindBidBond(bond.getQuotationId(), bond.getId(), bond.getNo());
        }
        return bond.getId();
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void updateBidBond(CrmBidBondSaveReqVO updateReqVO) {
        validateBidBondExists(updateReqVO.getId());
        CrmBidBondDO oldBond = validateBidBondExists(updateReqVO.getId());
        CrmBidBondDO updateObj = validateAndBuild(updateReqVO);
        updateObj.setId(updateReqVO.getId());
        // å•号不允许修改,保持原值
        updateObj.setNo(null);
        bidBondMapper.updateByIdIncludingNulls(updateObj);
        // æŠ¥ä»·å•变化时:先解绑旧报价单,再绑定新报价单
        if (oldBond.getQuotationId() != null && !oldBond.getQuotationId().equals(updateObj.getQuotationId())) {
            saleQuotationMapper.bindBidBond(oldBond.getQuotationId(), null, null);
        }
        if (updateObj.getQuotationId() != null && !updateObj.getQuotationId().equals(oldBond.getQuotationId())) {
            saleQuotationMapper.bindBidBond(updateObj.getQuotationId(), updateObj.getId(), oldBond.getNo());
        }
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void deleteBidBond(Long id) {
        validateBidBondExists(id);
        CrmBidBondDO bond = validateBidBondExists(id);
        if (CrmBidResultEnum.WON.getResult().equals(bond.getBidResult()) && bond.getQuotationId() != null) {
            throw ServiceExceptionUtil.exception(ErrorCodeConstants.BID_BOND_DELETE_FAIL_WON);
        }
        // è§£é™¤æŠ¥ä»·å•绑定后再删除
        if (bond.getQuotationId() != null) {
            saleQuotationMapper.bindBidBond(bond.getQuotationId(), null, null);
        }
        bidBondMapper.deleteById(id);
    }
@@ -177,7 +201,10 @@
            saleOrderNo = order.getNo();
        }
        // 5. é€€å›žçŠ¶æ€ä¸Žé‡‘é¢
        // 5. å…³è”报价单
        CrmSaleQuotationDO quotation = validateQuotation(reqVO, bidResult);
        // 6. é€€å›žçŠ¶æ€ä¸Žé‡‘é¢
        Integer refundStatus = resolveRefundStatus(reqVO.getRefundStatus(), bidResult);
        validateRefund(reqVO, bondAmount, refundStatus);
@@ -186,7 +213,53 @@
        bond.setBidResult(bidResult);
        bond.setRefundStatus(refundStatus);
        bond.setSaleOrderNo(saleOrderNo);
        bond.setQuotationNo(quotation != null ? quotation.getNo() : null);
        bond.setQuotationRound(quotation != null && quotation.getRevisionNo() != null
                ? quotation.getRevisionNo() : 0);
        return bond;
    }
    /**
     * æ ¡éªŒå¹¶åŠ è½½å…³è”æŠ¥ä»·å•
     */
    private CrmSaleQuotationDO validateQuotation(CrmBidBondSaveReqVO reqVO, Integer bidResult) {
        // ä¸­æ ‡æ—¶æŠ¥ä»·å•必填,保证中标后可以据此转合同
        if (CrmBidResultEnum.WON.getResult().equals(bidResult) && reqVO.getQuotationId() == null) {
            throw new ServiceException(ErrorCodeConstants.BID_BOND_WON_QUOTATION_REQUIRED.getCode(),
                    "中标结果为【已中标】时必须关联报价单,否则无法凭该报价单转合同。"
                            + "请选择本次中标的销售报价单后重新保存。");
        }
        if (reqVO.getQuotationId() == null) {
            return null;
        }
        CrmSaleQuotationDO quotation = saleQuotationMapper.selectById(reqVO.getQuotationId());
        if (quotation == null) {
            throw new ServiceException(ErrorCodeConstants.BID_BOND_QUOTATION_NOT_EXISTS.getCode(),
                    "关联报价单(ID=" + reqVO.getQuotationId() + ")不存在,请刷新报价单列表后重新选择。");
        }
        if (!Objects.equals(reqVO.getCustomerId(), quotation.getCustomerId())) {
            throw new ServiceException(ErrorCodeConstants.BID_BOND_QUOTATION_CUSTOMER_MISMATCH.getCode(),
                    "关联报价单[" + quotation.getNo() + "]的客户(ID=" + quotation.getCustomerId()
                            + ")与保证金客户(ID=" + reqVO.getCustomerId()
                            + ")不一致。请改选该客户名下的报价单,或先调整保证金客户。");
        }
        if (!Set.of(CrmSaleQuotationStatusEnum.DRAFT.getStatus(),
                CrmSaleQuotationStatusEnum.APPROVE.getStatus(),
                CrmSaleQuotationStatusEnum.REJECT.getStatus()).contains(quotation.getStatus())) {
            throw new ServiceException(ErrorCodeConstants.BID_BOND_QUOTATION_STATUS_INVALID.getCode(),
                    "关联报价单[" + quotation.getNo() + "]当前状态为【"
                            + getQuotationStatusName(quotation.getStatus())
                            + "】,只有「草稿、审批通过、审批不通过」的报价单可以绑定投标。"
                            + "请改选其他报价单,或先重新报价后再绑定。");
        }
        if (quotation.getBidBondId() != null && !Objects.equals(quotation.getBidBondId(), reqVO.getId())) {
            CrmBidBondDO boundBond = bidBondMapper.selectById(quotation.getBidBondId());
            throw new ServiceException(ErrorCodeConstants.BID_BOND_QUOTATION_ALREADY_BOUND.getCode(),
                    "关联报价单[" + quotation.getNo() + "]已绑定投标["
                            + (boundBond != null ? boundBond.getNo() : "ID=" + quotation.getBidBondId())
                            + "],同一报价单只能绑定一张投标。请改选其他报价单,或先在该投标中解除关联。");
        }
        return quotation;
    }
    private Integer resolveRefundStatus(Integer refundStatus, Integer bidResult) {
@@ -281,6 +354,7 @@
        Map<Long, CrmCustomerDO> customerMap = customerIds.isEmpty() ? Map.of()
                : customerService.getCustomerMap(customerIds);
        Map<Long, ErpSaleOrderRespDTO> orderMap = loadSaleOrderMap(bonds);
        Map<Long, CrmSaleQuotationDO> quotationMap = loadQuotationMap(bonds);
        return bonds.stream().map(bond -> {
            CrmBidBondRespVO respVO = BeanUtils.toBean(bond, CrmBidBondRespVO.class);
@@ -293,8 +367,25 @@
                respVO.setSaleOrderNo(order.getNo());
                respVO.setSaleOrderAmount(order.getTotalPrice());
            }
            CrmSaleQuotationDO quotation = bond.getQuotationId() == null ? null
                    : quotationMap.get(bond.getQuotationId());
            if (quotation != null) {
                respVO.setQuotationNo(quotation.getNo());
                respVO.setQuotationStatus(quotation.getStatus());
                respVO.setQuotationTotalPrice(quotation.getTotalPrice());
            }
            return respVO;
        }).collect(Collectors.toList());
    }
    private Map<Long, CrmSaleQuotationDO> loadQuotationMap(List<CrmBidBondDO> bonds) {
        Set<Long> quotationIds = bonds.stream().map(CrmBidBondDO::getQuotationId)
                .filter(Objects::nonNull).collect(Collectors.toSet());
        if (quotationIds.isEmpty()) {
            return Map.of();
        }
        return saleQuotationMapper.selectByIds(quotationIds).stream()
                .collect(Collectors.toMap(CrmSaleQuotationDO::getId, q -> q, (a, b) -> a));
    }
    /**
@@ -324,6 +415,11 @@
        return orderMap;
    }
    private String getQuotationStatusName(Integer status) {
        CrmSaleQuotationStatusEnum statusEnum = CrmSaleQuotationStatusEnum.getByStatus(status);
        return statusEnum != null ? statusEnum.getName() : "未知状态(" + status + ")";
    }
    private String getSaleOrderStatusName(Integer status) {
        if (status == null) {
            return "未知";
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/quotation/CrmSaleQuotationExpiryHelper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,155 @@
package cn.iocoder.yudao.module.crm.service.quotation;
import cn.hutool.core.collection.CollUtil;
import cn.iocoder.yudao.module.bpm.api.task.BpmProcessInstanceApi;
import cn.iocoder.yudao.module.crm.dal.dataobject.bidbond.CrmBidBondDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.quotation.CrmSaleQuotationDO;
import cn.iocoder.yudao.module.crm.dal.mysql.bidbond.CrmBidBondMapper;
import cn.iocoder.yudao.module.crm.dal.mysql.quotation.CrmSaleQuotationMapper;
import cn.iocoder.yudao.module.crm.enums.bidbond.CrmBidResultEnum;
import cn.iocoder.yudao.module.crm.enums.quotation.CrmSaleQuotationStatusEnum;
import cn.iocoder.yudao.module.crm.enums.quotation.CrmSaleQuotationVoidTypeEnum;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
/**
 * é”€å”®æŠ¥ä»·æœ‰æ•ˆæœŸåˆ¤å®šä¸ŽåŠ¨æ€ä½œåºŸ
 *
 * æŸ¥è¯¢/操作报价单时按 validUntil åŠ¨æ€åˆ¤æ–­æ˜¯å¦è¿‡æœŸï¼›è¿‡æœŸåŽè½åº“ä¸ºã€Œå·²ä½œåºŸã€ï¼Œ
 * ä¿è¯åˆ—表、详情与后续操作行为一致,不依赖定时任务。
 */
@Component
@Slf4j
public class CrmSaleQuotationExpiryHelper {
    private static final DateTimeFormatter REASON_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
    private static final Set<Integer> EXPIRABLE_STATUSES = Set.of(
            CrmSaleQuotationStatusEnum.DRAFT.getStatus(),
            CrmSaleQuotationStatusEnum.PROCESS.getStatus(),
            CrmSaleQuotationStatusEnum.APPROVE.getStatus(),
            CrmSaleQuotationStatusEnum.REJECT.getStatus());
    @Resource
    private CrmSaleQuotationMapper saleQuotationMapper;
    @Resource
    private CrmBidBondMapper bidBondMapper;
    @Resource
    private BpmProcessInstanceApi bpmProcessInstanceApi;
    /**
     * æ˜¯å¦å·²è¿‡æœ‰æ•ˆæœŸã€‚
     *
     * å‰ç«¯æ—¥æœŸæŽ§ä»¶åªé€‰åˆ°æ—¥ï¼ˆæ—¶é—´éƒ¨åˆ†ä¸º 00:00:00),此时按「当天结束前仍有效」处理。
     */
    public boolean isExpired(LocalDateTime validUntil) {
        if (validUntil == null) {
            return false;
        }
        if (LocalTime.MIDNIGHT.equals(validUntil.toLocalTime())) {
            return LocalDate.now().isAfter(validUntil.toLocalDate());
        }
        return LocalDateTime.now().isAfter(validUntil);
    }
    public void expireIfNeeded(CrmSaleQuotationDO quotation) {
        if (quotation == null) {
            return;
        }
        expireIfNeeded(List.of(quotation));
    }
    public void expireIfNeeded(List<CrmSaleQuotationDO> quotations) {
        if (CollUtil.isEmpty(quotations)) {
            return;
        }
        List<CrmSaleQuotationDO> candidates = quotations.stream()
                .filter(Objects::nonNull)
                .filter(q -> EXPIRABLE_STATUSES.contains(q.getStatus()))
                .filter(q -> isExpired(q.getValidUntil()))
                .collect(Collectors.toList());
        if (candidates.isEmpty()) {
            return;
        }
        Map<Long, CrmBidBondDO> bondMap = loadBondMap(candidates);
        Long userId = getLoginUserId();
        for (CrmSaleQuotationDO quotation : candidates) {
            if (isBoundBidInProgress(quotation, bondMap)) {
                continue;
            }
            doExpire(quotation, userId);
        }
    }
    private Map<Long, CrmBidBondDO> loadBondMap(List<CrmSaleQuotationDO> quotations) {
        Set<Long> bondIds = convertSet(quotations, CrmSaleQuotationDO::getBidBondId, Objects::nonNull);
        if (bondIds.isEmpty()) {
            return Map.of();
        }
        return bidBondMapper.selectByIds(bondIds).stream()
                .collect(Collectors.toMap(CrmBidBondDO::getId, Function.identity(), (a, b) -> a));
    }
    /**
     * æŠ•标进行中(待开标/已中标)的报价单不自动作废,避免正在参与投标的报价失效。
     */
    private boolean isBoundBidInProgress(CrmSaleQuotationDO quotation, Map<Long, CrmBidBondDO> bondMap) {
        if (quotation.getBidBondId() == null) {
            return false;
        }
        CrmBidBondDO bond = bondMap.get(quotation.getBidBondId());
        if (bond == null) {
            return false;
        }
        return CrmBidResultEnum.PENDING.getResult().equals(bond.getBidResult())
                || CrmBidResultEnum.WON.getResult().equals(bond.getBidResult());
    }
    private void doExpire(CrmSaleQuotationDO quotation, Long userId) {
        String reason = "报价单[" + quotation.getNo() + "]有效期至["
                + quotation.getValidUntil().format(REASON_TIME_FORMATTER) + "]已过,系统自动作废";
        LocalDateTime now = LocalDateTime.now();
        boolean wasProcessing = CrmSaleQuotationStatusEnum.PROCESS.getStatus().equals(quotation.getStatus());
        // å…ˆè½åº“为已作废,BPM æ’¤é”€å›žè°ƒåªä¼šå¤„理「审批中」状态,不会覆盖本状态
        saleQuotationMapper.updateById(new CrmSaleQuotationDO()
                .setId(quotation.getId())
                .setStatus(CrmSaleQuotationStatusEnum.VOID.getStatus())
                .setVoidType(CrmSaleQuotationVoidTypeEnum.EXPIRED.getType())
                .setVoidReason(reason)
                .setVoidTime(now));
        quotation.setStatus(CrmSaleQuotationStatusEnum.VOID.getStatus());
        quotation.setVoidType(CrmSaleQuotationVoidTypeEnum.EXPIRED.getType());
        quotation.setVoidReason(reason);
        quotation.setVoidTime(now);
        // å®¡æ‰¹ä¸­çš„æŠ¥ä»·å•需要同步撤销流程,避免审批人继续处理已失效的报价
        if (wasProcessing) {
            cancelProcessQuietly(quotation.getProcessInstanceId(), reason, userId);
        }
    }
    private void cancelProcessQuietly(String processInstanceId, String reason, Long userId) {
        if (processInstanceId == null || processInstanceId.isBlank()) {
            return;
        }
        try {
            bpmProcessInstanceApi.cancelProcessInstanceByAdmin(userId, processInstanceId, reason);
        } catch (Exception ex) {
            log.warn("[cancelProcessQuietly][报价单流程({})撤销失败,不影响自动作废]", processInstanceId, ex);
        }
    }
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/quotation/CrmSaleQuotationService.java
@@ -5,6 +5,7 @@
import cn.iocoder.yudao.module.crm.controller.admin.quotation.vo.CrmSaleQuotationSaveReqVO;
import cn.iocoder.yudao.module.crm.dal.dataobject.quotation.CrmSaleQuotationDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.quotation.CrmSaleQuotationProductDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.quotation.CrmSaleQuotationRevisionDO;
import java.util.Collection;
import java.util.List;
@@ -129,4 +130,29 @@
     * @param quotationId æŠ¥ä»·å•编号
     */
    void clearContractReference(Long quotationId);
    /**
     * æ‰‹åŠ¨ä½œåºŸæŠ¥ä»·å•
     *
     * @param id     æŠ¥ä»·å•编号
     * @param reason ä½œåºŸåŽŸå› 
     */
    void voidSaleQuotation(Long id, String reason);
    /**
     * èŽ·å¾—æŠ¥ä»·å•çš„è½®æ¬¡æµæ°´åˆ—è¡¨
     *
     * @param quotationId æŠ¥ä»·å•编号
     * @return è½®æ¬¡æµæ°´åˆ—表
     */
    List<CrmSaleQuotationRevisionDO> getSaleQuotationRevisionList(Long quotationId);
    /**
     * èŽ·å¾—å®¢æˆ·ä¸‹å¯ç»‘å®šæŠ•æ ‡çš„æŠ¥ä»·å•åˆ—è¡¨ï¼ˆè‰ç¨¿/审批通过/审批不通过,且未被其他投标绑定)
     *
     * @param customerId å®¢æˆ·ç¼–号
     * @param bidBondId  å½“前投标编号(编辑时用于保留自身已绑定的报价单)
     * @return æŠ¥ä»·å•列表
     */
    List<CrmSaleQuotationDO> getBindableQuotationList(Long customerId, Long bidBondId);
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/quotation/CrmSaleQuotationServiceImpl.java
@@ -3,7 +3,9 @@
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.ObjUtil;
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.json.JsonUtils;
import cn.iocoder.yudao.framework.common.util.number.MoneyUtils;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.bpm.api.task.BpmProcessInstanceApi;
@@ -15,15 +17,21 @@
import cn.iocoder.yudao.module.crm.controller.admin.contract.vo.contract.CrmContractSaveReqVO;
import cn.iocoder.yudao.module.crm.controller.admin.quotation.vo.CrmSaleQuotationPageReqVO;
import cn.iocoder.yudao.module.crm.controller.admin.quotation.vo.CrmSaleQuotationSaveReqVO;
import cn.iocoder.yudao.module.crm.dal.dataobject.bidbond.CrmBidBondDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.contract.CrmContractDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.quotation.CrmSaleQuotationDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.quotation.CrmSaleQuotationProductDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.quotation.CrmSaleQuotationRevisionDO;
import cn.iocoder.yudao.module.crm.dal.mysql.bidbond.CrmBidBondMapper;
import cn.iocoder.yudao.module.crm.dal.mysql.quotation.CrmSaleQuotationMapper;
import cn.iocoder.yudao.module.crm.dal.mysql.quotation.CrmSaleQuotationProductMapper;
import cn.iocoder.yudao.module.crm.dal.mysql.quotation.CrmSaleQuotationRevisionMapper;
import cn.iocoder.yudao.module.crm.dal.redis.no.CrmNoRedisDAO;
import cn.iocoder.yudao.module.crm.enums.bidbond.CrmBidResultEnum;
import cn.iocoder.yudao.module.crm.enums.common.CrmBizTypeEnum;
import cn.iocoder.yudao.module.crm.enums.permission.CrmPermissionLevelEnum;
import cn.iocoder.yudao.module.crm.enums.quotation.CrmSaleQuotationStatusEnum;
import cn.iocoder.yudao.module.crm.enums.quotation.CrmSaleQuotationVoidTypeEnum;
import cn.iocoder.yudao.module.crm.service.business.CrmBusinessService;
import cn.iocoder.yudao.module.crm.service.contact.CrmContactService;
import cn.iocoder.yudao.module.crm.service.contract.CrmContractService;
@@ -35,6 +43,7 @@
import cn.iocoder.yudao.module.mdm.api.item.MdmItemApi;
import cn.iocoder.yudao.module.mdm.api.item.dto.MdmItemRespDTO;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.flowable.engine.repository.ProcessDefinition;
@@ -43,10 +52,13 @@
import org.springframework.validation.annotation.Validated;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*;
import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
import static cn.iocoder.yudao.module.crm.enums.ErrorCodeConstants.*;
/**
@@ -64,10 +76,18 @@
     */
    private static final String SALE_QUOTATION_APPROVE_CATEGORY_CODE = "sale_quotation_approve";
    private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
    @Resource
    private CrmSaleQuotationMapper saleQuotationMapper;
    @Resource
    private CrmSaleQuotationProductMapper saleQuotationProductMapper;
    @Resource
    private CrmSaleQuotationRevisionMapper revisionMapper;
    @Resource
    private CrmBidBondMapper bidBondMapper;
    @Resource
    private CrmSaleQuotationExpiryHelper expiryHelper;
    @Resource
    private CrmNoRedisDAO noRedisDAO;
@@ -113,6 +133,7 @@
        // 2.1 æ’入报价单
        CrmSaleQuotationDO saleQuotation = BeanUtils.toBean(createReqVO, CrmSaleQuotationDO.class, q -> q
                .setNo(no)
                .setNeedBid(!Boolean.FALSE.equals(createReqVO.getNeedBid()))
                .setStatus(CrmSaleQuotationStatusEnum.DRAFT.getStatus()));
        calculateTotalPrice(saleQuotation, quotationProducts);
        saleQuotationMapper.insert(saleQuotation);
@@ -133,11 +154,18 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void updateSaleQuotation(CrmSaleQuotationSaveReqVO updateReqVO) {
        // 1.1 æ ¡éªŒå­˜åœ¨
        // 1.1 æ ¡éªŒå­˜åœ¨ï¼Œå¹¶å¤„理过期
        CrmSaleQuotationDO saleQuotation = validateSaleQuotationExists(updateReqVO.getId());
        // 1.2 æ ¡éªŒçŠ¶æ€ï¼Œåªæœ‰è‰ç¨¿çŠ¶æ€æ‰èƒ½ä¿®æ”¹
        expiryHelper.expireIfNeeded(saleQuotation);
        validateNotVoid(saleQuotation);
        // 1.2 æ ¡éªŒçŠ¶æ€ï¼šè‰ç¨¿å¯ç›´æŽ¥ä¿®æ”¹ï¼›ç»‘å®šæŠ•æ ‡ä¸”æŠ•æ ‡æœªä¸­æ ‡æ—¶ï¼Œå…è®¸ä¿®æ”¹å·²å®¡æ‰¹çš„æŠ¥ä»·å•é‡æ–°æŠ¥ä»·
        boolean requote = false;
        if (!CrmSaleQuotationStatusEnum.DRAFT.getStatus().equals(saleQuotation.getStatus())) {
            throw exception(SALE_QUOTATION_UPDATE_FAIL_NOT_DRAFT, saleQuotation.getNo());
            requote = canRequote(saleQuotation);
            if (!requote) {
                throw exception(SALE_QUOTATION_REQUOTE_FAIL_NOT_LOST, saleQuotation.getNo(),
                        getStatusName(saleQuotation.getStatus()));
            }
        }
        // 1.3 æ ¡éªŒç‰©æ–™é¡¹çš„æœ‰æ•ˆæ€§
        List<CrmSaleQuotationProductDO> quotationProducts = validateQuotationItems(updateReqVO.getItems());
@@ -155,7 +183,11 @@
        }
        calculateTotalPrice(updateObj, quotationProducts);
        saleQuotationMapper.updateById(updateObj);
        // 2.2 æ›´æ–°æŠ¥ä»·æ˜Žç»†
        // 2.2 é‡æ–°æŠ¥ä»·ï¼šé‡ç½®ä¸ºè‰ç¨¿å¹¶æ¸…空上一轮流程实例,等待重新提交
        if (requote) {
            saleQuotationMapper.resetToDraft(saleQuotation.getId());
        }
        // 2.3 æ›´æ–°æŠ¥ä»·æ˜Žç»†
        updateQuotationProductList(updateReqVO.getId(), quotationProducts);
        storageAttachmentApi.updateAttachments("file", "crm_sale_quotation", updateReqVO.getId(), updateReqVO.getBlobIds());
    }
@@ -171,6 +203,9 @@
        saleQuotations.forEach(saleQuotation -> {
            if (!CrmSaleQuotationStatusEnum.DRAFT.getStatus().equals(saleQuotation.getStatus())) {
                throw exception(SALE_QUOTATION_DELETE_FAIL_NOT_DRAFT, saleQuotation.getNo());
            }
            if (saleQuotation.getBidBondId() != null) {
                throw exception(SALE_QUOTATION_DELETE_FAIL_BOUND, saleQuotation.getNo(), saleQuotation.getBidBondNo());
            }
        });
@@ -190,11 +225,15 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void submitSaleQuotation(Long id, String processDefinitionKey, Long userId) {
        // 1. æ ¡éªŒæŠ¥ä»·å•存在且为草稿状态
        // 1. æ ¡éªŒæŠ¥ä»·å•存在且为草稿状态(先处理过期,避免提交已失效的报价)
        CrmSaleQuotationDO saleQuotation = validateSaleQuotationExists(id);
        expiryHelper.expireIfNeeded(saleQuotation);
        validateNotVoid(saleQuotation);
        if (ObjUtil.notEqual(saleQuotation.getStatus(), CrmSaleQuotationStatusEnum.DRAFT.getStatus())) {
            throw exception(SALE_QUOTATION_SUBMIT_FAIL_NOT_DRAFT, saleQuotation.getNo());
        }
        // 1.1 è®°å½•本轮报价流水(含报价快照与明细 JSON)
        recordRevision(saleQuotation, userId);
        // 2. å…¨å±€å…å®¡æ‰¹ï¼šç›´æŽ¥ç½®ä¸ºå®¡æ‰¹é€šè¿‡ï¼Œæ— éœ€æµç¨‹æ¨¡åž‹
        if (bpmProcessInstanceApi.isGlobalAutoApprove()) {
@@ -388,24 +427,32 @@
    @Override
    public CrmSaleQuotationDO getSaleQuotation(Long id) {
        return saleQuotationMapper.selectById(id);
        CrmSaleQuotationDO quotation = saleQuotationMapper.selectById(id);
        expiryHelper.expireIfNeeded(quotation);
        return quotation;
    }
    @Override
    public PageResult<CrmSaleQuotationDO> getSaleQuotationPage(CrmSaleQuotationPageReqVO pageReqVO, Long userId) {
        // èŽ·å–ç”¨æˆ·æœ‰æƒé™çš„å®¢æˆ· ID åˆ—表
        List<Long> permittedCustomerIds = customerApi.getPermittedCustomerIds();
        return saleQuotationMapper.selectPage(pageReqVO, userId, permittedCustomerIds);
        PageResult<CrmSaleQuotationDO> pageResult = saleQuotationMapper.selectPage(pageReqVO, userId, permittedCustomerIds);
        expiryHelper.expireIfNeeded(pageResult.getList());
        return pageResult;
    }
    @Override
    public PageResult<CrmSaleQuotationDO> getSaleQuotationPageByCustomerId(CrmSaleQuotationPageReqVO pageReqVO) {
        return saleQuotationMapper.selectPageByCustomerId(pageReqVO);
        PageResult<CrmSaleQuotationDO> pageResult = saleQuotationMapper.selectPageByCustomerId(pageReqVO);
        expiryHelper.expireIfNeeded(pageResult.getList());
        return pageResult;
    }
    @Override
    public PageResult<CrmSaleQuotationDO> getSaleQuotationPageByBusinessId(CrmSaleQuotationPageReqVO pageReqVO) {
        return saleQuotationMapper.selectPageByBusinessId(pageReqVO);
        PageResult<CrmSaleQuotationDO> pageResult = saleQuotationMapper.selectPageByBusinessId(pageReqVO);
        expiryHelper.expireIfNeeded(pageResult.getList());
        return pageResult;
    }
    @Override
@@ -424,15 +471,41 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long convertToContract(Long id, Long userId) {
        // 1. æ ¡éªŒæŠ¥ä»·å•存在,且为草稿或审批通过状态
        // 1. æ ¡éªŒæŠ¥ä»·å•存在,并处理过期
        CrmSaleQuotationDO saleQuotation = validateSaleQuotationExists(id);
        if (!CrmSaleQuotationStatusEnum.DRAFT.getStatus().equals(saleQuotation.getStatus())
                && !CrmSaleQuotationStatusEnum.APPROVE.getStatus().equals(saleQuotation.getStatus())) {
            CrmSaleQuotationStatusEnum current = CrmSaleQuotationStatusEnum.getByStatus(saleQuotation.getStatus());
            throw exception(SALE_QUOTATION_CONVERT_FAIL_NOT_DRAFT, saleQuotation.getNo(),
                    current != null ? current.getName() : String.valueOf(saleQuotation.getStatus()));
        expiryHelper.expireIfNeeded(saleQuotation);
        validateNotVoid(saleQuotation);
        // 1.1 éœ€è¦æŠ•标的报价单:必须先绑定投标
        boolean needBid = !Boolean.FALSE.equals(saleQuotation.getNeedBid());
        if (needBid && saleQuotation.getBidBondId() == null) {
            throw exception(SALE_QUOTATION_CONVERT_FAIL_BID_REQUIRED, saleQuotation.getNo());
        }
        // æ ¡éªŒæ˜¯å¦å·²è½¬åˆåŒ
        // 1.2 ç»‘定投标的报价单:必须已中标,且是该投标当前绑定的报价单
        if (saleQuotation.getBidBondId() != null) {
            CrmBidBondDO bidBond = bidBondMapper.selectById(saleQuotation.getBidBondId());
            if (bidBond == null) {
                throw new ServiceException(BID_BOND_NOT_EXISTS.getCode(),
                        "销售报价单[" + saleQuotation.getNo() + "]关联的投标(ID=" + saleQuotation.getBidBondId()
                                + ")不存在,无法转合同;请先在投标保证金中重新关联投标,或解除报价单绑定。");
            }
            if (!CrmBidResultEnum.WON.getResult().equals(bidBond.getBidResult())) {
                throw exception(SALE_QUOTATION_CONVERT_FAIL_BID_NOT_WON, saleQuotation.getNo(),
                        bidBond.getNo(), getBidResultName(bidBond.getBidResult()));
            }
            if (!saleQuotation.getId().equals(bidBond.getQuotationId())) {
                throw exception(SALE_QUOTATION_CONVERT_FAIL_BID_QUOTATION_MISMATCH,
                        saleQuotation.getNo(), bidBond.getNo());
            }
            if (!CrmSaleQuotationStatusEnum.APPROVE.getStatus().equals(saleQuotation.getStatus())) {
                throw exception(SALE_QUOTATION_CONVERT_FAIL_NOT_DRAFT, saleQuotation.getNo(),
                        getStatusName(saleQuotation.getStatus()));
            }
        } else if (!CrmSaleQuotationStatusEnum.DRAFT.getStatus().equals(saleQuotation.getStatus())
                && !CrmSaleQuotationStatusEnum.APPROVE.getStatus().equals(saleQuotation.getStatus())) {
            throw exception(SALE_QUOTATION_CONVERT_FAIL_NOT_DRAFT, saleQuotation.getNo(),
                    getStatusName(saleQuotation.getStatus()));
        }
        // 1.2 æ ¡éªŒæ˜¯å¦å·²è½¬åˆåŒ
        if (saleQuotation.getContractId() != null) {
            throw exception(SALE_QUOTATION_CONTRACT_EXISTS);
        }
@@ -484,4 +557,194 @@
        saleQuotationMapper.clearContractReference(quotationId);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void voidSaleQuotation(Long id, String reason) {
        // 1.1 æ ¡éªŒä½œåºŸåŽŸå› 
        String trimmedReason = StrUtil.trim(reason);
        if (StrUtil.isBlank(trimmedReason)) {
            throw exception(SALE_QUOTATION_VOID_REASON_REQUIRED);
        }
        if (trimmedReason.length() > 500) {
            throw new ServiceException(SALE_QUOTATION_VOID_REASON_REQUIRED.getCode(),
                    "作废原因不能超过 500 ä¸ªå­—符,当前为 " + trimmedReason.length() + " ä¸ªå­—符,请精简后重试");
        }
        // 1.2 æ ¡éªŒæŠ¥ä»·å•存在,并处理过期(已过期则按超期作废提示,不再走手动作废)
        CrmSaleQuotationDO saleQuotation = validateSaleQuotationExists(id);
        expiryHelper.expireIfNeeded(saleQuotation);
        validateNotVoid(saleQuotation);
        // 1.3 å·²ä¸­æ ‡çš„æŠ¥ä»·å•不允许作废,否则合同闭环断裂
        if (saleQuotation.getBidBondId() != null) {
            CrmBidBondDO bidBond = bidBondMapper.selectById(saleQuotation.getBidBondId());
            if (bidBond != null && CrmBidResultEnum.WON.getResult().equals(bidBond.getBidResult())) {
                throw exception(SALE_QUOTATION_VOID_FAIL_WON, saleQuotation.getNo());
            }
        }
        // 1.4 æ ¡éªŒå½“前状态是否允许作废
        boolean wasProcessing = CrmSaleQuotationStatusEnum.PROCESS.getStatus().equals(saleQuotation.getStatus());
        if (!wasProcessing
                && !CrmSaleQuotationStatusEnum.DRAFT.getStatus().equals(saleQuotation.getStatus())
                && !CrmSaleQuotationStatusEnum.APPROVE.getStatus().equals(saleQuotation.getStatus())
                && !CrmSaleQuotationStatusEnum.REJECT.getStatus().equals(saleQuotation.getStatus())) {
            throw exception(SALE_QUOTATION_VOID_FAIL_STATUS, saleQuotation.getNo(),
                    getStatusName(saleQuotation.getStatus()));
        }
        // 2.1 å…ˆè½åº“为已作废,BPM æ’¤é”€å›žè°ƒåªå¤„理审批中状态,不会覆盖本状态
        saleQuotationMapper.updateById(new CrmSaleQuotationDO()
                .setId(id)
                .setStatus(CrmSaleQuotationStatusEnum.VOID.getStatus())
                .setVoidType(CrmSaleQuotationVoidTypeEnum.MANUAL.getType())
                .setVoidReason(trimmedReason)
                .setVoidTime(LocalDateTime.now())
                .setVoidBy(getLoginUserId()));
        // 2.2 å®¡æ‰¹ä¸­çš„æŠ¥ä»·å•同步撤销流程,避免审批人继续处理已作废的报价
        if (wasProcessing) {
            cancelProcessQuietly(saleQuotation.getProcessInstanceId(), trimmedReason);
        }
    }
    @Override
    public List<CrmSaleQuotationRevisionDO> getSaleQuotationRevisionList(Long quotationId) {
        validateSaleQuotationExists(quotationId);
        return revisionMapper.selectListByQuotationId(quotationId);
    }
    @Override
    public List<CrmSaleQuotationDO> getBindableQuotationList(Long customerId, Long bidBondId) {
        if (customerId == null) {
            return Collections.emptyList();
        }
        List<CrmSaleQuotationDO> quotations = saleQuotationMapper.selectListByCustomerIdAndStatuses(customerId,
                List.of(CrmSaleQuotationStatusEnum.DRAFT.getStatus(),
                        CrmSaleQuotationStatusEnum.APPROVE.getStatus(),
                        CrmSaleQuotationStatusEnum.REJECT.getStatus()));
        // è¿‡æœŸæŠ¥ä»·å•会动态作废,需先从候选中剔除
        expiryHelper.expireIfNeeded(quotations);
        return quotations.stream()
                .filter(q -> q.getBidBondId() == null || Objects.equals(q.getBidBondId(), bidBondId))
                .collect(java.util.stream.Collectors.toList());
    }
    /**
     * è®°å½•本轮报价流水,供二次/三次报价追溯。
     */
    private void recordRevision(CrmSaleQuotationDO saleQuotation, Long userId) {
        int revisionNo = (saleQuotation.getRevisionNo() == null ? 0 : saleQuotation.getRevisionNo()) + 1;
        if (revisionMapper.selectByQuotationIdAndRevisionNo(saleQuotation.getId(), revisionNo) != null) {
            throw exception(SALE_QUOTATION_REVISION_EXISTS, saleQuotation.getNo(), revisionNo);
        }
        List<CrmSaleQuotationProductDO> products =
                saleQuotationProductMapper.selectListByQuotationId(saleQuotation.getId());
        revisionMapper.insert(CrmSaleQuotationRevisionDO.builder()
                .quotationId(saleQuotation.getId())
                .quotationNo(saleQuotation.getNo())
                .revisionNo(revisionNo)
                .bidBondId(saleQuotation.getBidBondId())
                .bidBondNo(saleQuotation.getBidBondNo())
                .customerId(saleQuotation.getCustomerId())
                .name(saleQuotation.getName())
                .quotationTime(saleQuotation.getQuotationTime())
                .validUntil(saleQuotation.getValidUntil())
                .totalProductPrice(saleQuotation.getTotalProductPrice())
                .discountPercent(saleQuotation.getDiscountPercent())
                .discountPrice(saleQuotation.getDiscountPrice())
                .totalPrice(saleQuotation.getTotalPrice())
                .taxRate(saleQuotation.getTaxRate())
                .taxPrice(saleQuotation.getTaxPrice())
                .itemsSnapshot(buildItemsSnapshot(products))
                .status(CrmSaleQuotationStatusEnum.PROCESS.getStatus())
                .submitUserId(userId)
                .submitTime(LocalDateTime.now())
                .build());
        // åŒæ­¥æŠ¥ä»·å•轮次
        saleQuotationMapper.updateById(new CrmSaleQuotationDO()
                .setId(saleQuotation.getId())
                .setRevisionNo(revisionNo));
        saleQuotation.setRevisionNo(revisionNo);
        // åŒæ­¥æŠ•标记录上的当前报价轮次
        if (saleQuotation.getBidBondId() != null) {
            bidBondMapper.updateById(new CrmBidBondDO()
                    .setId(saleQuotation.getBidBondId())
                    .setQuotationRound(revisionNo));
        }
    }
    /**
     * æ˜¯å¦å…è®¸é‡æ–°æŠ¥ä»·ï¼šç»‘定投标、报价已审批过、且投标结果为「未中标」。
     */
    private boolean canRequote(CrmSaleQuotationDO saleQuotation) {
        if (saleQuotation.getBidBondId() == null) {
            return false;
        }
        if (!CrmSaleQuotationStatusEnum.APPROVE.getStatus().equals(saleQuotation.getStatus())
                && !CrmSaleQuotationStatusEnum.REJECT.getStatus().equals(saleQuotation.getStatus())) {
            return false;
        }
        CrmBidBondDO bidBond = bidBondMapper.selectById(saleQuotation.getBidBondId());
        return bidBond != null && CrmBidResultEnum.LOST.getResult().equals(bidBond.getBidResult());
    }
    /**
     * å·²ä½œåºŸçš„æŠ¥ä»·å•不允许继续操作,按作废类型给出精确提示。
     */
    private void validateNotVoid(CrmSaleQuotationDO saleQuotation) {
        if (!CrmSaleQuotationStatusEnum.VOID.getStatus().equals(saleQuotation.getStatus())) {
            return;
        }
        if (CrmSaleQuotationVoidTypeEnum.EXPIRED.getType().equals(saleQuotation.getVoidType())) {
            String validUntil = saleQuotation.getValidUntil() != null
                    ? saleQuotation.getValidUntil().format(DATE_FORMATTER) : "未知";
            throw exception(SALE_QUOTATION_EXPIRED, saleQuotation.getNo(), validUntil);
        }
        throw exception(SALE_QUOTATION_VOIDED, saleQuotation.getNo(),
                StrUtil.blankToDefault(saleQuotation.getVoidReason(), "未填写作废原因"));
    }
    private String getStatusName(Integer status) {
        CrmSaleQuotationStatusEnum statusEnum = CrmSaleQuotationStatusEnum.getByStatus(status);
        return statusEnum != null ? statusEnum.getName() : "未知状态(" + status + ")";
    }
    private String getBidResultName(Integer result) {
        CrmBidResultEnum resultEnum = CrmBidResultEnum.valueOf(result);
        return resultEnum != null ? resultEnum.getName() : "未知结果(" + result + ")";
    }
    private String buildItemsSnapshot(List<CrmSaleQuotationProductDO> products) {
        if (CollUtil.isEmpty(products)) {
            return JsonUtils.toJsonString(Collections.emptyList());
        }
        Set<Long> itemIds = convertSet(products, CrmSaleQuotationProductDO::getItemId);
        Map<Long, MdmItemRespDTO> itemMap = mdmItemApi.getItemMap(itemIds);
        List<Map<String, Object>> items = convertList(products, product -> {
            MdmItemRespDTO item = itemMap.get(product.getItemId());
            Map<String, Object> snapshot = new LinkedHashMap<>();
            snapshot.put("itemId", product.getItemId());
            snapshot.put("itemCode", item != null ? item.getCode() : null);
            snapshot.put("itemName", item != null ? item.getName() : null);
            snapshot.put("itemSpecification", item != null ? item.getSpecification() : null);
            snapshot.put("count", product.getCount());
            snapshot.put("itemPrice", product.getItemPrice());
            snapshot.put("quotationPrice", product.getQuotationPrice());
            snapshot.put("taxPercent", product.getTaxPercent());
            snapshot.put("totalPrice", product.getTotalPrice());
            snapshot.put("taxPrice", product.getTaxPrice());
            snapshot.put("remark", product.getRemark());
            return snapshot;
        });
        return JsonUtils.toJsonString(items);
    }
    private void cancelProcessQuietly(String processInstanceId, String reason) {
        if (StrUtil.isBlank(processInstanceId)) {
            return;
        }
        try {
            bpmProcessInstanceApi.cancelProcessInstanceByAdmin(getLoginUserId(), processInstanceId, reason);
        } catch (Exception ex) {
            log.warn("[cancelProcessQuietly][报价单流程({})撤销失败,不影响作废]", processInstanceId, ex);
        }
    }
}
yudao-module-crm/src/test/java/cn/iocoder/yudao/module/crm/service/bidbond/CrmBidBondQuotationBindingTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,140 @@
package cn.iocoder.yudao.module.crm.service.bidbond;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.module.crm.controller.admin.bidbond.vo.CrmBidBondSaveReqVO;
import cn.iocoder.yudao.module.crm.dal.dataobject.bidbond.CrmBidBondDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.customer.CrmCustomerDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.quotation.CrmSaleQuotationDO;
import cn.iocoder.yudao.module.crm.dal.mysql.bidbond.CrmBidBondMapper;
import cn.iocoder.yudao.module.crm.dal.mysql.quotation.CrmSaleQuotationMapper;
import cn.iocoder.yudao.module.crm.service.customer.CrmCustomerService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.lang.reflect.Proxy;
import java.math.BigDecimal;
import java.util.concurrent.atomic.AtomicReference;
import static cn.iocoder.yudao.module.crm.enums.ErrorCodeConstants.BID_BOND_QUOTATION_ALREADY_BOUND;
import static cn.iocoder.yudao.module.crm.enums.ErrorCodeConstants.BID_BOND_QUOTATION_CUSTOMER_MISMATCH;
import static cn.iocoder.yudao.module.crm.enums.ErrorCodeConstants.BID_BOND_QUOTATION_STATUS_INVALID;
import static cn.iocoder.yudao.module.crm.enums.ErrorCodeConstants.BID_BOND_WON_QUOTATION_REQUIRED;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
class CrmBidBondQuotationBindingTest {
    private final CrmBidBondServiceImpl service = new CrmBidBondServiceImpl();
    private final AtomicReference<CrmBidBondDO> inserted = new AtomicReference<>();
    private final AtomicReference<Object[]> bound = new AtomicReference<>();
    @BeforeEach
    void setUp() {
        CrmCustomerService customerService = stub(CrmCustomerService.class, (proxy, method, args) -> {
            if ("getCustomer".equals(method.getName())) {
                return new CrmCustomerDO().setId((Long) args[0]).setName("客户1");
            }
            throw new AssertionError(method.getName());
        });
        CrmBidBondMapper bondMapper = stub(CrmBidBondMapper.class, (proxy, method, args) -> switch (method.getName()) {
            case "selectByNo" -> null;
            case "selectById" -> new CrmBidBondDO().setId((Long) args[0]).setNo("BZ-EXIST");
            case "insert" -> {
                CrmBidBondDO bond = (CrmBidBondDO) args[0];
                bond.setId(100L);
                inserted.set(bond);
                yield 1;
            }
            default -> throw new AssertionError(method.getName());
        });
        ReflectionTestUtils.setField(service, "customerService", customerService);
        ReflectionTestUtils.setField(service, "bidBondMapper", bondMapper);
    }
    private void bindQuotation(CrmSaleQuotationDO quotation) {
        CrmSaleQuotationMapper quoteMapper = stub(CrmSaleQuotationMapper.class,
                (proxy, method, args) -> switch (method.getName()) {
                    case "selectById" -> quotation;
                    case "bindBidBond" -> {
                        bound.set(args);
                        yield null;
                    }
                    default -> throw new AssertionError(method.getName());
                });
        ReflectionTestUtils.setField(service, "saleQuotationMapper", quoteMapper);
    }
    @Test
    void customerMismatchRejected() {
        bindQuotation(new CrmSaleQuotationDO().setId(5L).setNo("BJ5").setCustomerId(2L).setStatus(2));
        CrmBidBondSaveReqVO req = baseReq();
        req.setQuotationId(5L);
        ServiceException ex = assertThrows(ServiceException.class, () -> service.createBidBond(req));
        assertEquals(BID_BOND_QUOTATION_CUSTOMER_MISMATCH.getCode(), ex.getCode());
    }
    @Test
    void voidQuotationCannotBeBound() {
        bindQuotation(new CrmSaleQuotationDO().setId(5L).setNo("BJ5").setCustomerId(1L).setStatus(5));
        CrmBidBondSaveReqVO req = baseReq();
        req.setQuotationId(5L);
        ServiceException ex = assertThrows(ServiceException.class, () -> service.createBidBond(req));
        assertEquals(BID_BOND_QUOTATION_STATUS_INVALID.getCode(), ex.getCode());
    }
    @Test
    void quotationAlreadyBoundRejected() {
        bindQuotation(new CrmSaleQuotationDO().setId(5L).setNo("BJ5").setCustomerId(1L).setStatus(2)
                .setBidBondId(8L));
        CrmBidBondSaveReqVO req = baseReq();
        req.setQuotationId(5L);
        ServiceException ex = assertThrows(ServiceException.class, () -> service.createBidBond(req));
        assertEquals(BID_BOND_QUOTATION_ALREADY_BOUND.getCode(), ex.getCode());
    }
    @Test
    void wonRequiresQuotation() {
        bindQuotation(new CrmSaleQuotationDO().setId(5L).setNo("BJ5").setCustomerId(1L).setStatus(2));
        CrmBidBondSaveReqVO req = baseReq();
        req.setBidResult(1);
        ServiceException ex = assertThrows(ServiceException.class, () -> service.createBidBond(req));
        assertEquals(BID_BOND_WON_QUOTATION_REQUIRED.getCode(), ex.getCode());
    }
    @Test
    void createBindsQuotationBack() {
        bindQuotation(new CrmSaleQuotationDO().setId(5L).setNo("BJ5").setCustomerId(1L).setStatus(2)
                .setRevisionNo(2));
        CrmBidBondSaveReqVO req = baseReq();
        req.setQuotationId(5L);
        service.createBidBond(req);
        assertNotNull(inserted.get());
        assertEquals("BJ5", inserted.get().getQuotationNo());
        assertEquals(2, inserted.get().getQuotationRound());
        assertNotNull(bound.get());
        assertEquals(5L, bound.get()[0]);
        assertEquals(100L, bound.get()[1]);
        assertEquals(inserted.get().getNo(), bound.get()[2]);
    }
    private CrmBidBondSaveReqVO baseReq() {
        CrmBidBondSaveReqVO req = new CrmBidBondSaveReqVO();
        req.setCustomerId(1L);
        req.setProjectName("某某项目");
        req.setBondAmount(new BigDecimal("1000"));
        req.setBidResult(0);
        return req;
    }
    private static <T> T stub(Class<T> type, java.lang.reflect.InvocationHandler handler) {
        return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[]{type}, handler));
    }
}
yudao-module-crm/src/test/java/cn/iocoder/yudao/module/crm/service/quotation/CrmSaleQuotationConvertGateTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,103 @@
package cn.iocoder.yudao.module.crm.service.quotation;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.module.crm.dal.dataobject.bidbond.CrmBidBondDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.quotation.CrmSaleQuotationDO;
import cn.iocoder.yudao.module.crm.dal.mysql.bidbond.CrmBidBondMapper;
import cn.iocoder.yudao.module.crm.dal.mysql.quotation.CrmSaleQuotationMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.lang.reflect.Proxy;
import static cn.iocoder.yudao.module.crm.enums.ErrorCodeConstants.SALE_QUOTATION_CONVERT_FAIL_BID_NOT_WON;
import static cn.iocoder.yudao.module.crm.enums.ErrorCodeConstants.SALE_QUOTATION_CONVERT_FAIL_BID_QUOTATION_MISMATCH;
import static cn.iocoder.yudao.module.crm.enums.ErrorCodeConstants.SALE_QUOTATION_CONVERT_FAIL_BID_REQUIRED;
import static cn.iocoder.yudao.module.crm.enums.ErrorCodeConstants.SALE_QUOTATION_CONVERT_FAIL_NOT_DRAFT;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
class CrmSaleQuotationConvertGateTest {
    private final CrmSaleQuotationServiceImpl service = new CrmSaleQuotationServiceImpl();
    @BeforeEach
    void setUp() {
        ReflectionTestUtils.setField(service, "expiryHelper", new CrmSaleQuotationExpiryHelper());
    }
    @Test
    void needBidQuotationWithoutBondCannotConvert() {
        prepare(new CrmSaleQuotationDO().setId(1L).setNo("BJ1").setStatus(2).setNeedBid(true), null);
        ServiceException ex = assertThrows(ServiceException.class, () -> service.convertToContract(1L, 291L));
        assertEquals(SALE_QUOTATION_CONVERT_FAIL_BID_REQUIRED.getCode(), ex.getCode());
    }
    @Test
    void nullNeedBidTreatedAsRequired() {
        prepare(new CrmSaleQuotationDO().setId(1L).setNo("BJ1").setStatus(2), null);
        ServiceException ex = assertThrows(ServiceException.class, () -> service.convertToContract(1L, 291L));
        assertEquals(SALE_QUOTATION_CONVERT_FAIL_BID_REQUIRED.getCode(), ex.getCode());
    }
    @Test
    void notNeedBidQuotationUsesOriginalStatusRule() {
        prepare(new CrmSaleQuotationDO().setId(1L).setNo("BJ1").setStatus(1).setNeedBid(false), null);
        ServiceException ex = assertThrows(ServiceException.class, () -> service.convertToContract(1L, 291L));
        assertEquals(SALE_QUOTATION_CONVERT_FAIL_NOT_DRAFT.getCode(), ex.getCode());
    }
    @Test
    void boundQuotationRequiresWonBid() {
        prepare(new CrmSaleQuotationDO().setId(1L).setNo("BJ1").setStatus(2).setBidBondId(9L),
                new CrmBidBondDO().setId(9L).setNo("BZ9").setBidResult(2).setQuotationId(1L));
        ServiceException ex = assertThrows(ServiceException.class, () -> service.convertToContract(1L, 291L));
        assertEquals(SALE_QUOTATION_CONVERT_FAIL_BID_NOT_WON.getCode(), ex.getCode());
    }
    @Test
    void boundQuotationRequiresMatchingBid() {
        prepare(new CrmSaleQuotationDO().setId(1L).setNo("BJ1").setStatus(2).setBidBondId(9L),
                new CrmBidBondDO().setId(9L).setNo("BZ9").setBidResult(1).setQuotationId(8L));
        ServiceException ex = assertThrows(ServiceException.class, () -> service.convertToContract(1L, 291L));
        assertEquals(SALE_QUOTATION_CONVERT_FAIL_BID_QUOTATION_MISMATCH.getCode(), ex.getCode());
    }
    @Test
    void wonQuotationMustBeApproved() {
        prepare(new CrmSaleQuotationDO().setId(1L).setNo("BJ1").setStatus(0).setBidBondId(9L),
                new CrmBidBondDO().setId(9L).setNo("BZ9").setBidResult(1).setQuotationId(1L));
        ServiceException ex = assertThrows(ServiceException.class, () -> service.convertToContract(1L, 291L));
        assertEquals(SALE_QUOTATION_CONVERT_FAIL_NOT_DRAFT.getCode(), ex.getCode());
    }
    private void prepare(CrmSaleQuotationDO quote, CrmBidBondDO bond) {
        CrmSaleQuotationMapper quoteMapper = stub(CrmSaleQuotationMapper.class,
                (proxy, method, args) -> {
                    if ("selectById".equals(method.getName())) {
                        return quote;
                    }
                    throw new AssertionError(method.getName());
                });
        CrmBidBondMapper bondMapper = stub(CrmBidBondMapper.class,
                (proxy, method, args) -> {
                    if ("selectById".equals(method.getName())) {
                        return bond;
                    }
                    throw new AssertionError(method.getName());
                });
        ReflectionTestUtils.setField(service, "saleQuotationMapper", quoteMapper);
        ReflectionTestUtils.setField(service, "bidBondMapper", bondMapper);
    }
    private static <T> T stub(Class<T> type, java.lang.reflect.InvocationHandler handler) {
        return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[]{type}, handler));
    }
}
yudao-module-crm/src/test/java/cn/iocoder/yudao/module/crm/service/quotation/CrmSaleQuotationExpiryHelperTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,118 @@
package cn.iocoder.yudao.module.crm.service.quotation;
import cn.iocoder.yudao.module.crm.dal.dataobject.bidbond.CrmBidBondDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.quotation.CrmSaleQuotationDO;
import cn.iocoder.yudao.module.crm.dal.mysql.bidbond.CrmBidBondMapper;
import cn.iocoder.yudao.module.crm.dal.mysql.quotation.CrmSaleQuotationMapper;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.lang.reflect.Proxy;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class CrmSaleQuotationExpiryHelperTest {
    private final CrmSaleQuotationExpiryHelper helper = new CrmSaleQuotationExpiryHelper();
    @Test
    void isExpired_nullNeverExpires() {
        assertFalse(helper.isExpired(null));
    }
    @Test
    void isExpired_dateOnlyRemainsValidToday() {
        // å‰ç«¯æ—¥æœŸæŽ§ä»¶åªé€‰åˆ°æ—¥ï¼Œæ—¶é—´éƒ¨åˆ†ä¸º 00:00:00,当天应仍有效
        assertFalse(helper.isExpired(LocalDate.now().atStartOfDay()));
        assertTrue(helper.isExpired(LocalDate.now().minusDays(1).atStartOfDay()));
    }
    @Test
    void isExpired_exactTimeCompare() {
        assertFalse(helper.isExpired(LocalDateTime.now().plusMinutes(1)));
        assertTrue(helper.isExpired(LocalDateTime.now().minusMinutes(1)));
    }
    @Test
    void expiredQuotationIsVoided() {
        CrmSaleQuotationDO quote = new CrmSaleQuotationDO().setId(1L).setNo("BJ1").setStatus(0)
                .setValidUntil(LocalDate.now().minusDays(2).atStartOfDay());
        AtomicReference<CrmSaleQuotationDO> updated = new AtomicReference<>();
        CrmSaleQuotationMapper quoteMapper = stub(CrmSaleQuotationMapper.class,
                (proxy, method, args) -> switch (method.getName()) {
                    case "updateById" -> {
                        updated.set((CrmSaleQuotationDO) args[0]);
                        yield 1;
                    }
                    default -> throw new AssertionError(method.getName());
                });
        ReflectionTestUtils.setField(helper, "saleQuotationMapper", quoteMapper);
        ReflectionTestUtils.setField(helper, "bidBondMapper", emptyBondMapper());
        helper.expireIfNeeded(quote);
        assertEquals(5, quote.getStatus());
        assertEquals(2, quote.getVoidType());
        assertNotNull(quote.getVoidReason());
        assertNotNull(updated.get());
        assertEquals(5, updated.get().getStatus());
        assertEquals(2, updated.get().getVoidType());
    }
    @Test
    void bidInProgressIsSkipped() {
        CrmSaleQuotationDO quote = new CrmSaleQuotationDO().setId(1L).setNo("BJ1").setStatus(2).setBidBondId(9L)
                .setValidUntil(LocalDate.now().minusDays(2).atStartOfDay());
        CrmSaleQuotationMapper quoteMapper = stub(CrmSaleQuotationMapper.class,
                (proxy, method, args) -> {
                    throw new AssertionError("投标进行中不应自动作废:" + method.getName());
                });
        CrmBidBondMapper bondMapper = stub(CrmBidBondMapper.class,
                (proxy, method, args) -> List.of(new CrmBidBondDO().setId(9L).setBidResult(0)));
        ReflectionTestUtils.setField(helper, "saleQuotationMapper", quoteMapper);
        ReflectionTestUtils.setField(helper, "bidBondMapper", bondMapper);
        helper.expireIfNeeded(quote);
        assertEquals(2, quote.getStatus());
    }
    @Test
    void lostBidQuotationIsExpired() {
        CrmSaleQuotationDO quote = new CrmSaleQuotationDO().setId(1L).setNo("BJ1").setStatus(2).setBidBondId(9L)
                .setValidUntil(LocalDate.now().minusDays(2).atStartOfDay());
        AtomicReference<CrmSaleQuotationDO> updated = new AtomicReference<>();
        CrmSaleQuotationMapper quoteMapper = stub(CrmSaleQuotationMapper.class,
                (proxy, method, args) -> switch (method.getName()) {
                    case "updateById" -> {
                        updated.set((CrmSaleQuotationDO) args[0]);
                        yield 1;
                    }
                    default -> throw new AssertionError(method.getName());
                });
        CrmBidBondMapper bondMapper = stub(CrmBidBondMapper.class,
                (proxy, method, args) -> List.of(new CrmBidBondDO().setId(9L).setBidResult(2)));
        ReflectionTestUtils.setField(helper, "saleQuotationMapper", quoteMapper);
        ReflectionTestUtils.setField(helper, "bidBondMapper", bondMapper);
        helper.expireIfNeeded(quote);
        assertEquals(5, quote.getStatus());
        assertEquals(2, quote.getVoidType());
    }
    private static CrmBidBondMapper emptyBondMapper() {
        return stub(CrmBidBondMapper.class, (proxy, method, args) -> List.of());
    }
    private static <T> T stub(Class<T> type, java.lang.reflect.InvocationHandler handler) {
        return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[]{type}, handler));
    }
}
yudao-module-crm/src/test/java/cn/iocoder/yudao/module/crm/service/quotation/CrmSaleQuotationQuantityTest.java
@@ -30,7 +30,8 @@
    void conversionKeepsFractionalQuantity() {
        var service = new CrmSaleQuotationServiceImpl();
        var request = new AtomicReference<CrmContractSaveReqVO>();
        var quote = new CrmSaleQuotationDO().setId(1L).setNo("BJ-1").setName("台账报价").setStatus(0);
        var quote = new CrmSaleQuotationDO().setId(1L).setNo("BJ-1").setName("台账报价").setStatus(0)
                .setNeedBid(false);
        var quotes = stub(CrmSaleQuotationMapper.class, (proxy, method, args) -> switch (method.getName()) {
            case "selectById" -> quote;
            case "updateById" -> 1;
@@ -52,6 +53,7 @@
        ReflectionTestUtils.setField(service, "saleQuotationMapper", quotes);
        ReflectionTestUtils.setField(service, "saleQuotationProductMapper", products);
        ReflectionTestUtils.setField(service, "contractService", contracts);
        ReflectionTestUtils.setField(service, "expiryHelper", new CrmSaleQuotationExpiryHelper());
        assertEquals(2L, service.convertToContract(1L, 291L));
        assertEquals(new BigDecimal("22304.8"), request.get().getProducts().getFirst().getCount());
    }
yudao-module-crm/src/test/java/cn/iocoder/yudao/module/crm/service/quotation/CrmSaleQuotationRevisionTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,88 @@
package cn.iocoder.yudao.module.crm.service.quotation;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.module.crm.dal.dataobject.quotation.CrmSaleQuotationDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.quotation.CrmSaleQuotationProductDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.quotation.CrmSaleQuotationRevisionDO;
import cn.iocoder.yudao.module.crm.dal.mysql.quotation.CrmSaleQuotationMapper;
import cn.iocoder.yudao.module.crm.dal.mysql.quotation.CrmSaleQuotationProductMapper;
import cn.iocoder.yudao.module.crm.dal.mysql.quotation.CrmSaleQuotationRevisionMapper;
import cn.iocoder.yudao.module.mdm.api.item.MdmItemApi;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.lang.reflect.Proxy;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import static cn.iocoder.yudao.module.crm.enums.ErrorCodeConstants.SALE_QUOTATION_REVISION_EXISTS;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
class CrmSaleQuotationRevisionTest {
    private final CrmSaleQuotationServiceImpl service = new CrmSaleQuotationServiceImpl();
    private final List<CrmSaleQuotationRevisionDO> inserted = new ArrayList<>();
    private void prepare(long existingRevisionCount) {
        CrmSaleQuotationRevisionMapper revisionMapper = stub(CrmSaleQuotationRevisionMapper.class,
                (proxy, method, args) -> switch (method.getName()) {
                    case "selectByQuotationIdAndRevisionNo" ->
                            (Integer) args[1] <= existingRevisionCount ? new CrmSaleQuotationRevisionDO() : null;
                    case "insert" -> {
                        inserted.add((CrmSaleQuotationRevisionDO) args[0]);
                        yield 1;
                    }
                    default -> throw new AssertionError(method.getName());
                });
        CrmSaleQuotationProductMapper productMapper = stub(CrmSaleQuotationProductMapper.class,
                (proxy, method, args) -> {
                    if ("selectListByQuotationId".equals(method.getName())) {
                        return List.of(new CrmSaleQuotationProductDO().setItemId(1103L)
                                .setCount(new BigDecimal("2"))
                                .setQuotationPrice(new BigDecimal("100")));
                    }
                    throw new AssertionError(method.getName());
                });
        CrmSaleQuotationMapper quoteMapper = stub(CrmSaleQuotationMapper.class,
                (proxy, method, args) -> 1);
        MdmItemApi itemApi = stub(MdmItemApi.class, (proxy, method, args) -> Map.of());
        ReflectionTestUtils.setField(service, "revisionMapper", revisionMapper);
        ReflectionTestUtils.setField(service, "saleQuotationProductMapper", productMapper);
        ReflectionTestUtils.setField(service, "saleQuotationMapper", quoteMapper);
        ReflectionTestUtils.setField(service, "mdmItemApi", itemApi);
    }
    @Test
    void eachSubmitIncrementsRevision() {
        prepare(0);
        CrmSaleQuotationDO quote = new CrmSaleQuotationDO().setId(1L).setNo("BJ1").setRevisionNo(0);
        ReflectionTestUtils.invokeMethod(service, "recordRevision", quote, 291L);
        ReflectionTestUtils.invokeMethod(service, "recordRevision", quote, 291L);
        assertEquals(2, inserted.size());
        assertEquals(1, inserted.get(0).getRevisionNo());
        assertEquals(2, inserted.get(1).getRevisionNo());
        assertEquals(2, quote.getRevisionNo());
        assertNotNull(inserted.get(0).getItemsSnapshot());
        assertEquals(1, inserted.get(0).getStatus());
    }
    @Test
    void duplicateRevisionRejected() {
        prepare(1);
        CrmSaleQuotationDO quote = new CrmSaleQuotationDO().setId(1L).setNo("BJ1").setRevisionNo(0);
        ServiceException ex = assertThrows(ServiceException.class,
                () -> ReflectionTestUtils.invokeMethod(service, "recordRevision", quote, 291L));
        assertEquals(SALE_QUOTATION_REVISION_EXISTS.getCode(), ex.getCode());
    }
    private static <T> T stub(Class<T> type, java.lang.reflect.InvocationHandler handler) {
        return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[]{type}, handler));
    }
}
yudao-module-crm/src/test/java/cn/iocoder/yudao/module/crm/service/quotation/CrmSaleQuotationVoidTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,114 @@
package cn.iocoder.yudao.module.crm.service.quotation;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.module.bpm.api.task.BpmProcessInstanceApi;
import cn.iocoder.yudao.module.crm.dal.dataobject.bidbond.CrmBidBondDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.quotation.CrmSaleQuotationDO;
import cn.iocoder.yudao.module.crm.dal.mysql.bidbond.CrmBidBondMapper;
import cn.iocoder.yudao.module.crm.dal.mysql.quotation.CrmSaleQuotationMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.lang.reflect.Proxy;
import java.util.concurrent.atomic.AtomicReference;
import static cn.iocoder.yudao.module.crm.enums.ErrorCodeConstants.SALE_QUOTATION_VOID_FAIL_STATUS;
import static cn.iocoder.yudao.module.crm.enums.ErrorCodeConstants.SALE_QUOTATION_VOID_FAIL_WON;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
class CrmSaleQuotationVoidTest {
    private final CrmSaleQuotationServiceImpl service = new CrmSaleQuotationServiceImpl();
    @BeforeEach
    void setUp() {
        ReflectionTestUtils.setField(service, "expiryHelper", new CrmSaleQuotationExpiryHelper());
    }
    @Test
    void voidDraftQuotation() {
        CrmSaleQuotationDO quote = new CrmSaleQuotationDO().setId(1L).setNo("BJ1").setStatus(0);
        AtomicReference<CrmSaleQuotationDO> updated = new AtomicReference<>();
        ReflectionTestUtils.setField(service, "saleQuotationMapper", quoteMapper(quote, updated));
        service.voidSaleQuotation(1L, "  å®¢æˆ·å–消本次采购  ");
        assertNotNull(updated.get());
        assertEquals(5, updated.get().getStatus());
        assertEquals(1, updated.get().getVoidType());
        assertEquals("客户取消本次采购", updated.get().getVoidReason());
        assertNotNull(updated.get().getVoidTime());
    }
    @Test
    void voidWonQuotationRejected() {
        CrmSaleQuotationDO quote = new CrmSaleQuotationDO().setId(1L).setNo("BJ1").setStatus(2).setBidBondId(9L);
        ReflectionTestUtils.setField(service, "saleQuotationMapper", quoteMapper(quote, new AtomicReference<>()));
        ReflectionTestUtils.setField(service, "bidBondMapper", bondMapper(new CrmBidBondDO().setId(9L).setBidResult(1)));
        ServiceException ex = assertThrows(ServiceException.class,
                () -> service.voidSaleQuotation(1L, "不再参与"));
        assertEquals(SALE_QUOTATION_VOID_FAIL_WON.getCode(), ex.getCode());
    }
    @Test
    void voidConvertedQuotationRejected() {
        CrmSaleQuotationDO quote = new CrmSaleQuotationDO().setId(1L).setNo("BJ1").setStatus(10);
        ReflectionTestUtils.setField(service, "saleQuotationMapper", quoteMapper(quote, new AtomicReference<>()));
        ServiceException ex = assertThrows(ServiceException.class,
                () -> service.voidSaleQuotation(1L, "不再参与"));
        assertEquals(SALE_QUOTATION_VOID_FAIL_STATUS.getCode(), ex.getCode());
    }
    @Test
    void voidProcessingQuotationCancelsProcess() {
        CrmSaleQuotationDO quote = new CrmSaleQuotationDO().setId(1L).setNo("BJ1").setStatus(1)
                .setProcessInstanceId("proc-1");
        AtomicReference<CrmSaleQuotationDO> updated = new AtomicReference<>();
        ReflectionTestUtils.setField(service, "saleQuotationMapper", quoteMapper(quote, updated));
        AtomicReference<String[]> cancelled = new AtomicReference<>();
        ReflectionTestUtils.setField(service, "bpmProcessInstanceApi", stub(BpmProcessInstanceApi.class,
                (proxy, method, args) -> {
                    if ("cancelProcessInstanceByAdmin".equals(method.getName())) {
                        cancelled.set(new String[]{(String) args[1], (String) args[2]});
                        return null;
                    }
                    throw new AssertionError(method.getName());
                }));
        service.voidSaleQuotation(1L, "项目取消");
        assertEquals(5, updated.get().getStatus());
        assertNotNull(cancelled.get());
        assertEquals("proc-1", cancelled.get()[0]);
        assertEquals("项目取消", cancelled.get()[1]);
    }
    private CrmSaleQuotationMapper quoteMapper(CrmSaleQuotationDO quote, AtomicReference<CrmSaleQuotationDO> updated) {
        return stub(CrmSaleQuotationMapper.class, (proxy, method, args) -> switch (method.getName()) {
            case "selectById" -> quote;
            case "updateById" -> {
                updated.set((CrmSaleQuotationDO) args[0]);
                yield 1;
            }
            default -> throw new AssertionError(method.getName());
        });
    }
    private CrmBidBondMapper bondMapper(CrmBidBondDO bond) {
        return stub(CrmBidBondMapper.class, (proxy, method, args) -> {
            if ("selectById".equals(method.getName())) {
                return bond;
            }
            throw new AssertionError(method.getName());
        });
    }
    private static <T> T stub(Class<T> type, java.lang.reflect.InvocationHandler handler) {
        return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[]{type}, handler));
    }
}