7 天以前 11214e3074266a23fe61e8eebbce647fdb7305ef
报价单修改-优化,增加导入记录,降价历史
已添加11个文件
已修改5个文件
1243 ■■■■■ 文件已修改
docs/sales_quotation_import_api.md 223 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/sales_quotation_price_history.sql 56 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/common/utils/poi/ExcelUtil.java 244 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/framework/web/exception/GlobalExceptionHandler.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/controller/SalesQuotationController.java 47 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/dto/SalesQuotationImportDto.java 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/dto/SalesQuotationMainImportDto.java 50 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/dto/SalesQuotationProductImportDto.java 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/mapper/SalesQuotationImportLogMapper.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/mapper/SalesQuotationPriceHistoryMapper.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/pojo/SalesQuotationImportLog.java 74 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/pojo/SalesQuotationPriceHistory.java 69 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/service/SalesQuotationService.java 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/service/impl/SalesQuotationServiceImpl.java 328 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/sales/SalesQuotationImportLogMapper.xml 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/sales/SalesQuotationPriceHistoryMapper.xml 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/sales_quotation_import_api.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,223 @@
# é”€å”®æŠ¥ä»·å¯¼å…¥åŠŸèƒ½ - å‰ç«¯è”调文档
## ä¸€ã€åŠŸèƒ½æ¦‚è¿°
销售报价模块支持多Sheet模板导入,模板结构参考销售台账:
1. **报价单数据Sheet**:包含报价单基本信息
2. **报价产品数据Sheet**:包含产品明细信息
---
## äºŒã€æ¨¡æ¿ç»“æž„
### 2.1 æŠ¥ä»·å•数据Sheet
| å­—段 | è¯´æ˜Ž | æ˜¯å¦å¿…å¡« |
|------|------|----------|
| æŠ¥ä»·å•号 | æŠ¥ä»·å•编号(不填自动生成) | å¦ |
| å®¢æˆ·åç§° | å®¢æˆ·åç§° | æ˜¯ |
| ä¸šåŠ¡å‘˜ | é”€å”®äººå‘˜å§“名 | å¦ |
| æŠ¥ä»·æ—¥æœŸ | æ ¼å¼yyyy-MM-dd | å¦ |
| æœ‰æ•ˆæœŸè‡³ | æ ¼å¼yyyy-MM-dd | å¦ |
| ä»˜æ¬¾æ–¹å¼ | å¦‚"月结30天" | å¦ |
| äº¤è´§å‘¨æœŸ | å¦‚"7天" | å¦ |
| å¤‡æ³¨ | å…¶ä»–说明 | å¦ |
### 2.2 æŠ¥ä»·äº§å“æ•°æ®Sheet
| å­—段 | è¯´æ˜Ž | æ˜¯å¦å¿…å¡« |
|------|------|----------|
| æŠ¥ä»·å•号 | å…³è”报价单数据Sheet的报价单号 | æ˜¯ |
| äº§å“å¤§ç±» | äº§å“åˆ†ç±»åç§° | æ˜¯ |
| è§„格型号 | äº§å“è§„格型号 | æ˜¯ |
| å•位 | è®¡é‡å•位 | å¦ |
| æ•°é‡ | æŠ¥ä»·æ•°é‡ | æ˜¯ |
| å«ç¨Žå•ä»· | å•价(元) | æ˜¯ |
| å«ç¨Žæ€»ä»· | æ€»ä»·ï¼ˆå…ƒï¼‰ | å¦ï¼ˆä¸å¡«è‡ªåŠ¨è®¡ç®—ï¼‰ |
| å¤‡æ³¨ | äº§å“å¤‡æ³¨ | å¦ |
---
## ä¸‰ã€æŽ¥å£è¯¦æƒ…
### 3.1 ä¸‹è½½æŠ¥ä»·å¯¼å…¥æ¨¡æ¿
**请求**
```
GET /sales/quotation/downloadTemplate
```
**响应**
- Excel文件下载,包含两个Sheet:
  - æŠ¥ä»·å•数据(含示例数据)
  - æŠ¥ä»·äº§å“æ•°æ®ï¼ˆå«ç¤ºä¾‹æ•°æ®ï¼‰
### 3.2 å¯¼å…¥æŠ¥ä»·å•
**请求**
```
POST /sales/quotation/import
Content-Type: multipart/form-data
file: [Excel文件]
```
**响应**
```json
{
    "code": 200,
    "msg": "操作成功",
    "data": {
        "id": 1,
        "batchNo": "QT_IMP_20260612143000",
        "fileName": "报价单.xlsx",
        "totalCount": 5,
        "successCount": 5,
        "newCount": 5,
        "failCount": 0,
        "status": "completed",
        "createTime": "2026-06-12 14:30:00",
        "createUserName": "张三"
    }
}
```
**业务逻辑**
1. **模板检查**:导入前检查报价审批模板是否存在
   - ä¸å­˜åœ¨ï¼šè¿”回错误"请先配置报价审批模板,无法导入"
   - å­˜åœ¨ï¼šç»§ç»­å¯¼å…¥
2. **报价单号处理**:
   - å¦‚果填写了报价单号,使用填写的单号
   - å¦‚果未填写,自动生成单号(格式:QT + æ—¥æœŸ + åºå·ï¼‰
   - æŠ¥ä»·å•号已存在则跳过
3. **产品关联**:通过报价单号将产品数据关联到对应的报价单
4. **审批流程**:导入成功后自动创建审批流程
5. **降价记录**:相同规格型号的产品对比历史价格,自动记录降价
**错误码**
| é”™è¯¯ä¿¡æ¯ | è¯´æ˜Ž |
|----------|------|
| è¯·å…ˆé…ç½®æŠ¥ä»·å®¡æ‰¹æ¨¡æ¿ï¼Œæ— æ³•导入 | ç³»ç»Ÿæœªé…ç½®æŠ¥ä»·å®¡æ‰¹æ¨¡æ¿ |
| è¯»å–文件失败 | Excel文件格式错误 |
| æŠ¥ä»·å•数据为空,请检查模板内容 | æŠ¥ä»·å•Sheet无数据 |
### 3.3 æŸ¥è¯¢å¯¼å…¥è®°å½•
**请求**
```
GET /sales/quotation/importLog/list?pageNum=1&pageSize=10
```
**响应**
```json
{
    "code": 200,
    "data": {
        "total": 20,
        "records": [
            {
                "id": 1,
                "batchNo": "QT_IMP_20260612143000",
                "fileName": "报价单.xlsx",
                "totalCount": 5,
                "successCount": 5,
                "newCount": 5,
                "failCount": 0,
                "status": "completed",
                "createUserName": "张三",
                "createTime": "2026-06-12 14:30:00"
            }
        ]
    }
}
```
### 3.4 æŸ¥è¯¢é™ä»·åŽ†å²
**请求**
```
GET /sales/quotation/priceHistory/list?quotationProductId=123
```
**响应**
```json
{
    "code": 200,
    "data": [
        {
            "id": 1,
            "productName": "电池组件A",
            "specification": "MODEL-A-100W",
            "oldPrice": 150.00,
            "newPrice": 120.00,
            "priceChange": -30.00,
            "changeReason": "降价",
            "importBatch": "QT_IMP_20260612143000",
            "importTime": "2026-06-12 14:30:00",
            "createUserName": "张三"
        }
    ]
}
```
---
## å››ã€ä¸šåŠ¡æµç¨‹
```
下载模板
    â”‚
    â–¼
填写报价单数据Sheet(每行一个报价单)
填写报价产品数据Sheet(通过报价单号关联)
    â”‚
    â–¼
上传文件
    â”‚
    â–¼
检查审批模板是否存在
    â”‚
    â”œâ”€â”€ ä¸å­˜åœ¨ â”€â”€â–º é”™è¯¯ï¼š"请先配置报价审批模板,无法导入"
    â”‚
    â””── å­˜åœ¨ â”€â”€â–º è§£æžä¸¤ä¸ªSheet数据
    â”‚
    â–¼
遍历报价单数据
    â”‚
    â”œâ”€â”€ æ£€æŸ¥æŠ¥ä»·å•号是否已存在 â”€â”€â–º å·²å­˜åœ¨åˆ™è·³è¿‡
    â”‚
    â”œâ”€â”€ åˆ›å»ºæŠ¥ä»·å•记录
    â”œâ”€â”€ å…³è”产品数据(通过报价单号匹配)
    â”œâ”€â”€ è®°å½•降价历史(对比历史价格)
    â””── åˆ›å»ºå®¡æ‰¹æµç¨‹
    â”‚
    â–¼
返回导入结果
```
---
## äº”、示例数据
**报价单数据Sheet**
| æŠ¥ä»·å•号 | å®¢æˆ·åç§° | ä¸šåŠ¡å‘˜ | ä»˜æ¬¾æ–¹å¼ | äº¤è´§å‘¨æœŸ |
|----------|----------|--------|----------|----------|
| QT202606120001 | ç¤ºä¾‹å®¢æˆ· | å¼ ä¸‰ | æœˆç»“30天 | 7天 |
**报价产品数据Sheet**
| æŠ¥ä»·å•号 | äº§å“å¤§ç±» | è§„格型号 | å•位 | æ•°é‡ | å«ç¨Žå•ä»· | å«ç¨Žæ€»ä»· |
|----------|----------|----------|------|------|----------|----------|
| QT202606120001 | ç”µæ± ç»„ä»¶ | MODEL-A-100W | ç‰‡ | 100 | 150.00 | 15000.00 |
| QT202606120001 | ç”µæ± ç»„ä»¶ | MODEL-B-200W | ç‰‡ | 50 | 200.00 | 10000.00 |
---
## å…­ã€æ•°æ®åº“变更
需执行:`docs/sales_quotation_price_history.sql`
docs/sales_quotation_price_history.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,56 @@
-- ============================================================
-- é”€å”®æŠ¥ä»·é™ä»·è®°å½•表
-- é€‚用场景:记录报价单项目的历史价格变化
-- ç”Ÿæˆæ—¥æœŸï¼š2026-06-12
-- ============================================================
-- é”€å”®æŠ¥ä»·é™ä»·è®°å½•表
CREATE TABLE `sales_quotation_price_history` (
    `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
    `quotation_id` BIGINT NOT NULL COMMENT '报价单ID',
    `quotation_product_id` BIGINT NOT NULL COMMENT '报价商品ID',
    `product_name` VARCHAR(200) DEFAULT NULL COMMENT '商品名称(冗余存储)',
    `specification` VARCHAR(200) DEFAULT NULL COMMENT '商品规格(冗余存储)',
    `old_price` DECIMAL(24, 4) DEFAULT NULL COMMENT '原单价',
    `new_price` DECIMAL(24, 4) DEFAULT NULL COMMENT '新单价',
    `price_change` DECIMAL(24, 4) DEFAULT NULL COMMENT '价格变动(新-旧,负数表示降价)',
    `change_reason` VARCHAR(500) DEFAULT NULL COMMENT '变动原因',
    `import_batch` VARCHAR(50) DEFAULT NULL COMMENT '导入批次号',
    `import_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '导入时间',
    `create_user` BIGINT DEFAULT NULL COMMENT '操作人ID',
    `create_user_name` VARCHAR(100) DEFAULT NULL COMMENT '操作人姓名',
    `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    `tenant_id` BIGINT DEFAULT NULL COMMENT '租户ID',
    `dept_id` BIGINT DEFAULT NULL COMMENT '部门ID',
    PRIMARY KEY (`id`),
    KEY `idx_quotation_id` (`quotation_id`),
    KEY `idx_quotation_product_id` (`quotation_product_id`),
    KEY `idx_import_batch` (`import_batch`),
    KEY `idx_import_time` (`import_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='销售报价降价记录表';
-- é”€å”®æŠ¥ä»·å¯¼å…¥è®°å½•表
CREATE TABLE `sales_quotation_import_log` (
    `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
    `batch_no` VARCHAR(50) NOT NULL COMMENT '导入批次号',
    `file_name` VARCHAR(200) DEFAULT NULL COMMENT '导入文件名',
    `total_count` INT DEFAULT 0 COMMENT '总记录数',
    `success_count` INT DEFAULT 0 COMMENT '成功记录数',
    `update_count` INT DEFAULT 0 COMMENT '更新记录数',
    `new_count` INT DEFAULT 0 COMMENT '新增记录数',
    `fail_count` INT DEFAULT 0 COMMENT '失败记录数',
    `status` VARCHAR(20) DEFAULT 'pending' COMMENT '状态:pending-待审核, approved-已通过, rejected-已拒绝',
    `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注',
    `create_user` BIGINT DEFAULT NULL COMMENT '操作人ID',
    `create_user_name` VARCHAR(100) DEFAULT NULL COMMENT '操作人姓名',
    `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '导入时间',
    `audit_user` BIGINT DEFAULT NULL COMMENT '审核人ID',
    `audit_user_name` VARCHAR(100) DEFAULT NULL COMMENT '审核人姓名',
    `audit_time` DATETIME DEFAULT NULL COMMENT '审核时间',
    `tenant_id` BIGINT DEFAULT NULL COMMENT '租户ID',
    `dept_id` BIGINT DEFAULT NULL COMMENT '部门ID',
    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_batch_no` (`batch_no`),
    KEY `idx_status` (`status`),
    KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='销售报价导入记录表';
src/main/java/com/ruoyi/common/utils/poi/ExcelUtil.java
@@ -10,6 +10,7 @@
import java.lang.reflect.ParameterizedType;
import java.math.BigDecimal;
import java.text.DecimalFormat;
import java.text.SimpleDateFormat;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
@@ -2089,4 +2090,247 @@
        }
        return method;
    }
    /**
     * å¯¼å‡ºå¤šSheet Excel模板(静态方法)
     * æ”¯æŒä¸åŒç±»åž‹çš„DTO导出到不同的Sheet
     *
     * @param response HTTP响应
     * @param sheetDataMap Map<Sheet名称, SheetData>,SheetData包含数据列表和对应的Class类型
     * @param fileName æ–‡ä»¶å
     */
    @SuppressWarnings("unchecked")
    public static void exportExcelMultiSheet(HttpServletResponse response,
            Map<String, SheetData<?>> sheetDataMap, String fileName)
    {
        try (SXSSFWorkbook workbook = new SXSSFWorkbook())
        {
            // åˆ›å»ºæ ·å¼
            CellStyle headerStyle = createHeaderStyle(workbook);
            CellStyle dataStyle = createDataStyle(workbook);
            // éåŽ†æ¯ä¸ªSheet
            for (Map.Entry<String, SheetData<?>> entry : sheetDataMap.entrySet())
            {
                String sheetName = entry.getKey();
                SheetData<?> sheetData = entry.getValue();
                List<?> dataList = sheetData.getDataList();
                Class<?> clazz = sheetData.getClazz();
                // åˆ›å»ºSheet
                Sheet sheet = workbook.createSheet(sheetName);
                // èŽ·å–å­—æ®µä¿¡æ¯
                List<Object[]> fields = getFieldsByClass(clazz, Type.IMPORT);
                if (fields.isEmpty())
                {
                    continue;
                }
                // åˆ›å»ºè¡¨å¤´
                Row headerRow = sheet.createRow(0);
                int colIndex = 0;
                for (Object[] fieldObj : fields)
                {
                    Field field = (Field) fieldObj[0];
                    Excel excel = (Excel) fieldObj[1];
                    Cell cell = headerRow.createCell(colIndex);
                    cell.setCellValue(excel.name());
                    cell.setCellStyle(headerStyle);
                    // è®¾ç½®åˆ—宽
                    sheet.setColumnWidth(colIndex, (int) ((excel.width() + 0.72) * 256));
                    colIndex++;
                }
                // å†™å…¥æ•°æ®
                if (dataList != null && !dataList.isEmpty())
                {
                    int rowIndex = 1;
                    for (Object data : dataList)
                    {
                        Row dataRow = sheet.createRow(rowIndex);
                        colIndex = 0;
                        for (Object[] fieldObj : fields)
                        {
                            Field field = (Field) fieldObj[0];
                            Excel excel = (Excel) fieldObj[1];
                            field.setAccessible(true);
                            Object value = field.get(data);
                            Cell cell = dataRow.createCell(colIndex);
                            setCellValueByType(cell, value, excel, dataStyle, workbook);
                            colIndex++;
                        }
                        rowIndex++;
                    }
                }
            }
            // è¾“出到响应
            response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
            response.setCharacterEncoding("utf-8");
            String encodedFileName = new String(fileName.getBytes("GBK"), "ISO8859_1") + ".xlsx";
            response.addHeader("Content-Disposition", "attachment;filename=" + encodedFileName);
            workbook.write(response.getOutputStream());
        }
        catch (Exception e)
        {
            log.error("导出多Sheet Excel异常: {}", e.getMessage());
            throw new UtilException("导出Excel失败: " + e.getMessage());
        }
    }
    /**
     * Sheet数据封装类
     */
    public static class SheetData<T>
    {
        private List<T> dataList;
        private Class<T> clazz;
        public SheetData(List<T> dataList, Class<T> clazz)
        {
            this.dataList = dataList;
            this.clazz = clazz;
        }
        public List<T> getDataList()
        {
            return dataList;
        }
        public Class<T> getClazz()
        {
            return clazz;
        }
    }
    /**
     * æ ¹æ®ç±»èŽ·å–å­—æ®µåˆ—è¡¨ï¼ˆé™æ€æ–¹æ³•ï¼‰
     */
    private static List<Object[]> getFieldsByClass(Class<?> clazz, Type type)
    {
        List<Object[]> fields = new ArrayList<>();
        List<Field> tempFields = new ArrayList<>();
        tempFields.addAll(Arrays.asList(clazz.getSuperclass().getDeclaredFields()));
        tempFields.addAll(Arrays.asList(clazz.getDeclaredFields()));
        for (Field field : tempFields)
        {
            if (field.isAnnotationPresent(Excel.class))
            {
                Excel excel = field.getAnnotation(Excel.class);
                if (excel != null && (excel.type() == Type.ALL || excel.type() == type))
                {
                    fields.add(new Object[] { field, excel });
                }
            }
        }
        // æŒ‰sort排序
        fields.sort(Comparator.comparing(objects -> ((Excel) objects[1]).sort()));
        return fields;
    }
    /**
     * åˆ›å»ºè¡¨å¤´æ ·å¼
     */
    private static CellStyle createHeaderStyle(Workbook workbook)
    {
        CellStyle style = workbook.createCellStyle();
        style.setAlignment(HorizontalAlignment.CENTER);
        style.setVerticalAlignment(VerticalAlignment.CENTER);
        style.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex());
        style.setFillPattern(FillPatternType.SOLID_FOREGROUND);
        style.setBorderRight(BorderStyle.THIN);
        style.setRightBorderColor(IndexedColors.GREY_50_PERCENT.getIndex());
        style.setBorderLeft(BorderStyle.THIN);
        style.setLeftBorderColor(IndexedColors.GREY_50_PERCENT.getIndex());
        style.setBorderTop(BorderStyle.THIN);
        style.setTopBorderColor(IndexedColors.GREY_50_PERCENT.getIndex());
        style.setBorderBottom(BorderStyle.THIN);
        style.setBottomBorderColor(IndexedColors.GREY_50_PERCENT.getIndex());
        Font font = workbook.createFont();
        font.setFontName("Arial");
        font.setFontHeightInPoints((short) 10);
        font.setBold(true);
        style.setFont(font);
        DataFormat dataFormat = workbook.createDataFormat();
        style.setDataFormat(dataFormat.getFormat("@"));
        return style;
    }
    /**
     * åˆ›å»ºæ•°æ®æ ·å¼
     */
    private static CellStyle createDataStyle(Workbook workbook)
    {
        CellStyle style = workbook.createCellStyle();
        style.setAlignment(HorizontalAlignment.CENTER);
        style.setVerticalAlignment(VerticalAlignment.CENTER);
        style.setBorderRight(BorderStyle.THIN);
        style.setRightBorderColor(IndexedColors.GREY_50_PERCENT.getIndex());
        style.setBorderLeft(BorderStyle.THIN);
        style.setLeftBorderColor(IndexedColors.GREY_50_PERCENT.getIndex());
        style.setBorderTop(BorderStyle.THIN);
        style.setTopBorderColor(IndexedColors.GREY_50_PERCENT.getIndex());
        style.setBorderBottom(BorderStyle.THIN);
        style.setBottomBorderColor(IndexedColors.GREY_50_PERCENT.getIndex());
        Font font = workbook.createFont();
        font.setFontName("Arial");
        font.setFontHeightInPoints((short) 10);
        style.setFont(font);
        return style;
    }
    /**
     * è®¾ç½®å•元格值
     */
    private static void setCellValueByType(Cell cell, Object value, Excel excel, CellStyle dataStyle, Workbook workbook)
    {
        cell.setCellStyle(dataStyle);
        if (value == null)
        {
            cell.setCellValue("");
            return;
        }
        String dateFormat = excel.dateFormat();
        if (StringUtils.isNotEmpty(dateFormat) && value instanceof Date)
        {
            cell.setCellValue(new SimpleDateFormat(dateFormat).format((Date) value));
        }
        else if (value instanceof Date)
        {
            cell.setCellValue(new SimpleDateFormat("yyyy-MM-dd").format((Date) value));
        }
        else if (value instanceof BigDecimal)
        {
            cell.setCellValue(((BigDecimal) value).doubleValue());
        }
        else if (value instanceof Number)
        {
            cell.setCellValue(((Number) value).doubleValue());
        }
        else if (value instanceof Boolean)
        {
            cell.setCellValue((Boolean) value);
        }
        else
        {
            String strValue = Convert.toStr(value);
            // é˜²æ­¢CSV注入
            if (StringUtils.startsWithAny(strValue, FORMULA_STR))
            {
                strValue = RegExUtils.replaceFirst(strValue, FORMULA_REGEX_STR, "\t$0");
            }
            cell.setCellValue(strValue);
        }
    }
}
src/main/java/com/ruoyi/framework/web/exception/GlobalExceptionHandler.java
@@ -8,6 +8,7 @@
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingPathVariableException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
@@ -75,6 +76,17 @@
    }
    /**
     * è¯·æ±‚参数缺失
     */
    @ExceptionHandler(MissingServletRequestParameterException.class)
    public AjaxResult handleMissingServletRequestParameterException(MissingServletRequestParameterException e, HttpServletRequest request)
    {
        String requestURI = request.getRequestURI();
        log.error("请求地址'{}',缺少必需的请求参数'{}'", requestURI, e.getParameterName());
        return AjaxResult.error(String.format("缺少必需的请求参数[%s]", e.getParameterName()));
    }
    /**
     * è¯·æ±‚参数类型不匹配
     */
    @ExceptionHandler(MethodArgumentTypeMismatchException.class)
src/main/java/com/ruoyi/sales/controller/SalesQuotationController.java
@@ -1,46 +1,85 @@
package com.ruoyi.sales.controller;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.framework.web.domain.AjaxResult;
import com.ruoyi.framework.web.domain.R;
import com.ruoyi.sales.dto.SalesQuotationDto;
import com.ruoyi.sales.pojo.SalesQuotationImportLog;
import com.ruoyi.sales.pojo.SalesQuotationPriceHistory;
import com.ruoyi.sales.service.SalesQuotationService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import lombok.AllArgsConstructor;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
@RestController
@RequestMapping("/sales/quotation")
@AllArgsConstructor
@Tag(name = "销售报价管理")
public class SalesQuotationController {
    private final SalesQuotationService salesQuotationService;
    @GetMapping("/list")
    @Operation(summary = "分页查询报价单列表")
    public AjaxResult getList(Page page, SalesQuotationDto salesQuotationDto) {
        return AjaxResult.success(salesQuotationService.listPage(page, salesQuotationDto));
    }
    @PostMapping("/export")
    @Operation(summary = "导出报价单")
    public void export(HttpServletResponse response) {
        Page page = new Page(-1,-1);
        SalesQuotationDto afterSalesService = new SalesQuotationDto();
        IPage<SalesQuotationDto> listPage = salesQuotationService.listPage(page, afterSalesService);
        ExcelUtil<SalesQuotationDto> util = new ExcelUtil<SalesQuotationDto>(SalesQuotationDto.class);
        ExcelUtil<SalesQuotationDto> util = new ExcelUtil<>(SalesQuotationDto.class);
        util.exportExcel(response, listPage.getRecords() , "销售报价");
    }
    @PostMapping("/add")
    @Operation(summary = "新增报价单")
    public AjaxResult add(@RequestBody SalesQuotationDto salesQuotationDto) {
        return AjaxResult.success(salesQuotationService.add(salesQuotationDto));
    }
    @PostMapping("/update")
    @Operation(summary = "修改报价单")
    public AjaxResult update(@RequestBody SalesQuotationDto salesQuotationDto) {
        return AjaxResult.success(salesQuotationService.edit(salesQuotationDto));
    }
    @DeleteMapping("/delete")
    @Operation(summary = "删除报价单")
    public AjaxResult delete(@RequestBody Long id) {
        return AjaxResult.success(salesQuotationService.delete(id));
    }
    @GetMapping("/downloadTemplate")
    @Operation(summary = "下载报价导入模板")
    public void downloadTemplate(HttpServletResponse response) {
        salesQuotationService.downloadTemplate(response);
    }
    @PostMapping("/import")
    @Operation(summary = "导入报价单")
    public R<SalesQuotationImportLog> importQuotation(@RequestParam("file") MultipartFile file) {
        return R.ok(salesQuotationService.importQuotation(file));
    }
    @GetMapping("/importLog/list")
    @Operation(summary = "查询导入记录列表")
    public R<IPage<SalesQuotationImportLog>> listImportLog(Page page) {
        return R.ok(salesQuotationService.listImportLog(page));
    }
    @GetMapping("/priceHistory/list")
    @Operation(summary = "查询降价历史记录")
    public R<List<SalesQuotationPriceHistory>> listPriceHistory(@RequestParam("quotationProductId") Long quotationProductId) {
        return R.ok(salesQuotationService.listPriceHistory(quotationProductId));
    }
}
src/main/java/com/ruoyi/sales/dto/SalesQuotationImportDto.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,44 @@
package com.ruoyi.sales.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.ruoyi.framework.aspectj.lang.annotation.Excel;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.math.BigDecimal;
import java.util.Date;
/**
 * é”€å”®æŠ¥ä»·å¯¼å…¥DTO(兼容单Sheet导入)
 */
@Data
@EqualsAndHashCode(callSuper = true)
public class SalesQuotationImportDto extends SalesQuotationProductImportDto {
    @Excel(name = "客户名称")
    @Schema(description = "客户名称")
    private String customerName;
    @Excel(name = "业务员")
    @Schema(description = "业务员")
    private String salesperson;
    @JsonFormat(pattern = "yyyy-MM-dd")
    @Excel(name = "报价日期", width = 30, dateFormat = "yyyy-MM-dd")
    @Schema(description = "报价日期")
    private Date quotationDate;
    @JsonFormat(pattern = "yyyy-MM-dd")
    @Excel(name = "有效期至", width = 30, dateFormat = "yyyy-MM-dd")
    @Schema(description = "有效期至")
    private Date validDate;
    @Excel(name = "付款方式")
    @Schema(description = "付款方式")
    private String paymentMethod;
    @Excel(name = "交货周期")
    @Schema(description = "交货周期")
    private String deliveryPeriod;
}
src/main/java/com/ruoyi/sales/dto/SalesQuotationMainImportDto.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,50 @@
package com.ruoyi.sales.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.ruoyi.framework.aspectj.lang.annotation.Excel;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
import java.util.Date;
/**
 * é”€å”®æŠ¥ä»·ä¸»è¡¨å¯¼å…¥DTO(报价单数据Sheet)
 */
@Data
public class SalesQuotationMainImportDto {
    @Excel(name = "报价单号")
    @Schema(description = "报价单号(可选,不填自动生成)")
    private String quotationNo;
    @Excel(name = "客户名称")
    @Schema(description = "客户名称")
    private String customerName;
    @Excel(name = "业务员")
    @Schema(description = "业务员")
    private String salesperson;
    @JsonFormat(pattern = "yyyy-MM-dd")
    @Excel(name = "报价日期", width = 30, dateFormat = "yyyy-MM-dd")
    @Schema(description = "报价日期")
    private Date quotationDate;
    @JsonFormat(pattern = "yyyy-MM-dd")
    @Excel(name = "有效期至", width = 30, dateFormat = "yyyy-MM-dd")
    @Schema(description = "有效期至")
    private Date validDate;
    @Excel(name = "付款方式")
    @Schema(description = "付款方式")
    private String paymentMethod;
    @Excel(name = "交货周期")
    @Schema(description = "交货周期")
    private String deliveryPeriod;
    @Excel(name = "备注")
    @Schema(description = "备注")
    private String remark;
}
src/main/java/com/ruoyi/sales/dto/SalesQuotationProductImportDto.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,38 @@
package com.ruoyi.sales.dto;
import com.ruoyi.framework.aspectj.lang.annotation.Excel;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
/**
 * é”€å”®æŠ¥ä»·äº§å“å¯¼å…¥DTO(报价产品数据Sheet)
 */
@Data
public class SalesQuotationProductImportDto {
    @Excel(name = "报价单号")
    @Schema(description = "报价单号(关联主表)")
    private String quotationNo;
    @Excel(name = "产品大类")
    @Schema(description = "产品大类")
    private String productCategory;
    @Excel(name = "规格型号")
    @Schema(description = "规格型号")
    private String specificationModel;
    @Excel(name = "单位")
    @Schema(description = "单位")
    private String unit;
    @Excel(name = "单价")
    @Schema(description = "单价")
    private BigDecimal unitPrice;
    @Excel(name = "备注")
    @Schema(description = "备注")
    private String remark;
}
src/main/java/com/ruoyi/sales/mapper/SalesQuotationImportLogMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,9 @@
package com.ruoyi.sales.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.sales.pojo.SalesQuotationImportLog;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SalesQuotationImportLogMapper extends BaseMapper<SalesQuotationImportLog> {
}
src/main/java/com/ruoyi/sales/mapper/SalesQuotationPriceHistoryMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,9 @@
package com.ruoyi.sales.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.sales.pojo.SalesQuotationPriceHistory;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SalesQuotationPriceHistoryMapper extends BaseMapper<SalesQuotationPriceHistory> {
}
src/main/java/com/ruoyi/sales/pojo/SalesQuotationImportLog.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,74 @@
package com.ruoyi.sales.pojo;
import com.baomidou.mybatisplus.annotation.*;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
/**
 * é”€å”®æŠ¥ä»·å¯¼å…¥è®°å½•
 */
@Data
@TableName("sales_quotation_import_log")
@Schema(description = "销售报价导入记录")
public class SalesQuotationImportLog {
    @TableId(value = "id", type = IdType.AUTO)
    @Schema(description = "主键ID")
    private Long id;
    @Schema(description = "导入批次号")
    private String batchNo;
    @Schema(description = "导入文件名")
    private String fileName;
    @Schema(description = "总记录数")
    private Integer totalCount;
    @Schema(description = "成功记录数")
    private Integer successCount;
    @Schema(description = "更新记录数")
    private Integer updateCount;
    @Schema(description = "新增记录数")
    private Integer newCount;
    @Schema(description = "失败记录数")
    private Integer failCount;
    @Schema(description = "状态:pending-待审核, approved-已通过, rejected-已拒绝")
    private String status;
    @Schema(description = "备注")
    private String remark;
    @Schema(description = "操作人ID")
    private Long createUser;
    @Schema(description = "操作人姓名")
    private String createUserName;
    @Schema(description = "导入时间")
    @TableField(fill = FieldFill.INSERT)
    private LocalDateTime createTime;
    @Schema(description = "审核人ID")
    private Long auditUser;
    @Schema(description = "审核人姓名")
    private String auditUserName;
    @Schema(description = "审核时间")
    private LocalDateTime auditTime;
    @Schema(description = "租户ID")
    @TableField(fill = FieldFill.INSERT)
    private Long tenantId;
    @Schema(description = "部门ID")
    @TableField(fill = FieldFill.INSERT)
    private Long deptId;
}
src/main/java/com/ruoyi/sales/pojo/SalesQuotationPriceHistory.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,69 @@
package com.ruoyi.sales.pojo;
import com.baomidou.mybatisplus.annotation.*;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
 * é”€å”®æŠ¥ä»·é™ä»·è®°å½•
 */
@Data
@TableName("sales_quotation_price_history")
@Schema(description = "销售报价降价记录")
public class SalesQuotationPriceHistory {
    @TableId(value = "id", type = IdType.AUTO)
    @Schema(description = "主键ID")
    private Long id;
    @Schema(description = "报价单ID")
    private Long quotationId;
    @Schema(description = "报价商品ID")
    private Long quotationProductId;
    @Schema(description = "商品名称")
    private String productName;
    @Schema(description = "商品规格")
    private String specification;
    @Schema(description = "原单价")
    private BigDecimal oldPrice;
    @Schema(description = "新单价")
    private BigDecimal newPrice;
    @Schema(description = "价格变动(新-旧,负数表示降价)")
    private BigDecimal priceChange;
    @Schema(description = "变动原因")
    private String changeReason;
    @Schema(description = "导入批次号")
    private String importBatch;
    @Schema(description = "导入时间")
    private LocalDateTime importTime;
    @Schema(description = "操作人ID")
    private Long createUser;
    @Schema(description = "操作人姓名")
    private String createUserName;
    @Schema(description = "创建时间")
    @TableField(fill = FieldFill.INSERT)
    private LocalDateTime createTime;
    @Schema(description = "租户ID")
    @TableField(fill = FieldFill.INSERT)
    private Long tenantId;
    @Schema(description = "部门ID")
    @TableField(fill = FieldFill.INSERT)
    private Long deptId;
}
src/main/java/com/ruoyi/sales/service/SalesQuotationService.java
@@ -3,8 +3,15 @@
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.service.IService;
import com.ruoyi.sales.dto.SalesQuotationDto;
import com.ruoyi.sales.dto.SalesQuotationImportDto;
import com.ruoyi.sales.pojo.SalesQuotation;
import com.ruoyi.sales.pojo.SalesQuotationPriceHistory;
import com.ruoyi.sales.pojo.SalesQuotationImportLog;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.springframework.web.multipart.MultipartFile;
import jakarta.servlet.http.HttpServletResponse;
import java.util.List;
public interface SalesQuotationService extends IService<SalesQuotation> {
    IPage listPage(Page page, SalesQuotationDto salesQuotationDto);
@@ -14,4 +21,27 @@
    boolean delete(Long id);
    boolean edit(SalesQuotationDto salesQuotationDto);
    /**
     * ä¸‹è½½æŠ¥ä»·æ¨¡æ¿
     */
    void downloadTemplate(HttpServletResponse response);
    /**
     * å¯¼å…¥æŠ¥ä»·å•(导入前检查审批模板是否存在)
     * @param file å¯¼å…¥æ–‡ä»¶
     * @return å¯¼å…¥ç»“æžœ
     */
    SalesQuotationImportLog importQuotation(MultipartFile file);
    /**
     * æŸ¥è¯¢å¯¼å…¥è®°å½•列表
     */
    IPage<SalesQuotationImportLog> listImportLog(Page page);
    /**
     * æŸ¥è¯¢é™ä»·åŽ†å²è®°å½•
     * @param quotationId æŠ¥ä»·å•†å“ID
     */
    List<SalesQuotationPriceHistory> listPriceHistory(Long quotationId);
}
src/main/java/com/ruoyi/sales/service/impl/SalesQuotationServiceImpl.java
@@ -18,25 +18,39 @@
import com.ruoyi.basic.mapper.CustomerMapper;
import com.ruoyi.basic.pojo.Customer;
import com.ruoyi.common.enums.IsDeleteEnum;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.OrderUtils;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.bean.BeanUtils;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.framework.security.LoginUser;
import com.ruoyi.sales.dto.SalesQuotationDto;
import com.ruoyi.sales.dto.SalesQuotationImportDto;
import com.ruoyi.sales.dto.SalesQuotationMainImportDto;
import com.ruoyi.sales.dto.SalesQuotationProductImportDto;
import com.ruoyi.sales.mapper.SalesQuotationImportLogMapper;
import com.ruoyi.sales.mapper.SalesQuotationMapper;
import com.ruoyi.sales.mapper.SalesQuotationPriceHistoryMapper;
import com.ruoyi.sales.mapper.SalesQuotationProductMapper;
import com.ruoyi.sales.pojo.SalesQuotation;
import com.ruoyi.sales.pojo.SalesQuotationProduct;
import com.ruoyi.sales.pojo.*;
import com.ruoyi.sales.service.SalesQuotationProductService;
import com.ruoyi.sales.service.SalesQuotationService;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.stream.Collectors;
@Slf4j
@Service
@Transactional(rollbackFor = Exception.class)
@RequiredArgsConstructor
@@ -44,6 +58,8 @@
    private final SalesQuotationProductMapper salesQuotationProductMapper;
    private final SalesQuotationMapper salesQuotationMapper;
    private final SalesQuotationProductService salesQuotationProductService;
    private final SalesQuotationPriceHistoryMapper priceHistoryMapper;
    private final SalesQuotationImportLogMapper importLogMapper;
    private final ApproveProcessServiceImpl approveProcessService;
    private final CustomerMapper customerMapper;
@@ -85,7 +101,7 @@
        SalesQuotation salesQuotation = new SalesQuotation();
        BeanUtils.copyProperties(salesQuotationDto, salesQuotation);
        salesQuotation.setId(null);
        Customer customer = customerMapper.selectById(Long.valueOf(salesQuotationDto.getCustomerId()));
        Customer customer = customerMapper.selectById(salesQuotationDto.getCustomerId());
        if (ObjectUtils.isNotEmpty(customer))  {
            salesQuotation.setCustomer(customer.getCustomerName());
        }
@@ -132,6 +148,7 @@
        }
        return true;
    }
    @Override
    public boolean edit(SalesQuotationDto salesQuotationDto) {
        SalesQuotation salesQuotation = new SalesQuotation();
@@ -188,6 +205,7 @@
        }
        return true;
    }
    @Override
    public boolean delete(Long id) {
        SalesQuotation salesQuotation = salesQuotationMapper.selectById(id);
@@ -205,5 +223,309 @@
        return true;
    }
    @Override
    public void downloadTemplate(HttpServletResponse response) {
        // æŠ¥ä»·å•数据示例
        List<SalesQuotationMainImportDto> mainList = new ArrayList<>();
        SalesQuotationMainImportDto mainExample = new SalesQuotationMainImportDto();
        mainExample.setQuotationNo("QT202606120001");
        mainExample.setCustomerName("示例客户");
        mainExample.setSalesperson("张三");
        mainExample.setPaymentMethod("月结30天");
        mainExample.setDeliveryPeriod("7天");
        mainExample.setRemark("示例报价单");
        mainList.add(mainExample);
        // æŠ¥ä»·äº§å“æ•°æ®ç¤ºä¾‹
        List<SalesQuotationProductImportDto> productList = new ArrayList<>();
        SalesQuotationProductImportDto productExample1 = new SalesQuotationProductImportDto();
        productExample1.setQuotationNo("QT202606120001");
        productExample1.setProductCategory("电池组件");
        productExample1.setSpecificationModel("MODEL-A-100W");
        productExample1.setUnit("片");
        productExample1.setUnitPrice(new BigDecimal("150.00"));
        productList.add(productExample1);
        SalesQuotationProductImportDto productExample2 = new SalesQuotationProductImportDto();
        productExample2.setQuotationNo("QT202606120001");
        productExample2.setProductCategory("电池组件");
        productExample2.setSpecificationModel("MODEL-B-200W");
        productExample2.setUnit("片");
        productExample2.setUnitPrice(new BigDecimal("200.00"));
        productList.add(productExample2);
        // ä½¿ç”¨é™æ€æ–¹æ³•导出多Sheet模板
        Map<String, ExcelUtil.SheetData<?>> sheetDataMap = new LinkedHashMap<>();
        sheetDataMap.put("报价单数据", new ExcelUtil.SheetData<>(mainList, SalesQuotationMainImportDto.class));
        sheetDataMap.put("报价产品数据", new ExcelUtil.SheetData<>(productList, SalesQuotationProductImportDto.class));
        ExcelUtil.exportExcelMultiSheet(response, sheetDataMap, "销售报价导入模板");
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public SalesQuotationImportLog importQuotation(MultipartFile file) {
        LoginUser loginUser = SecurityUtils.getLoginUser();
        // æ£€æŸ¥å®¡æ‰¹æ¨¡æ¿æ˜¯å¦å­˜åœ¨
        ApprovalTemplate approvalTemplate = approvalTemplateMapper.selectOne(
                new LambdaQueryWrapper<ApprovalTemplate>()
                        .eq(ApprovalTemplate::getBusinessType, 6L)
                        .eq(ApprovalTemplate::getDeleted, 0)
                        .orderByDesc(ApprovalTemplate::getId)
                        .last("LIMIT 1")
        );
        if (approvalTemplate == null) {
            throw new ServiceException("请先配置报价审批模板,无法导入");
        }
        // è§£æžå¤šSheet Excel文件
        ExcelUtil<SalesQuotationMainImportDto> mainUtil = new ExcelUtil<>(SalesQuotationMainImportDto.class);
        Map<String, List<SalesQuotationMainImportDto>> sheetMap;
        try {
            sheetMap = mainUtil.importExcelMultiSheet(Arrays.asList("报价单数据", "报价产品数据"), file.getInputStream(), 0);
        } catch (IOException e) {
            throw new ServiceException("读取文件失败: " + e.getMessage());
        }
        List<SalesQuotationMainImportDto> mainList = sheetMap.get("报价单数据");
        List<SalesQuotationMainImportDto> productListRaw = sheetMap.get("报价产品数据");
        if (CollectionUtils.isEmpty(mainList)) {
            throw new ServiceException("报价单数据为空,请检查模板内容");
        }
        // å°†äº§å“æ•°æ®è½¬ä¸ºæ­£ç¡®çš„DTO类型
        ExcelUtil<SalesQuotationProductImportDto> productUtil = new ExcelUtil<>(SalesQuotationProductImportDto.class);
        Map<String, List<SalesQuotationProductImportDto>> productSheetMap;
        try {
            productSheetMap = productUtil.importExcelMultiSheet(Arrays.asList("报价产品数据"), file.getInputStream(), 0);
        } catch (IOException e) {
            throw new ServiceException("读取产品数据失败: " + e.getMessage());
        }
        List<SalesQuotationProductImportDto> productList = productSheetMap.get("报价产品数据");
        // ç”Ÿæˆæ‰¹æ¬¡å·
        String batchNo = "QT_IMP_" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
        // åˆ›å»ºå¯¼å…¥è®°å½•
        SalesQuotationImportLog importLog = new SalesQuotationImportLog();
        importLog.setBatchNo(batchNo);
        importLog.setFileName(file.getOriginalFilename());
        importLog.setTotalCount(mainList.size());
        importLog.setSuccessCount(0);
        importLog.setUpdateCount(0);
        importLog.setNewCount(0);
        importLog.setFailCount(0);
        importLog.setStatus("completed");
        importLog.setCreateUser(loginUser.getUserId());
        importLog.setCreateUserName(loginUser.getNickName());
        importLogMapper.insert(importLog);
        // æŸ¥è¯¢ç›¸å…³æ•°æ®
        List<Customer> customers = customerMapper.selectList(
                new LambdaQueryWrapper<Customer>()
                        .in(Customer::getCustomerName, mainList.stream()
                                .map(SalesQuotationMainImportDto::getCustomerName)
                                .filter(Objects::nonNull)
                                .collect(Collectors.toList()))
        );
        Map<String, Customer> customerMap = customers.stream()
                .collect(Collectors.toMap(Customer::getCustomerName, c -> c, (a, b) -> a));
        // æŒ‰æŠ¥ä»·å•号分组产品
        Map<String, List<SalesQuotationProductImportDto>> productGroupMap = new HashMap<>();
        if (!CollectionUtils.isEmpty(productList)) {
            productGroupMap = productList.stream()
                    .filter(p -> p.getQuotationNo() != null && !p.getQuotationNo().isEmpty())
                    .collect(Collectors.groupingBy(SalesQuotationProductImportDto::getQuotationNo));
        }
        int successCount = 0;
        int newCount = 0;
        int updateCount = 0;
        int failCount = 0;
        for (SalesQuotationMainImportDto mainDto : mainList) {
            try {
                String customerName = mainDto.getCustomerName();
                if (customerName == null || customerName.isEmpty()) {
                    failCount++;
                    continue;
                }
                // æŸ¥æ‰¾å®¢æˆ·
                Customer customer = customerMap.get(customerName);
                // åˆ›å»ºæŠ¥ä»·å•
                SalesQuotation quotation = new SalesQuotation();
                quotation.setCustomer(customerName);
                quotation.setCustomerId(customer != null ? customer.getId() : null);
                // ç”ŸæˆæŠ¥ä»·å•号
                String quotationNo;
                if (mainDto.getQuotationNo() != null && !mainDto.getQuotationNo().isEmpty()) {
                    quotationNo = mainDto.getQuotationNo();
                } else {
                    quotationNo = OrderUtils.countTodayByCreateTime(salesQuotationMapper, "QT", "quotation_no", LocalDateTime.now());
                }
                quotation.setQuotationNo(quotationNo);
                // æ£€æŸ¥æŠ¥ä»·å•号是否已存在
                SalesQuotation existing = salesQuotationMapper.selectOne(
                        new LambdaQueryWrapper<SalesQuotation>()
                                .eq(SalesQuotation::getQuotationNo, quotationNo)
                                .last("LIMIT 1")
                );
                boolean isUpdate = existing != null;
                if (isUpdate) {
                    quotation.setId(existing.getId());
                    quotation.setQuotationDate(existing.getQuotationDate());
                    quotation.setStatus(existing.getStatus());
                } else {
                    quotation.setQuotationDate(LocalDate.now());
                    quotation.setStatus("待审批");
                }
                quotation.setSalesperson(mainDto.getSalesperson());
                quotation.setPaymentMethod(mainDto.getPaymentMethod());
                quotation.setDeliveryPeriod(mainDto.getDeliveryPeriod());
                quotation.setRemark("导入批次号: " + batchNo + (mainDto.getRemark() != null ? "," + mainDto.getRemark() : ""));
                // è®¡ç®—总金额并保存产品
                BigDecimal totalAmount = BigDecimal.ZERO;
                List<SalesQuotationProduct> quotationProducts = new ArrayList<>();
                List<SalesQuotationProductImportDto> products = productGroupMap.get(quotationNo);
                if (!CollectionUtils.isEmpty(products)) {
                    for (SalesQuotationProductImportDto productDto : products) {
                        SalesQuotationProduct product = new SalesQuotationProduct();
                        product.setProduct(productDto.getProductCategory());
                        product.setSpecification(productDto.getSpecificationModel());
                        product.setUnit(productDto.getUnit());
                        product.setQuantity(0);
                        product.setUnitPrice(productDto.getUnitPrice() != null ? productDto.getUnitPrice().doubleValue() : 0.0);
                        BigDecimal amount = productDto.getUnitPrice() != null
                                ? productDto.getUnitPrice()
                                : BigDecimal.ZERO;
                        product.setAmount(amount.doubleValue());
                        totalAmount = totalAmount.add(amount);
                        quotationProducts.add(product);
                    }
                }
                quotation.setTotalAmount(totalAmount);
                if (isUpdate) {
                    salesQuotationMapper.updateById(quotation);
                    // åˆ é™¤æ—§äº§å“æ•°æ®
                    salesQuotationProductMapper.delete(new LambdaQueryWrapper<SalesQuotationProduct>()
                            .eq(SalesQuotationProduct::getSalesQuotationId, existing.getId()));
                } else {
                    salesQuotationMapper.insert(quotation);
                }
                // ä¿å­˜äº§å“å¹¶è®°å½•降价历史
                for (SalesQuotationProduct product : quotationProducts) {
                    product.setSalesQuotationId(quotation.getId());
                    salesQuotationProductMapper.insert(product);
                    recordPriceHistory(quotation.getId(), product, batchNo);
                }
                if (!isUpdate) {
                    // åˆ›å»ºå®¡æ‰¹å®žä¾‹
                    ApprovalInstanceDto approvalInstance = new ApprovalInstanceDto();
                    approvalInstance.setTemplateId(approvalTemplate.getId());
                    approvalInstance.setTemplateName(approvalTemplate.getTemplateName());
                    approvalInstance.setBusinessId(quotation.getId());
                    approvalInstance.setBusinessType(6L);
                    approvalInstance.setCurrentLevel(1);
                    approvalInstance.setTitle(quotationNo + "审批");
                    approvalInstance.setApplicantId(loginUser.getUserId());
                    approvalInstance.setApplicantName(loginUser.getNickName());
                    approvalInstance.setApplyTime(LocalDateTime.now());
                    approvalInstanceService.add(approvalInstance);
                }
                successCount++;
                if (isUpdate) {
                    updateCount++;
                } else {
                    newCount++;
                }
            } catch (Exception e) {
                log.error("导入报价单失败: {}", e.getMessage());
                failCount++;
            }
        }
        // æ›´æ–°å¯¼å…¥è®°å½•
        importLog.setSuccessCount(successCount);
        importLog.setNewCount(newCount);
        importLog.setUpdateCount(updateCount);
        importLog.setFailCount(failCount);
        importLogMapper.updateById(importLog);
        return importLog;
    }
    /**
     * è®°å½•降价历史
     */
    private void recordPriceHistory(Long quotationId, SalesQuotationProduct product, String batchNo) {
        LoginUser loginUser = SecurityUtils.getLoginUser();
        // æŸ¥æ‰¾ç›¸åŒé¡¹ç›®åç§°çš„历史报价产品
        List<SalesQuotationProduct> historyProducts = salesQuotationProductMapper.selectList(
                new LambdaQueryWrapper<SalesQuotationProduct>()
                        .eq(SalesQuotationProduct::getProduct, product.getProduct())
                        .ne(SalesQuotationProduct::getId, product.getId())
                        .orderByDesc(SalesQuotationProduct::getCreateTime)
                        .last("LIMIT 1")
        );
        if (!historyProducts.isEmpty()) {
            SalesQuotationProduct historyProduct = historyProducts.get(0);
            BigDecimal oldPrice = historyProduct.getUnitPrice() != null
                    ? new BigDecimal(historyProduct.getUnitPrice().toString())
                    : BigDecimal.ZERO;
            BigDecimal newPrice = product.getUnitPrice() != null
                    ? new BigDecimal(product.getUnitPrice().toString())
                    : BigDecimal.ZERO;
            // å¦‚果价格有变化,记录降价历史
            if (oldPrice.compareTo(newPrice) != 0) {
                SalesQuotationPriceHistory priceHistory = new SalesQuotationPriceHistory();
                priceHistory.setQuotationId(quotationId);
                priceHistory.setQuotationProductId(product.getId());
                priceHistory.setProductName(product.getProduct());
                priceHistory.setSpecification(product.getSpecification());
                priceHistory.setOldPrice(oldPrice);
                priceHistory.setNewPrice(newPrice);
                priceHistory.setPriceChange(newPrice.subtract(oldPrice));
                priceHistory.setImportBatch(batchNo);
                priceHistory.setImportTime(LocalDateTime.now());
                priceHistory.setCreateUser(loginUser.getUserId());
                priceHistory.setCreateUserName(loginUser.getNickName());
                priceHistory.setChangeReason(newPrice.compareTo(oldPrice) < 0 ? "降价" : "涨价");
                priceHistoryMapper.insert(priceHistory);
            }
        }
    }
    @Override
    public IPage<SalesQuotationImportLog> listImportLog(Page page) {
        return importLogMapper.selectPage(page,
                new LambdaQueryWrapper<SalesQuotationImportLog>()
                        .orderByDesc(SalesQuotationImportLog::getCreateTime));
    }
    @Override
    public List<SalesQuotationPriceHistory> listPriceHistory(Long quotationId) {
        return priceHistoryMapper.selectList(
                new LambdaQueryWrapper<SalesQuotationPriceHistory>()
                        .eq(SalesQuotationPriceHistory::getQuotationId, quotationId)
                        .orderByDesc(SalesQuotationPriceHistory::getImportTime));
    }
}
src/main/resources/mapper/sales/SalesQuotationImportLogMapper.xml
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.sales.mapper.SalesQuotationImportLogMapper">
</mapper>
src/main/resources/mapper/sales/SalesQuotationPriceHistoryMapper.xml
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.sales.mapper.SalesQuotationPriceHistoryMapper">
</mapper>