From c21cb3d8cebd9cdef4df2efa372c5b9257c713e4 Mon Sep 17 00:00:00 2001
From: 吴羡 <16976746+Xian-TT3@user.noreply.gitee.com>
Date: 星期三, 28 十月 2026 17:57:40 +0800
Subject: [PATCH] feat(device,supply,satisfaction): 设备计划后端改造、耗材存放位置与满意度照片支持

---
 cnas-device/src/main/java/com/ruoyi/device/service/impl/DeviceExaminePlanServiceImpl.java |  781 ++++++++++++++++++++++++++++++++++++++++++++++++++++---
 1 files changed, 737 insertions(+), 44 deletions(-)

diff --git a/cnas-device/src/main/java/com/ruoyi/device/service/impl/DeviceExaminePlanServiceImpl.java b/cnas-device/src/main/java/com/ruoyi/device/service/impl/DeviceExaminePlanServiceImpl.java
index 4a4d45b..5e8fd20 100644
--- a/cnas-device/src/main/java/com/ruoyi/device/service/impl/DeviceExaminePlanServiceImpl.java
+++ b/cnas-device/src/main/java/com/ruoyi/device/service/impl/DeviceExaminePlanServiceImpl.java
@@ -9,8 +9,6 @@
 import com.baomidou.mybatisplus.core.toolkit.Wrappers;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
-import com.deepoove.poi.XWPFTemplate;
-import com.deepoove.poi.config.Configure;
 import com.ruoyi.common.constant.MenuJumpPathConstants;
 import com.ruoyi.common.core.domain.entity.InformationNotification;
 import com.ruoyi.common.core.domain.entity.User;
@@ -20,20 +18,40 @@
 import com.ruoyi.device.dto.DeviceExaminePlanDetailsDto;
 import com.ruoyi.device.dto.DeviceExaminePlanDto;
 import com.ruoyi.device.excel.upload.DeviceExaminePlanUpload;
+import com.ruoyi.device.mapper.DeviceExamineAttachmentMapper;
 import com.ruoyi.device.mapper.DeviceExaminePlanDetailsMapper;
 import com.ruoyi.device.mapper.DeviceExaminePlanMapper;
+import com.ruoyi.device.mapper.DeviceExamineRecordContrastDetailsMapper;
+import com.ruoyi.device.mapper.DeviceExamineRecordContrastMapper;
+import com.ruoyi.device.mapper.DeviceExamineRecordDetailMapper;
+import com.ruoyi.device.mapper.DeviceExamineRecordMapper;
 import com.ruoyi.device.mapper.DeviceMapper;
 import com.ruoyi.device.pojo.Device;
+import com.ruoyi.device.pojo.DeviceExamineAttachment;
 import com.ruoyi.device.pojo.DeviceExaminePlan;
 import com.ruoyi.device.pojo.DeviceExaminePlanDetails;
+import com.ruoyi.device.pojo.DeviceExamineRecord;
+import com.ruoyi.device.pojo.DeviceExamineRecordContrast;
+import com.ruoyi.device.pojo.DeviceExamineRecordContrastDetails;
+import com.ruoyi.device.pojo.DeviceExamineRecordDetail;
 import com.ruoyi.device.service.DeviceExaminePlanDetailsService;
 import com.ruoyi.device.service.DeviceExaminePlanService;
 import com.ruoyi.framework.exception.ErrorException;
-import com.ruoyi.inspect.util.HackLoopTableRenderPolicy;
-import com.ruoyi.inspect.util.UserUtils;
 import com.ruoyi.system.mapper.UserMapper;
 import com.ruoyi.system.service.InformationNotificationService;
+import org.apache.poi.ss.usermodel.BorderStyle;
+import org.apache.poi.ss.usermodel.Cell;
+import org.apache.poi.ss.usermodel.CellStyle;
+import org.apache.poi.ss.usermodel.Font;
+import org.apache.poi.ss.usermodel.HorizontalAlignment;
+import org.apache.poi.ss.usermodel.Row;
+import org.apache.poi.ss.usermodel.Sheet;
+import org.apache.poi.ss.usermodel.VerticalAlignment;
+import org.apache.poi.ss.usermodel.Workbook;
+import org.apache.poi.ss.util.CellRangeAddress;
+import org.apache.poi.xssf.usermodel.XSSFWorkbook;
 import org.springframework.beans.BeanUtils;
+import org.springframework.beans.factory.annotation.Value;
 import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
@@ -41,14 +59,20 @@
 
 import jakarta.annotation.Resource;
 import jakarta.servlet.http.HttpServletResponse;
+import java.io.File;
 import java.io.IOException;
-import java.io.InputStream;
 import java.io.OutputStream;
 import java.net.URLEncoder;
+import java.time.LocalDate;
 import java.time.LocalDateTime;
+import java.time.YearMonth;
+import java.time.format.DateTimeFormatter;
+import java.time.temporal.ChronoUnit;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
 
 /**
  * <p>
@@ -74,6 +98,22 @@
     private DeviceMapper deviceMapper;
     @Resource
     private UserMapper userMapper;
+    @Resource
+    private DeviceExamineRecordMapper deviceExamineRecordMapper;
+    @Resource
+    private DeviceExamineRecordContrastMapper deviceExamineRecordContrastMapper;
+    @Resource
+    private DeviceExamineRecordDetailMapper deviceExamineRecordDetailMapper;
+    @Resource
+    private DeviceExamineRecordContrastDetailsMapper deviceExamineRecordContrastDetailsMapper;
+    @Resource
+    private DeviceExamineAttachmentMapper deviceExamineAttachmentMapper;
+
+    @Value("${file.path}")
+    private String filePath;
+
+    @Value("${wordUrl}")
+    private String wordUrl;
 
     /**
      * 鏂板璁惧鏍告煡璁″垝
@@ -152,6 +192,7 @@
                     throw new ErrorException("璁惧缂栧彿" + examinePlanDetail.getDeviceNumber() + "鏈煡璇㈠埌鏍告煡浜�");
                 }
                 examinePlanDetail.setDeviceId(device.getId());
+                examinePlanDetail.setDeviceModel(device.getSpecificationModel());
                 examinePlanDetail.setCheckChargerUserId(user.getId());
             }
 
@@ -258,57 +299,196 @@
     }
 
     /**
-     * 瀵煎嚭璁惧鏍告煡璁″垝
+     * 瀵煎嚭璁惧鏍告煡璁″垝锛圗xcel锛夛細鏈夌瓫閫夋潯浠舵椂瀵煎嚭绛涢�夌粨鏋滐紝娌℃湁绛涢�夋椂瀵煎嚭璇ュ勾浠藉叏閮�
      * @param deviceExaminePlanId 璁惧鏍告煡璁″垝id
+     * @param deviceName 璁惧鍚嶇О绛涢��
+     * @param deviceNumber 璁惧缂栧彿绛涢��
      * @param response 鍝嶅簲浣�
      */
     @Override
-    public void exportDeviceExaminePlanDetail(Integer deviceExaminePlanId, HttpServletResponse response) {
-        // 鏌ヨ璁惧鏍告煡璁″垝
-        DeviceExaminePlanDto deviceExaminePlanDto = baseMapper.selectExamineExaminePlanDto(deviceExaminePlanId);
+    public void exportDeviceExaminePlanDetail(Integer deviceExaminePlanId, String deviceName, String deviceNumber, HttpServletResponse response) {
+        DeviceExaminePlan deviceExaminePlan = baseMapper.selectById(deviceExaminePlanId);
+        if (deviceExaminePlan == null) {
+            throw new ErrorException("璁惧鏍告煡璁″垝涓嶅瓨鍦�");
+        }
+        DeviceExaminePlanDetails query = new DeviceExaminePlanDetails();
+        query.setPlanId(deviceExaminePlanId);
+        query.setDeviceName(deviceName);
+        query.setDeviceNumber(deviceNumber);
+        List<DeviceExaminePlanDetailsDto> deviceExaminePlanDetailsDtoList =
+                deviceExaminePlanDetailsMapper.listDeviceExaminePlanDetail(QueryWrappers.queryWrappers(query));
 
-        // 鏌ヨ璁惧鏍告煡璁″垝璇︽儏
-        List<DeviceExaminePlanDetails> deviceExaminePlanDetailsList = deviceExaminePlanDetailsMapper.selectList(Wrappers.<DeviceExaminePlanDetails>lambdaQuery().eq(DeviceExaminePlanDetails::getPlanId, deviceExaminePlanId)
-                .orderByAsc(DeviceExaminePlanDetails::getCheckTime));
-        // 璁剧疆搴忓彿
-        ArrayList<DeviceExaminePlanDetailsDto> deviceExaminePlanDetailsDtoList = new ArrayList<>();
-        deviceExaminePlanDetailsList.forEach(deviceExamineRecordContrastDetail -> {
-            DeviceExaminePlanDetailsDto deviceExaminePlanDetailsDto = new DeviceExaminePlanDetailsDto();
-            BeanUtils.copyProperties(deviceExamineRecordContrastDetail, deviceExaminePlanDetailsDto);
-            deviceExaminePlanDetailsDto.setIndex(deviceExaminePlanDetailsList.indexOf(deviceExamineRecordContrastDetail) + 1);
-            deviceExaminePlanDetailsDtoList.add(deviceExaminePlanDetailsDto);
-        });
+        String planName = StringUtils.isNotBlank(deviceExaminePlan.getPlanName()) ? deviceExaminePlan.getPlanName() : "璁惧鏍告煡璁″垝";
+        String[] columns = {"搴忓彿", "璁惧缂栧彿", "璁惧鍚嶇О", "璁惧鍨嬪彿", "鏍告煡鏃堕棿", "鏍告煡鎸囨爣",
+                "鏍告煡鏂规硶", "缁撴灉濡備綍鍒ゅ畾", "鏍告煡璐熻矗浜�", "瀹℃牳鐘舵��", "澶囨敞"};
+        List<String[]> dataRows = new ArrayList<>();
+        int index = 1;
+        for (DeviceExaminePlanDetailsDto detail : deviceExaminePlanDetailsDtoList) {
+            String checkTime = StringUtils.isNotBlank(detail.getCompleteDate()) ? detail.getCompleteDate() : detail.getCheckTime();
+            dataRows.add(new String[]{
+                    String.valueOf(index++),
+                    blankToEmpty(detail.getDeviceNumber()),
+                    blankToEmpty(detail.getDeviceName()),
+                    blankToEmpty(detail.getDeviceModel()),
+                    blankToEmpty(checkTime),
+                    blankToEmpty(detail.getCheckIndex()),
+                    blankToEmpty(detail.getCheckMethod()),
+                    blankToEmpty(detail.getHowResults()),
+                    blankToEmpty(detail.getCheckChargerUser()),
+                    recordStatusText(detail.getRecordStatus()),
+                    blankToEmpty(detail.getRemark())
+            });
+        }
 
-        // 鑾峰彇璺緞
-        InputStream inputStream = this.getClass().getResourceAsStream("/static/word/examine-plan-detail.docx");
-        Configure configure = Configure.builder()
-                .bind("deviceExaminePlanDetailsDtoList", new HackLoopTableRenderPolicy())
-                .build();
-        XWPFTemplate template = XWPFTemplate.compile(inputStream, configure).render(
-                new HashMap<String, Object>() {{
-                    put("deviceExaminePlan", deviceExaminePlanDto);
-                    put("deviceExaminePlanDetailsDtoList", deviceExaminePlanDetailsDtoList);
-                    //鑾峰彇鏍告煡浜虹殑绛惧悕鍦板潃
-                    put("writeUserUrl", UserUtils.getFinalUserSignatureUrl(deviceExaminePlanDto.getWriteUserId()));
-                    //鑾峰彇瀹℃煡浜虹殑绛惧悕鍦板潃
-                    put("reviewUserUrl", UserUtils.getFinalUserSignatureUrl(deviceExaminePlanDto.getRatifyUserId()));
-                }});
-
+        response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
+        response.setCharacterEncoding("UTF-8");
         try {
-            response.setContentType("application/msword");
-            String fileName = URLEncoder.encode(
-                    "璁惧鏍告煡璁″垝", "UTF-8");
             response.setHeader("Content-disposition",
-                    "attachment;filename=" + fileName + ".docx");
-            OutputStream os = response.getOutputStream();
-            template.write(os);
-            os.flush();
-            os.close();
-            inputStream.close();
+                    "attachment;filename=" + URLEncoder.encode(planName, "UTF-8") + ".xlsx");
+            writePlanExcel(response, planName, columns, dataRows);
         } catch (Exception e) {
             e.printStackTrace();
             throw new RuntimeException("瀵煎嚭澶辫触");
         }
+    }
+
+    /**
+     * 鍐欒鍒掕〃锛氭爣棰樿 + 鍒楀悕 + 鏁版嵁锛屾暣浣撳彧鍔犳渶澶栬竟妗嗭紝鍏ㄩ儴灞呬腑锛屾棤鑳屾櫙鑹�
+     */
+    private void writePlanExcel(HttpServletResponse response, String planName, String[] columns,
+                                List<String[]> dataRows) throws IOException {
+        Workbook workbook = new XSSFWorkbook();
+        Map<String, CellStyle> styleCache = new HashMap<>();
+        Font titleFont = buildExcelFont(workbook, true, 16);
+        Font headerFont = buildExcelFont(workbook, true, 12);
+        Font contentFont = buildExcelFont(workbook, false, 12);
+
+        Sheet sheet = workbook.createSheet("璁惧鏍告煡璁″垝");
+        int lastColumn = columns.length - 1;
+
+        Row titleRow = sheet.createRow(0);
+        titleRow.setHeightInPoints(30);
+        for (int c = 0; c <= lastColumn; c++) {
+            Cell cell = titleRow.createCell(c);
+            if (c == 0) {
+                cell.setCellValue(planName);
+            }
+            cell.setCellStyle(exportStyle(workbook, styleCache, titleFont, c == 0, c == lastColumn, true, false));
+        }
+        sheet.addMergedRegion(new CellRangeAddress(0, 0, 0, lastColumn));
+
+        Row headerRow = sheet.createRow(1);
+        headerRow.setHeightInPoints(22);
+        for (int c = 0; c <= lastColumn; c++) {
+            Cell cell = headerRow.createCell(c);
+            cell.setCellValue(columns[c]);
+            cell.setCellStyle(exportStyle(workbook, styleCache, headerFont,
+                    c == 0, c == lastColumn, false, dataRows.isEmpty()));
+        }
+
+        for (int r = 0; r < dataRows.size(); r++) {
+            Row row = sheet.createRow(r + 2);
+            boolean lastDataRow = r == dataRows.size() - 1;
+            for (int c = 0; c <= lastColumn; c++) {
+                Cell cell = row.createCell(c);
+                cell.setCellValue(dataRows.get(r)[c]);
+                cell.setCellStyle(exportStyle(workbook, styleCache, contentFont,
+                        c == 0, c == lastColumn, false, lastDataRow));
+            }
+        }
+
+        for (int c = 0; c <= lastColumn; c++) {
+            int width = textWidth(columns[c]);
+            for (String[] row : dataRows) {
+                width = Math.max(width, textWidth(row[c]));
+            }
+            sheet.setColumnWidth(c, Math.min(255, width + 4) * 256);
+        }
+
+        OutputStream os = response.getOutputStream();
+        workbook.write(os);
+        os.flush();
+        workbook.close();
+    }
+
+    private Font buildExcelFont(Workbook workbook, boolean bold, int size) {
+        Font font = workbook.createFont();
+        font.setFontName("瀹嬩綋");
+        font.setBold(bold);
+        font.setFontHeightInPoints((short) size);
+        return font;
+    }
+
+    /**
+     * 鍗曞厓鏍兼牱寮忥細灞呬腑銆佹棤鑳屾櫙鑹诧紱鍐呴儴缁嗙綉鏍肩嚎锛屾暣浣撴渶澶栧湀绮楄竟妗�
+     */
+    private CellStyle exportStyle(Workbook workbook, Map<String, CellStyle> styleCache, Font font,
+                                  boolean outerLeft, boolean outerRight, boolean outerTop, boolean outerBottom) {
+        String key = font.getBold() + "-" + font.getFontHeightInPoints() + "-" + (outerLeft ? 1 : 0)
+                + (outerRight ? 1 : 0) + (outerTop ? 1 : 0) + (outerBottom ? 1 : 0);
+        CellStyle cached = styleCache.get(key);
+        if (cached != null) {
+            return cached;
+        }
+        CellStyle style = workbook.createCellStyle();
+        style.setFont(font);
+        style.setAlignment(HorizontalAlignment.CENTER);
+        style.setVerticalAlignment(VerticalAlignment.CENTER);
+        style.setBorderLeft(BorderStyle.THIN);
+        style.setBorderRight(BorderStyle.THIN);
+        style.setBorderTop(BorderStyle.THIN);
+        style.setBorderBottom(BorderStyle.THIN);
+        if (outerLeft) {
+            style.setBorderLeft(BorderStyle.MEDIUM);
+        }
+        if (outerRight) {
+            style.setBorderRight(BorderStyle.MEDIUM);
+        }
+        if (outerTop) {
+            style.setBorderTop(BorderStyle.MEDIUM);
+        }
+        if (outerBottom) {
+            style.setBorderBottom(BorderStyle.MEDIUM);
+        }
+        styleCache.put(key, style);
+        return style;
+    }
+
+    /**
+     * 浼扮畻鏄剧ず瀹藉害锛堜腑鏂囨寜涓や釜瀛楃瀹斤級
+     */
+    private int textWidth(String text) {
+        if (text == null) {
+            return 0;
+        }
+        int width = 0;
+        for (int i = 0; i < text.length(); i++) {
+            width += text.charAt(i) > 0xFF ? 2 : 1;
+        }
+        return width;
+    }
+
+    /**
+     * 璁板綍鐘舵�佹枃妗堬細0鏈紑濮嬨��1寰呮壒鍑嗐��2閫氳繃銆�3涓嶉�氳繃
+     */
+    private String recordStatusText(Integer recordStatus) {
+        if (recordStatus == null) {
+            return "鏈紑濮�";
+        }
+        switch (recordStatus) {
+            case 1:
+                return "寰呮壒鍑�";
+            case 2:
+                return "閫氳繃";
+            case 3:
+                return "涓嶉�氳繃";
+            default:
+                return "鏈紑濮�";
+        }
+    }
+
+    private String blankToEmpty(String value) {
+        return value == null ? "" : value;
     }
 
     @Override
@@ -346,4 +526,517 @@
 
         return deviceExaminePlanDto;
     }
+
+    /**
+     * 鎸夊勾浠芥煡璇㈣澶囨牳鏌ヨ鍒掞紙涓嶅瓨鍦ㄥ垯鑷姩鍒涘缓锛夛紝骞惰嚜鍔ㄨˉ鍏ㄥ凡鍒扮偣鐨勮澶囨槑缁�
+     */
+    @Override
+    public DeviceExaminePlanDto getOrCreatePlanByYear(String planYear) {
+        if (StringUtils.isBlank(planYear)) {
+            planYear = String.valueOf(LocalDate.now().getYear());
+        }
+        DeviceExaminePlan plan = getOrCreatePlanEntity(planYear);
+        ensureExamineDetails(plan);
+        DeviceExaminePlanDto planDto = new DeviceExaminePlanDto();
+        BeanUtils.copyProperties(plan, planDto);
+        DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
+        planDto.setWriteTimeStr(plan.getWriteTime() == null ? null : plan.getWriteTime().format(dateFormatter));
+        planDto.setRatifyTimeStr(plan.getRatifyTime() == null ? null : plan.getRatifyTime().format(dateFormatter));
+        if (plan.getWriteUserId() != null) {
+            User writeUser = userMapper.selectById(plan.getWriteUserId());
+            if (writeUser != null) {
+                planDto.setWriteName(writeUser.getName());
+            }
+        }
+        if (plan.getRatifyUserId() != null) {
+            User ratifyUser = userMapper.selectById(plan.getRatifyUserId());
+            if (ratifyUser != null) {
+                planDto.setRatifyName(ratifyUser.getName());
+            }
+        }
+        return planDto;
+    }
+
+    /**
+     * 鏍告煡璁″垝骞翠唤鍒楄〃锛氬垹闄ゆ病鏈変换浣曟槑缁嗙殑绌哄勾浠借鍒掑悗锛岃繑鍥炴湁鏄庣粏鐨勫勾浠斤紙鍊掑簭锛�
+     */
+    @Override
+    public List<String> getExaminePlanYears() {
+        List<DeviceExaminePlan> plans = baseMapper.selectList(null);
+        for (DeviceExaminePlan plan : plans) {
+            Long detailCount = deviceExaminePlanDetailsMapper.selectCount(Wrappers.<DeviceExaminePlanDetails>lambdaQuery()
+                    .eq(DeviceExaminePlanDetails::getPlanId, plan.getPlanId()));
+            if (detailCount == null || detailCount == 0) {
+                removeById(plan.getPlanId());
+            }
+        }
+        return baseMapper.selectPlanYears();
+    }
+
+    /**
+     * 璁惧妗f鍙樻洿鍚庯紝绔嬪嵆鎶婅澶囩櫥璁板埌涓嬫鏍告煡鏃ユ湡鎵�鍦ㄥ勾浠界殑璁惧鏍告煡璁″垝鏄庣粏
+     */
+    @Override
+    public void syncDeviceExamineDetail(Device device) {
+        LocalDate due = calcExamineDueDate(device);
+        if (due == null) {
+            return;
+        }
+        DeviceExaminePlan plan = getOrCreatePlanEntity(String.valueOf(due.getYear()));
+        fillExamineDetailIfAbsent(plan, device);
+    }
+
+    /**
+     * 澶嶆牳閫氳繃鍚庯紝鎶婅澶囨。妗堢殑鏈�杩�/涓嬫鏍告煡鏃ユ湡婊氬姩鍒版湰杞�
+     */
+    @Override
+    public void updateDeviceExamineDate(Integer planDetailsId) {
+        DeviceExaminePlanDetails detail = deviceExaminePlanDetailsMapper.selectById(planDetailsId);
+        if (detail == null || detail.getDeviceId() == null) {
+            return;
+        }
+        Device device = deviceMapper.selectById(detail.getDeviceId());
+        if (device == null) {
+            return;
+        }
+        // 瀹℃牳閫氳繃褰撳ぉ鍗宠涓哄疄闄呮牳鏌ュ畬鎴愭椂闂�
+        LocalDateTime checkDateTime = LocalDate.now().atStartOfDay();
+        // 鍙厑璁稿悜鍓嶆粴鍔紝閬垮厤閲嶅澶嶆牳鏃ц褰曟椂鎶婃棩鏈熸敼鍥炲幓
+        if (device.getLastExamineDate() != null && !checkDateTime.isAfter(device.getLastExamineDate())) {
+            return;
+        }
+        device.setLastExamineDate(checkDateTime);
+        if (StringUtils.isNotBlank(device.getExamineCycle())) {
+            try {
+                device.setNextExamineDate(checkDateTime.plusMonths(Long.parseLong(device.getExamineCycle().trim())));
+            } catch (NumberFormatException ignored) {
+            }
+        }
+        deviceMapper.updateById(device);
+        // 鏈疆鏍告煡瀹屾垚鍚庣珛鍗崇櫥璁颁笅涓�杞紝鍘嗗彶鏄庣粏鍜岃褰曚繚鎸佷笉鍔�
+        syncDeviceExamineDetail(device);
+    }
+
+    /**
+     * 鏌ヨ骞翠唤瀵瑰簲鐨勮鍒掞紝涓嶅瓨鍦ㄥ垯鍒涘缓
+     */
+    private DeviceExaminePlan getOrCreatePlanEntity(String planYear) {
+        DeviceExaminePlan plan = baseMapper.selectOne(Wrappers.<DeviceExaminePlan>lambdaQuery()
+                .eq(DeviceExaminePlan::getPlanYear, planYear)
+                .orderByDesc(DeviceExaminePlan::getPlanId)
+                .last("limit 1"));
+        if (plan == null) {
+            plan = new DeviceExaminePlan();
+            plan.setPlanName(planYear + "骞村害璁惧鏍告煡璁″垝");
+            plan.setPlanYear(planYear);
+            plan.setWriteUserId(SecurityUtils.getUserId().intValue());
+            plan.setWriteTime(LocalDateTime.now());
+            baseMapper.insert(plan);
+        }
+        return plan;
+    }
+
+    /**
+     * 鎵弿鎵�鏈夎澶囷紝鎶婃湰骞村害鍙婂線骞撮�炬湡鏈牳鏌ョ殑璁惧琛ヨ繘璁″垝
+     */
+    private void ensureExamineDetails(DeviceExaminePlan plan) {
+        List<Device> devices = deviceMapper.selectList(null);
+        for (Device device : devices) {
+            fillExamineDetailIfAbsent(plan, device);
+        }
+    }
+
+    /**
+     * 璁惧鏈疆搴旀牳鏌ユ棩鏈燂紙娌″~涓嬫鏃ユ湡鏃剁敤鏈�杩戞棩鏈�+鏍告煡鍛ㄦ湡鍏滃簳锛�
+     */
+    private LocalDate calcExamineDueDate(Device device) {
+        LocalDateTime base = device.getNextExamineDate();
+        if (base == null && device.getLastExamineDate() != null && StringUtils.isNotBlank(device.getExamineCycle())) {
+            try {
+                base = device.getLastExamineDate().plusMonths(Long.parseLong(device.getExamineCycle().trim()));
+            } catch (NumberFormatException ignored) {
+            }
+        }
+        return base == null ? null : base.toLocalDate();
+    }
+
+    /**
+     * 璁″垝涓皻鏃犺璁惧鏈疆鏄庣粏鏃惰嚜鍔ㄨˉ涓�鏉★紱鏍告煡鏃堕棿鍙栨。妗堜笅娆℃牳鏌ユ棩鏈燂紙绮剧‘鍒版棩锛�
+     */
+    private void fillExamineDetailIfAbsent(DeviceExaminePlan plan, Device device) {
+        LocalDate due = calcExamineDueDate(device);
+        if (due == null) {
+            return;
+        }
+        if (StringUtils.isBlank(plan.getPlanYear())) {
+            return;
+        }
+        int planYearValue;
+        try {
+            planYearValue = Integer.parseInt(plan.getPlanYear().trim());
+        } catch (NumberFormatException e) {
+            return;
+        }
+        int dueYear = due.getYear();
+        // 鏈潵骞翠唤鐨勮鍒掑彧鐧昏搴旀牳鏌ユ棩鏈熷氨鍦ㄨ骞翠唤鐨勮澶囷紝閬垮厤鎶婂線骞寸殑鏃ф棩鏈熻ˉ杩涙湭鏉ョ殑璁″垝
+        if (planYearValue > LocalDate.now().getYear()) {
+            if (dueYear != planYearValue) {
+                return;
+            }
+        } else if (dueYear > planYearValue) {
+            return;
+        }
+        String checkTime = due.format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
+        Long exist = deviceExaminePlanDetailsMapper.selectCount(Wrappers.<DeviceExaminePlanDetails>lambdaQuery()
+                .eq(DeviceExaminePlanDetails::getPlanId, plan.getPlanId())
+                .eq(DeviceExaminePlanDetails::getDeviceId, device.getId())
+                .likeRight(DeviceExaminePlanDetails::getCheckTime, checkTime));
+        if (exist != null && exist > 0) {
+            return;
+        }
+        DeviceExaminePlanDetails detail = new DeviceExaminePlanDetails();
+        detail.setPlanId(plan.getPlanId());
+        detail.setDeviceId(device.getId());
+        detail.setDeviceName(device.getDeviceName() == null ? "" : device.getDeviceName());
+        detail.setDeviceModel(device.getSpecificationModel());
+        detail.setDeviceNumber(device.getManagementNumber());
+        detail.setCheckTime(checkTime);
+        deviceExaminePlanDetailsMapper.insert(detail);
+    }
+
+    /**
+     * 涓烘寚瀹氳澶囨壘涓�鏉″彲褰曞叆鏍告煡璁板綍鐨勮鍒掓槑缁嗭紱娌℃湁鍙敤鏄庣粏鏃跺湪褰撳墠骞村害璁″垝鏂板鏈疆鏄庣粏
+     */
+    @Override
+    public DeviceExaminePlanDetails resolveExaminePlanDetail(Integer deviceId, String checkTime) {
+        return findExaminePlanDetail(deviceId, checkTime, true);
+    }
+
+    /**
+     * 鏌ユ壘鎸囧畾璁惧鍙鐢ㄧ殑璁″垝鏄庣粏锛涘彧鏌ヨ锛屼笉鏂板璁″垝鎴栨槑缁�
+     */
+    @Override
+    public DeviceExaminePlanDetails findExaminePlanDetail(Integer deviceId, String checkTime) {
+        return findExaminePlanDetail(deviceId, checkTime, false);
+    }
+
+    private DeviceExaminePlanDetails findExaminePlanDetail(Integer deviceId, String checkTime, boolean createIfAbsent) {
+        if (deviceId == null) {
+            throw new ErrorException("璇烽�夋嫨鏍告煡璁惧");
+        }
+        Device device = deviceMapper.selectById(deviceId);
+        if (device == null) {
+            throw new ErrorException("鏍告煡璁惧涓嶅瓨鍦�");
+        }
+        String checkMonth = normalizeCheckMonth(checkTime);
+        String planYear = checkMonth.substring(0, 4);
+        DeviceExaminePlan plan;
+        if (createIfAbsent) {
+            plan = getOrCreatePlanEntity(planYear);
+        } else {
+            plan = baseMapper.selectOne(Wrappers.<DeviceExaminePlan>lambdaQuery()
+                    .eq(DeviceExaminePlan::getPlanYear, planYear)
+                    .orderByDesc(DeviceExaminePlan::getPlanId)
+                    .last("limit 1"));
+        }
+        List<DeviceExaminePlanDetails> details = deviceExaminePlanDetailsMapper.selectList(
+                Wrappers.<DeviceExaminePlanDetails>lambdaQuery()
+                        .eq(DeviceExaminePlanDetails::getDeviceId, deviceId)
+                        .orderByAsc(DeviceExaminePlanDetails::getCheckTime));
+        DeviceExaminePlanDetails nearest = null;
+        long nearestDistance = Long.MAX_VALUE;
+        for (DeviceExaminePlanDetails detail : details) {
+            // 宸叉湁鏍告煡璁板綍鐨勬槑缁嗕唬琛ㄥ凡瀹屾垚鐨勪竴杞紝鍚庣画鏍告煡姘歌繙涓嶅鐢ㄣ�佷笉瑕嗙洊
+            Long recordCount = deviceExamineRecordMapper.selectCount(Wrappers.<DeviceExamineRecord>lambdaQuery()
+                    .eq(DeviceExamineRecord::getPlanDetailsId, detail.getPlanDetailsId()));
+            if (recordCount != null && recordCount > 0) {
+                continue;
+            }
+            if (plan != null && detail.getPlanId() != null && detail.getPlanId().equals(plan.getPlanId())) {
+                return detail;
+            }
+            long distance = monthDistance(detail.getCheckTime(), checkMonth);
+            if (distance < nearestDistance) {
+                nearest = detail;
+                nearestDistance = distance;
+            }
+        }
+        // 鎻愬墠鏍告煡鏃朵紭鍏堝鐢ㄧ郴缁熺櫥璁般�佸皻鏈牳鏌ョ殑杩欎竴杞紙鍙兘鍦ㄥ埆鐨勫勾浠借鍒掗噷锛�
+        if (nearest != null) {
+            return nearest;
+        }
+        if (!createIfAbsent) {
+            return null;
+        }
+        DeviceExaminePlanDetails detail = new DeviceExaminePlanDetails();
+        detail.setPlanId(plan.getPlanId());
+        detail.setDeviceId(device.getId());
+        detail.setDeviceName(device.getDeviceName() == null ? "" : device.getDeviceName());
+        detail.setDeviceModel(device.getSpecificationModel());
+        detail.setDeviceNumber(device.getManagementNumber());
+        String detailCheckTime = normalizeCheckDate(checkTime);
+        detail.setCheckTime(StringUtils.isBlank(detailCheckTime) ? checkMonth : detailCheckTime);
+        deviceExaminePlanDetailsMapper.insert(detail);
+        return detail;
+    }
+
+    /**
+     * 瑙勮寖鏍告煡鏈堜唤涓� yyyy-MM锛岄潪娉曟垨涓虹┖鏃跺彇褰撳墠鏈堜唤
+     */
+    private String normalizeCheckMonth(String checkTime) {
+        if (StringUtils.isNotBlank(checkTime)) {
+            String normalized = checkTime.trim().replace('.', '-').replace('/', '-');
+            if (normalized.length() >= 7) {
+                try {
+                    return YearMonth.parse(normalized.substring(0, 7)).toString();
+                } catch (Exception ignored) {
+                }
+            }
+        }
+        return LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM"));
+    }
+
+    /**
+     * 瑙勮寖鏍告煡鏃ユ湡锛氬甫鍏蜂綋鏃ユ湡鐨勪繚鐣欏埌鏃ワ紝鍙~鏈堜唤鐨勮鑼冧负 yyyy-MM
+     */
+    private String normalizeCheckDate(String checkTime) {
+        if (StringUtils.isBlank(checkTime)) {
+            return null;
+        }
+        String normalized = checkTime.trim().replace('.', '-').replace('/', '-');
+        if (normalized.length() >= 10) {
+            try {
+                return LocalDate.parse(normalized.substring(0, 10)).toString();
+            } catch (Exception ignored) {
+            }
+        }
+        return normalizeCheckMonth(normalized);
+    }
+
+    /**
+     * 涓や釜鏈堜唤涔嬮棿鐩稿樊鐨勬湀鏁�
+     */
+    private long monthDistance(String fromCheckTime, String targetMonth) {
+        try {
+            YearMonth from = YearMonth.parse(normalizeCheckMonth(fromCheckTime));
+            YearMonth target = YearMonth.parse(targetMonth);
+            return Math.abs(ChronoUnit.MONTHS.between(from, target));
+        } catch (Exception e) {
+            return Long.MAX_VALUE;
+        }
+    }
+
+    /**
+     * 淇敼璁″垝鏄庣粏锛涙牳鏌ユ湀浠借法骞存椂锛屾妸鏄庣粏褰掑埌鏍告煡鏈堜唤鎵�灞炲勾浠界殑璁″垝涓�
+     */
+    @Override
+    public boolean updateExaminePlanDetail(DeviceExaminePlanDetails detail) {
+        if (detail == null || detail.getPlanDetailsId() == null) {
+            throw new ErrorException("缂哄皯璁惧鏍告煡璁″垝鏄庣粏id");
+        }
+        if (StringUtils.isNotBlank(detail.getCheckTime())) {
+            String checkTime = normalizeCheckDate(detail.getCheckTime());
+            detail.setCheckTime(checkTime);
+            DeviceExaminePlan plan = getOrCreatePlanEntity(checkTime.substring(0, 4));
+            detail.setPlanId(plan.getPlanId());
+        }
+        return deviceExaminePlanDetailsMapper.updateById(detail) > 0;
+    }
+
+    /**
+     * 鍒犻櫎璁″垝鏄庣粏锛涘凡瀹℃牳瀹屾垚鐨勬牳鏌�/姣斿璁板綍闅忓巻鍙茬暀妗d笉鍏佽鍒犻櫎锛屾湭瀹℃牳鐨勮褰曢殢鏄庣粏涓�璧峰垹闄�
+     */
+    @Override
+    public boolean removeExaminePlanDetail(Integer planDetailsId) {
+        if (planDetailsId == null) {
+            throw new ErrorException("缂哄皯璁惧鏍告煡璁″垝鏄庣粏id");
+        }
+        DeviceExaminePlanDetails detail = deviceExaminePlanDetailsMapper.selectById(planDetailsId);
+        if (detail == null) {
+            return false;
+        }
+        List<DeviceExamineRecord> records = deviceExamineRecordMapper.selectList(Wrappers.<DeviceExamineRecord>lambdaQuery()
+                .eq(DeviceExamineRecord::getPlanDetailsId, planDetailsId));
+        List<DeviceExamineRecordContrast> contrasts = deviceExamineRecordContrastMapper.selectList(Wrappers.<DeviceExamineRecordContrast>lambdaQuery()
+                .eq(DeviceExamineRecordContrast::getPlanDetailsId, planDetailsId));
+        // 瀹℃牳涓嶉�氳繃鐨勮褰曞厑璁歌繛鍚屾槑缁嗕竴璧峰垹闄わ紱瀹℃牳閫氳繃鐨勮褰曚负鍘嗗彶鐣欐。锛屼笉鍏佽鍒犻櫎
+        boolean hasPassedRecord = records.stream().anyMatch(item -> item.getReviewStatus() != null
+                && item.getReviewStatus() == 1);
+        boolean hasPassedContrast = contrasts.stream().anyMatch(item -> item.getReviewStatus() != null
+                && item.getReviewStatus() == 1);
+        if (hasPassedRecord || hasPassedContrast) {
+            throw new ErrorException("璇ヨ鍒掑凡瀛樺湪瀹℃牳閫氳繃鐨勬牳鏌ヨ褰曪紝涓轰繚鐣欏巻鍙茶褰曚笉鍏佽鍒犻櫎");
+        }
+        if (!records.isEmpty()) {
+            List<Integer> recordIds = records.stream().map(DeviceExamineRecord::getRecordId).collect(Collectors.toList());
+            deviceExamineRecordDetailMapper.delete(Wrappers.<DeviceExamineRecordDetail>lambdaQuery()
+                    .in(DeviceExamineRecordDetail::getRecordId, recordIds));
+            deviceExamineRecordMapper.deleteBatchIds(recordIds);
+        }
+        if (!contrasts.isEmpty()) {
+            List<Integer> contrastIds = contrasts.stream().map(DeviceExamineRecordContrast::getRecordContrastId).collect(Collectors.toList());
+            deviceExamineRecordContrastDetailsMapper.delete(Wrappers.<DeviceExamineRecordContrastDetails>lambdaQuery()
+                    .in(DeviceExamineRecordContrastDetails::getRecordContrastId, contrastIds));
+            deviceExamineRecordContrastMapper.deleteBatchIds(contrastIds);
+        }
+        deviceExamineAttachmentMapper.delete(Wrappers.<DeviceExamineAttachment>lambdaQuery()
+                .eq(DeviceExamineAttachment::getPlanDetailsId, planDetailsId));
+        boolean removed = deviceExaminePlanDetailsService.removeById(planDetailsId);
+        if (removed && detail.getDeviceId() != null) {
+            rollbackDeviceExamineDate(detail);
+        }
+        return removed;
+    }
+
+    /**
+     * 鍒犻櫎鏈鏍哥殑璁″垝鍚庯紝鎶婅澶囨。妗堟仮澶嶅埌鏈�杩戜竴鏉″鏍搁�氳繃鐨勮褰曪紱
+     * 娌℃湁宸插畬鎴愯褰曟椂锛屾墠娓呯┖妗f鐨勬牳鏌ユ棩鏈熷拰鍛ㄦ湡
+     */
+    private void rollbackDeviceExamineDate(DeviceExaminePlanDetails removedDetail) {
+        Device device = deviceMapper.selectById(removedDetail.getDeviceId());
+        if (device == null) {
+            return;
+        }
+        Long remainCount = deviceExaminePlanDetailsMapper.selectCount(Wrappers.<DeviceExaminePlanDetails>lambdaQuery()
+                .eq(DeviceExaminePlanDetails::getDeviceId, removedDetail.getDeviceId()));
+        boolean isCurrentRound = sameExamineMonth(removedDetail.getCheckTime(), device.getNextExamineDate());
+        // 鍒犻櫎鐨勪笉鏄。妗堝綋鍓嶇櫥璁扮殑閭d竴杞紝涓旇澶囪繕鏈夊叾浠栬鍒掓椂锛屼笉鍔ㄦ。妗�
+        if (!isCurrentRound && remainCount != null && remainCount > 0) {
+            return;
+        }
+        LocalDate lastDone = null;
+        List<DeviceExaminePlanDetails> details = deviceExaminePlanDetailsMapper.selectList(
+                Wrappers.<DeviceExaminePlanDetails>lambdaQuery()
+                        .eq(DeviceExaminePlanDetails::getDeviceId, removedDetail.getDeviceId()));
+        for (DeviceExaminePlanDetails item : details) {
+            if (!hasPassedExamineRecord(item.getPlanDetailsId())) {
+                continue;
+            }
+            LocalDate date = parseCheckMonth(item.getCheckTime());
+            if (date != null && (lastDone == null || date.isAfter(lastDone))) {
+                lastDone = date;
+            }
+        }
+        if (lastDone == null) {
+            deviceMapper.update(null, Wrappers.<Device>lambdaUpdate()
+                    .eq(Device::getId, removedDetail.getDeviceId())
+                    .set(Device::getLastExamineDate, null)
+                    .set(Device::getNextExamineDate, null)
+                    .set(Device::getExamineCycle, null));
+            return;
+        }
+        LocalDateTime last = lastDone.atStartOfDay();
+        LocalDateTime next = null;
+        if (StringUtils.isNotBlank(device.getExamineCycle())) {
+            try {
+                next = last.plusMonths(Long.parseLong(device.getExamineCycle().trim()));
+            } catch (NumberFormatException ignored) {
+            }
+        }
+        deviceMapper.update(null, Wrappers.<Device>lambdaUpdate()
+                .eq(Device::getId, removedDetail.getDeviceId())
+                .set(Device::getLastExamineDate, last)
+                .set(Device::getNextExamineDate, next));
+    }
+
+    /**
+     * 鏄庣粏涓嬫槸鍚﹀瓨鍦ㄥ鏍搁�氳繃鐨勬牳鏌ヨ褰曟垨姣斿璁板綍
+     */
+    private boolean hasPassedExamineRecord(Integer planDetailsId) {
+        Long recordCount = deviceExamineRecordMapper.selectCount(Wrappers.<DeviceExamineRecord>lambdaQuery()
+                .eq(DeviceExamineRecord::getPlanDetailsId, planDetailsId)
+                .eq(DeviceExamineRecord::getReviewStatus, 1));
+        if (recordCount != null && recordCount > 0) {
+            return true;
+        }
+        Long contrastCount = deviceExamineRecordContrastMapper.selectCount(Wrappers.<DeviceExamineRecordContrast>lambdaQuery()
+                .eq(DeviceExamineRecordContrast::getPlanDetailsId, planDetailsId)
+                .eq(DeviceExamineRecordContrast::getReviewStatus, 1));
+        return contrastCount != null && contrastCount > 0;
+    }
+
+    /**
+     * 璁″垝鏄庣粏鐨勬牳鏌ユ湀浠芥槸鍚﹀氨鏄。妗堜笅娆℃牳鏌ユ棩鏈熸墍鍦ㄦ湀浠�
+     */
+    private boolean sameExamineMonth(String checkTime, LocalDateTime nextExamineDate) {
+        if (StringUtils.isBlank(checkTime) || nextExamineDate == null) {
+            return false;
+        }
+        String normalized = checkTime.trim().replace('.', '-').replace('/', '-');
+        return normalized.length() >= 7
+                && normalized.substring(0, 7).equals(nextExamineDate.format(DateTimeFormatter.ofPattern("yyyy-MM")));
+    }
+
+    /**
+     * 瑙f瀽鏄庣粏鏍告煡鏈堜唤涓哄綋鏈堥鏃ワ紝鏍煎紡涓嶅悎娉曟椂杩斿洖 null
+     */
+    private LocalDate parseCheckMonth(String checkTime) {
+        if (StringUtils.isNotBlank(checkTime)) {
+            String normalized = checkTime.trim().replace('.', '-').replace('/', '-');
+            if (normalized.length() >= 7) {
+                try {
+                    return YearMonth.parse(normalized.substring(0, 7)).atDay(1);
+                } catch (Exception ignored) {
+                }
+            }
+        }
+        return null;
+    }
+
+    @Override
+    public List<DeviceExamineAttachment> selectAttachments(Integer planDetailsId) {
+        if (planDetailsId == null) {
+            return new ArrayList<>();
+        }
+        return deviceExamineAttachmentMapper.selectList(Wrappers.<DeviceExamineAttachment>lambdaQuery()
+                .eq(DeviceExamineAttachment::getPlanDetailsId, planDetailsId)
+                .orderByAsc(DeviceExamineAttachment::getSort, DeviceExamineAttachment::getId));
+    }
+
+    @Override
+    public DeviceExamineAttachment uploadAttachment(Integer planDetailsId, MultipartFile file) {
+        if (planDetailsId == null) {
+            throw new ErrorException("缂哄皯鏍告煡璁″垝鏄庣粏id");
+        }
+        if (deviceExaminePlanDetailsMapper.selectById(planDetailsId) == null) {
+            throw new ErrorException("鏍告煡璁″垝鏄庣粏涓嶅瓨鍦�");
+        }
+        if (file == null || file.isEmpty()) {
+            throw new ErrorException("闄勪欢涓嶈兘涓虹┖");
+        }
+        // 绯荤粺鍏ㄥ眬涓婁紶涓婇檺涓�200MB锛岃繖閲屾彁鍓嶇粰鍑哄弸濂芥彁绀�
+        if (file.getSize() > 200L * 1024 * 1024) {
+            throw new ErrorException("闄勪欢涓嶈兘瓒呰繃200MB");
+        }
+        String originalName = file.getOriginalFilename();
+        if (originalName == null || originalName.trim().isEmpty()) {
+            originalName = "鏈懡鍚嶆枃浠�";
+        }
+        // 涓埆娴忚鍣ㄥ彲鑳藉甫涓婅矾寰勫墠缂�锛屽彧淇濈暀鏂囦欢鍚嶆湰韬�
+        originalName = originalName.replaceAll(".*[/\\\\]", "");
+        try {
+            // 鍥剧墖鏀惧浘鐗囩洰褰曪紙/img 闈欐�佽矾寰勶級锛屽叾浣欎换鎰忔牸寮忔斁 word 鐩綍锛�/word 闈欐�佽矾寰勶級
+            boolean image = originalName.matches("(?i).+\\.(jpg|jpeg|png|gif)$");
+            File directory = new File(image ? filePath : wordUrl);
+            if (!directory.exists()) {
+                directory.mkdirs();
+            }
+            String fileUrl = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyMMddHHmmssSSS")) + "-" + originalName;
+            file.transferTo(new File(directory, fileUrl));
+            DeviceExamineAttachment attachment = new DeviceExamineAttachment();
+            attachment.setPlanDetailsId(planDetailsId);
+            attachment.setFileName(originalName);
+            attachment.setFileUrl(fileUrl);
+            attachment.setSort(selectAttachments(planDetailsId).size());
+            deviceExamineAttachmentMapper.insert(attachment);
+            return attachment;
+        } catch (IOException e) {
+            throw new ErrorException("闄勪欢涓婁紶澶辫触");
+        }
+    }
+
+    @Override
+    public boolean deleteAttachment(Long id) {
+        return deviceExamineAttachmentMapper.deleteById(id) > 0;
+    }
 }

--
Gitblit v1.9.3