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 @@
    }
    /**
     * 导出设备核查计划
     * 导出设备核查计划(Excel):有筛选条件时导出筛选结果,没有筛选时导出该年份全部
     * @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.getFinalUserSignatureOrTransparent(deviceExaminePlanDto.getWriteUserId()));
                    //获取审查人的签名地址
                    put("reviewUserUrl", UserUtils.getFinalUserSignatureOrTransparent(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();
    }
    /**
     * 设备档案变更后,立即把设备登记到下次核查日期所在年份的设备核查计划明细
     */
    @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;
    }
    /**
     * 删除计划明细;已审核完成的核查/比对记录随历史留档不允许删除,未审核的记录随明细一起删除
     */
    @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;
    }
    /**
     * 删除未审核的计划后,把设备档案恢复到最近一条审核通过的记录;
     * 没有已完成记录时,才清空档案的核查日期和周期
     */
    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());
        // 删除的不是档案当前登记的那一轮,且设备还有其他计划时,不动档案
        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")));
    }
    /**
     * 解析明细核查月份为当月首日,格式不合法时返回 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;
    }
}