广丰卷烟厂数采质量分析系统
zhuguifei
7 天以前 2b31fa203f3435a582be51f45899d99164c9917a
RuoYi-Vue-Plus/ruoyi-modules/ruoyi-qa/src/main/java/org/dromara/qa/analy/service/impl/StoreSilkInfoServiceImpl.java
@@ -1,8 +1,6 @@
package org.dromara.qa.analy.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.BeanUtils;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.baomidou.dynamic.datasource.annotation.DS;
import org.dromara.common.core.utils.MapstructUtils;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.mybatis.core.page.TableDataInfo;
@@ -16,10 +14,10 @@
import org.dromara.qa.analy.domain.PackerTimeData;
import org.dromara.qa.analy.domain.RollerTimeData;
import org.dromara.qa.analy.domain.vo.StoreSilkDetailVo;
import org.dromara.qa.analy.mapper.RollerTimeDataMapper;
import org.dromara.qa.analy.service.IRollerTimeDataService;
import org.dromara.qa.analy.service.IPackerTimeDataService;
import org.dromara.qa.md.domain.MdShift;
import org.dromara.qa.md.domain.bo.MdShiftBo;
import org.dromara.qa.md.mapper.MdShiftMapper;
import org.dromara.qa.md.service.OracleShiftReader;
import org.springframework.stereotype.Service;
import org.dromara.qa.analy.domain.bo.StoreSilkInfoBo;
@@ -27,12 +25,9 @@
import org.dromara.qa.analy.domain.StoreSilkInfo;
import org.dromara.qa.analy.mapper.StoreSilkInfoMapper;
import org.dromara.qa.analy.service.IStoreSilkInfoService;
import org.dromara.qa.analy.mapper.FeedmatchTimeDataMapper;
import org.dromara.qa.analy.service.IFeedmatchTimeDataService;
import java.lang.reflect.Field;
import java.sql.Timestamp;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.*;
import java.time.LocalDate;
import java.time.LocalDateTime;
@@ -46,16 +41,16 @@
 * @author zhuguifei
 * @date 2026-03-02
 */
@DS("oracle_zs")
@Slf4j
@RequiredArgsConstructor
@Service
public class StoreSilkInfoServiceImpl implements IStoreSilkInfoService {
    private final StoreSilkInfoMapper baseMapper;
    private final FeedmatchTimeDataMapper feedmatchTimeDataMapper;
    private final RollerTimeDataMapper rollerTimeDataMapper;
    private final org.dromara.qa.analy.mapper.PackerTimeDataMapper packerTimeDataMapper;
    private final MdShiftMapper mdShiftMapper;
    private final IFeedmatchTimeDataService feedmatchTimeDataService;
    private final IRollerTimeDataService rollerTimeDataService;
    private final IPackerTimeDataService packerTimeDataService;
    private final OracleShiftReader oracleShiftReader;
    /**
@@ -102,6 +97,32 @@
            Date distimebegin = storeSilkInfoVo.getDistimebegin();
            //出料结束时间
            Date distimeend = storeSilkInfoVo.getDistimeend();
            if (distimebegin == null) {
                continue;
            }
            /**
             * 统计“出料进行中”的关键点:
             * - 出料结束时间 distimeend 可能为空(仍在出料中)
             * - 也可能已经有结束时间(出料已结束)
             *
             * 因此这里统一计算一个“本次统计的有效结束时间” effectiveDistEnd:
             * 1) distimeend == null :说明还在出料中,本次统计截止到当前时间 now
             * 2) distimeend != null :说明已出料结束,截止到 distimeend
             * 3) 若 distimeend 在未来(脏数据/时钟误差),也按 now 截止,避免统计到未来
             *
             * 后续所有查询、班次切分、扣减都基于 effectiveDistEnd,
             * 这样同一套逻辑同时适配“出料已结束”和“出料未结束”两种场景。
             */
            Date now = new Date();
            Date effectiveDistEnd;
            if (distimeend == null) {
                effectiveDistEnd = now;
            } else {
                effectiveDistEnd = distimeend.after(now) ? now : distimeend;
            }
            if (!effectiveDistEnd.after(distimebegin)) {
                continue;
            }
            //储丝柜柜号
            String siloid = storeSilkInfoVo.getSiloid();
@@ -110,24 +131,56 @@
            String containerNum = siloid.substring(lastIndex + 1);
            if (StringUtils.isEmpty(containerNum)) continue;
            //根据出料开始时间查询喂丝机->储丝柜->机台对应关系(feedmatch_time_data)
            Timestamp targetTime = new Timestamp(distimebegin.getTime() + 10 * 60 * 1000); // 查询出料10分钟后的第一条记录,保证数据准确性
            /**
             * 根据出料开始时间查询“喂丝机 -> 储丝柜 -> 机台(管道)”对应关系(feedmatch_time_data)
             *
             * 说明:
             * - 这里不是直接用 distimebegin,而是取出料开始后 10 分钟的第一条记录,
             *   目的是避开出料刚开始时的波动/映射未稳定问题,保证映射准确性。
             * - 查询上界使用 effectiveDistEnd:如果出料还没结束,就以当前时间作为上界,仍然能取到对应关系。
             */
            Timestamp targetTime = new Timestamp(distimebegin.getTime() + 10 * 60 * 1000); // 出料开始后10分钟
            String containerStr = StringUtils.isEmpty(containerNum) ? "" : containerNum.trim();
            //小于10的柜号补0
            if (containerStr.length() == 1) {
                containerStr = "0" + containerStr;
            }
            LambdaQueryWrapper<FeedmatchTimeData> lqw = new LambdaQueryWrapper<>();
            String finalContainerStr = containerStr;
            lqw.ge(FeedmatchTimeData::getTime, targetTime)
                    .le(FeedmatchTimeData::getTime, distimeend) // 不能大于出料结束时间
                    .le(FeedmatchTimeData::getTime, effectiveDistEnd)
                    .and(wrapper -> wrapper
                            .like(FeedmatchTimeData::getFs11, finalContainerStr)
                            .or()
                            .like(FeedmatchTimeData::getFs12, finalContainerStr)
                            .or()
                            .like(FeedmatchTimeData::getFs21, finalContainerStr)
                            .or()
                            .like(FeedmatchTimeData::getFs22, finalContainerStr)
                            .or()
                            .like(FeedmatchTimeData::getFs31, finalContainerStr)
                            .or()
                            .like(FeedmatchTimeData::getFs32, finalContainerStr)
                            .or()
                            .like(FeedmatchTimeData::getFs41, finalContainerStr)
                            .or()
                            .like(FeedmatchTimeData::getFs42, finalContainerStr)
                    )
                    .orderByAsc(FeedmatchTimeData::getTime)
                    .last("LIMIT 1");
            FeedmatchTimeData feedMatch = feedmatchTimeDataMapper.selectOne(lqw);
            FeedmatchTimeData feedMatch = feedmatchTimeDataService.selectOne(lqw);
            if (feedMatch == null) {
                // TODO  添加提示
                continue;
            }
            // feedMatch 转map  TODO 逆转map需验证key是否会重复
            //fsRevMap是逆转map     key->喂丝机对应的储丝柜号   value-> fs + 序号
            // feedMatch 转map:通过反向映射快速定位“该柜对应哪个喂丝机”、“该机组对应哪个管道”
            // fsRevMap:key=储丝柜号后两位(如 01/09),value=字段名(如 fs11/fs12...)
            Map<String, String> fsRevMap = new HashMap<>();
            //pipeRevMap是逆转map   key->机组对应的喂丝机和管道  value-> pipe + 序号
            // pipeRevMap:key=喂丝机号+管道组合(如 1x / 2x...),value=字段名(如 pipe01/pipe02...)
            Map<String, String> pipeRevMap = new HashMap<>();
            // pipeMap:key=字段名(pipe01/pipe02...),value=字段值(用于后续解析具体管道号)
            Map<String, String> pipeMap = new HashMap<>();
            Field[] fields = feedMatch.getClass().getDeclaredFields();
            for (Field field : fields) {
@@ -139,14 +192,26 @@
                    throw new RuntimeException(e);
                }
                if (field.getName().startsWith("fs") && value != null) {
                    fsRevMap.put(value.toString(), field.getName());
                    String key = value.toString().trim();
                    if (key.length() == 1) {
                        key = "0" + key;
                    } else if (key.length() > 2) {
                        key = key.substring(key.length() - 2);
                    }
                    fsRevMap.put(key, field.getName());
                } else if (field.getName().startsWith("pipe") && value != null) {
                    pipeRevMap.put(value.toString(), field.getName());
                    pipeMap.put(field.getName(),value.toString());
                }
            }
            // 根据储丝柜号获取喂丝机号
            String fsNum = fsRevMap.get(containerNum);
            String containerKey = StringUtils.isEmpty(containerNum) ? "" : containerNum.trim();
            if (containerKey.length() == 1) {
                containerKey = "0" + containerKey;
            } else if (containerKey.length() > 2) {
                containerKey = containerKey.substring(containerKey.length() - 2);
            }
            String fsNum = fsRevMap.get(containerKey);
            if (StringUtils.isEmpty(fsNum)) {
                // TODO   喂丝机号空返回信息
                continue;
@@ -155,7 +220,11 @@
                // TODO   管道号空返回信息
                continue;
            }
            // List存->  喂丝机对应的机组(如 pipe01 pipe02 代表1#、2#卷接机组)
            /**
             * pipeList 存放“该喂丝机对应的机组字段名”
             * - 例如 pipe01、pipe02 代表 1#、2#卷接/包装机组
             * - 后续会从 pipe01/pipe02 提取机组号 equNo,并用于拼接 key 查询卷接/包装表
             */
            List<String> pipeList = new ArrayList<>();
            for (Map.Entry<String, String> entry : pipeRevMap.entrySet()) {
                //fsNum第三位是喂丝机序号
@@ -168,8 +237,12 @@
                continue;
            }
            // 根据出料开始结束时间,查询该柜料在哪几个班次生产
            List<MdShiftBo> distShiftList = calcShiftSpans(distimebegin, distimeend, mdShifts);
            /**
             * 根据 [distimebegin, effectiveDistEnd] 计算涉及到的班次列表
             * - 出料已结束:effectiveDistEnd=distimeend
             * - 出料未结束:effectiveDistEnd=now
             */
            List<MdShiftBo> distShiftList = calcShiftSpans(distimebegin, effectiveDistEnd, mdShifts);
            storeSilkInfoVo.setDistShiftList(distShiftList);
            if (distShiftList.isEmpty()) continue;
            //查询日期和班次内卷接机组的产量
@@ -177,9 +250,12 @@
            // 卷包产量统计
            Double rollerOutput = 0.0;
            Double packerOutput = 0.0;
            // 明细列表
            // 明细列表(仅存最终正数结果)
            List<StoreSilkDetailVo> rollerDetailList = new ArrayList<>();
            List<StoreSilkDetailVo> packerDetailList = new ArrayList<>();
            // 操作记录列表(存所有增减过程)
            List<StoreSilkDetailVo> rollerRecordList = new ArrayList<>();
            List<StoreSilkDetailVo> packerRecordList = new ArrayList<>();
            for (int s = 0; s < distShiftList.size(); s++) {
                MdShiftBo shiftBo = distShiftList.get(s);
@@ -189,24 +265,23 @@
                String shift = shiftBo.getCode();
                // 解析班次时间
                // 1. 获取班次配置的开始和结束时间字符串 (格式如 "07:30:00")
                // 1) 获取班次配置的开始/结束时间 (可能是 "HH:mm" 或 "HH:mm:ss")
                String stimStr = shiftBo.getStim();
                String etimStr = shiftBo.getEtim();
                if (StringUtils.isEmpty(stimStr) || StringUtils.isEmpty(etimStr)) {
                    continue;
                }
                // 2. 补全秒数(如果配置仅为 HH:mm)
                // 2) 补全秒数(如果配置仅为 HH:mm)
                if (stimStr.length() == 5) stimStr += ":00";
                if (etimStr.length() == 5) etimStr += ":00";
                // 3. 结合日期解析为 LocalDateTime
                // 注意:shiftBo.getDay() 是该班次的归属日期
                // 3) 结合日期(shiftBo.getDay() 为班次归属日)解析为 LocalDateTime
                String dateStr = shiftBo.getDay().toInstant().atZone(zone).toLocalDate().toString();
                DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
                LocalDateTime shiftStart = LocalDateTime.parse(dateStr + " " + stimStr, formatter);
                LocalDateTime shiftEnd = LocalDateTime.parse(dateStr + " " + etimStr, formatter);
                // 4. 处理跨天班次:如果 结束时间 <= 开始时间,说明跨天,结束时间需 +1 天
                // 4) 处理跨天班次:如果 结束时间 <= 开始时间,说明跨天,结束时间 +1 天
                if (!shiftEnd.isAfter(shiftStart)) {
                    shiftEnd = shiftEnd.plusDays(1);
                }
@@ -215,16 +290,43 @@
                Date stimDate = Date.from(shiftStart.atZone(zone).toInstant());
                Date etimDate = Date.from(shiftEnd.atZone(zone).toInstant());
                // 计算班次结束前10分钟的时间点
                String shiftEndStr = shiftEnd.format(formatter);
                String tenMinBeforeShiftEnd = shiftEnd.minusMinutes(10).format(formatter);
                /**
                 * 为了同时支持“出料未结束”,这里引入一个“本班次的统计窗口”:
                 *
                 * 统计结束时刻 calcEnd = min(班次结束, effectiveDistEnd)
                 * - 出料已结束:effectiveDistEnd=distimeend,可能早于班次结束,calcEnd=distimeend
                 * - 出料未结束:effectiveDistEnd=now,calcEnd=now 或 班次结束(取较小)
                 *
                 * 统计开始时刻 calcStart = max(班次开始, distimebegin)
                 * - 若出料开始晚于班次开始,则从出料开始统计
                 * - 若出料开始早于班次开始,则从班次开始统计
                 *
                 * 最终本班次有效产量口径为:
                 *   output = Qty(calcEnd) - Qty(calcStart)
                 *
                 * 说明:这个口径已经“隐含了扣尾”
                 * - 如果 calcEnd < 班次结束(例如出料已结束或统计到当前时刻),尾巴自然不会被统计进去
                 * - 因此不需要再额外写一个“扣尾 if(...)”分支,逻辑更统一、更不容易出错
                 */
                LocalDateTime effectiveEndLdt = LocalDateTime.ofInstant(effectiveDistEnd.toInstant(), zone);
                LocalDateTime calcEnd = shiftEnd.isBefore(effectiveEndLdt) ? shiftEnd : effectiveEndLdt;
                Date calcEndDate = Date.from(calcEnd.atZone(zone).toInstant());
                if (!calcEndDate.after(stimDate)) {
                    continue;
                }
                Date calcStartDate = distimebegin.after(stimDate) ? distimebegin : stimDate;
                if (!calcEndDate.after(calcStartDate)) {
                    continue;
                }
                String calcEndStr = calcEnd.format(formatter);
                String tenMinBeforeCalcEnd = calcEnd.minusMinutes(10).format(formatter);
                // 根据卷接机组和班次获取产量(pipeList = 多少个机组)
                for (int j = 0; j < pipeList.size(); j++) {
                    String pipe = pipeList.get(j);
                    // 提取机组号
                    String equNo = pipe.replaceAll("\\D+", "");
                    //管道号
                    // 管道号(用于明细展示):从 feedMatch 的 pipe01/pipe02... 字段值里取末位
                    String channel = pipeMap.get("pipe" + equNo);
                    if (channel != null && channel.length() > 0) {
                        channel = channel.substring(channel.length() - 1);
@@ -232,7 +334,12 @@
                        channel = "";
                    }
                    //注意4号管道以后对应的机组需要+1,机组4号空缺跳到5号
                    /**
                     * 机组号映射规则(业务约定):
                     * - pipe04 对应的机组号在系统里是空缺的,需要跳到 5 号
                     * - 因此当管道号 >= 4 时,机组号需要 +1
                     * - 同时这里补齐为 2 位数字,便于拼 key(例如 01/02/05...)
                     */
                    try {
                        int equ = Integer.parseInt(equNo);
                        equNo = String.format("%02d", equ >= 4 ? equ + 1 : equ);
@@ -243,70 +350,82 @@
                    }
                    // 拼接卷接机的key,中间 "1" 代表卷接机,格式:班次 + "1" + 机组号
                    // key 拼接规则:
                    // - 卷接机:班次 + "1" + 机组号(例如 101、105...)
                    String key = shift + "1" + equNo;
                    // 拼接包装机的key,中间 "2" 代表包装机,格式:班次 + "2" + 机组号
                    // - 包装机:班次 + "2" + 机组号(例如 201、205...)
                    String packerKey = shift + "2" + equNo;
                    // ================= 卷接机产量统计 =================
                    Double currentRollerOutput = 0.0;
                    // 1. 查询班次结束时的产量快照
                    /**
                     * 取数策略说明(卷接/包装一致):
                     * - 表内的 qty 视为累计值(同一 key 下单调递增)
                     * - 由于数据是采样上报,某个时刻不一定刚好有记录
                     * - 因此采用“目标时刻前 10 分钟内,离目标最近的一条记录”作为快照值
                     *
                     * 注意:10分钟是经验窗口,如果现场采样更稀疏,可考虑放宽窗口
                     */
                    // 1) 查询统计结束时刻 calcEnd 的快照值 Qty(calcEnd)
                    LambdaQueryWrapper<RollerTimeData> rlqw = new LambdaQueryWrapper<>();
                    rlqw.le(RollerTimeData::getTime, shiftEndStr)
                    rlqw.le(RollerTimeData::getTime, calcEndStr)
                            .eq(RollerTimeData::getKey, key)
                            .ge(RollerTimeData::getTime, tenMinBeforeShiftEnd)
                            .ge(RollerTimeData::getTime, tenMinBeforeCalcEnd)
                            .orderByDesc(RollerTimeData::getTime)
                            .isNotNull(RollerTimeData::getQty)
                            .gt(RollerTimeData::getQty, 0)
                            .last("LIMIT 1");
                    RollerTimeData rData = rollerTimeDataMapper.selectOne(rlqw);
                    RollerTimeData rData = rollerTimeDataService.selectOne(rlqw);
                    if (rData != null) {
                        // 初始产量设为班次结束时的累计值
                        // 先把统计结束时刻累计值加进来:current = Qty(calcEnd)
                        currentRollerOutput += rData.getQty();
                        // 2. 处理“扣头”:出料开始时间 > 班次开始时间
                        if (distimebegin.after(stimDate)) {
                            LocalDateTime distBeginTime = LocalDateTime.ofInstant(distimebegin.toInstant(), zone);
                            String distBeginStr = distBeginTime.format(formatter);
                            String tenMinBeforeDistBegin = distBeginTime.minusMinutes(10).format(formatter);
                        // 记录过程:班次截止累计
                        StoreSilkDetailVo endRecord = new StoreSilkDetailVo();
                        endRecord.setFsNum(fsNum.substring(2, 3));
                        endRecord.setSiloNum(containerNum);
                        endRecord.setPipeNum(channel);
                        endRecord.setEquNo(equNo);
                        endRecord.setShiftCode(shift);
                        endRecord.setShiftStartTime(calcStartDate);
                        endRecord.setShiftEndTime(calcEndDate);
                        endRecord.setOutput(rData.getQty());
                        endRecord.setCalcType("班次截止累计");
                        endRecord.setHitTime(rData.getTime());
                        rollerRecordList.add(endRecord);
                        // 2) 扣“头”:如果统计开始时刻晚于班次开始,则减去 Qty(calcStart)
                        if (calcStartDate.after(stimDate)) {
                            LocalDateTime calcStartLdt = LocalDateTime.ofInstant(calcStartDate.toInstant(), zone);
                            String calcStartStr = calcStartLdt.format(formatter);
                            String tenMinBeforeCalcStart = calcStartLdt.minusMinutes(10).format(formatter);
                            LambdaQueryWrapper<RollerTimeData> beginRlqw = new LambdaQueryWrapper<>();
                            beginRlqw.le(RollerTimeData::getTime, distBeginStr)
                                    .eq(RollerTimeData::getKey, key)
                                    .ge(RollerTimeData::getTime, tenMinBeforeDistBegin)
                                    .orderByDesc(RollerTimeData::getTime)
                                    .isNotNull(RollerTimeData::getQty)
                                    .gt(RollerTimeData::getQty, 0)
                                    .last("LIMIT 1");
                            beginRlqw.le(RollerTimeData::getTime, calcStartStr)
                                .eq(RollerTimeData::getKey, key)
                                .ge(RollerTimeData::getTime, tenMinBeforeCalcStart)
                                .orderByDesc(RollerTimeData::getTime)
                                .isNotNull(RollerTimeData::getQty)
                                .gt(RollerTimeData::getQty, 0)
                                .last("LIMIT 1");
                            RollerTimeData rBeginData = rollerTimeDataMapper.selectOne(beginRlqw);
                            RollerTimeData rBeginData = rollerTimeDataService.selectOne(beginRlqw);
                            if (rBeginData != null) {
                                currentRollerOutput -= rBeginData.getQty();
                            }
                        }
                        // 3. 处理“扣尾”:出料结束时间 < 班次结束时间
                        if (etimDate.after(distimeend)) {
                            LocalDateTime distEndTime = LocalDateTime.ofInstant(distimeend.toInstant(), zone);
                            String distEndStr = distEndTime.format(formatter);
                            String tenMinBeforeDistEnd = distEndTime.minusMinutes(10).format(formatter);
                            LambdaQueryWrapper<RollerTimeData> endRlqw = new LambdaQueryWrapper<>();
                            endRlqw.le(RollerTimeData::getTime, distEndStr)
                                    .eq(RollerTimeData::getKey, key)
                                    .ge(RollerTimeData::getTime, tenMinBeforeDistEnd)
                                    .orderByDesc(RollerTimeData::getTime)
                                    .isNotNull(RollerTimeData::getQty)
                                    .gt(RollerTimeData::getQty, 0)
                                    .last("LIMIT 1");
                            RollerTimeData rEndData = rollerTimeDataMapper.selectOne(endRlqw);
                            if (rEndData != null) {
                                double qtyDelta = rData.getQty() - rEndData.getQty();
                                if (qtyDelta > 0) {
                                    currentRollerOutput -= qtyDelta;
                                }
                                // 记录过程:扣除头部产量
                                StoreSilkDetailVo beginRecord = new StoreSilkDetailVo();
                                beginRecord.setFsNum(fsNum.substring(2, 3));
                                beginRecord.setSiloNum(containerNum);
                                beginRecord.setPipeNum(channel);
                                beginRecord.setEquNo(equNo);
                                beginRecord.setShiftCode(shift);
                                beginRecord.setShiftStartTime(calcStartDate);
                                beginRecord.setShiftEndTime(calcEndDate);
                                beginRecord.setOutput(-rBeginData.getQty()); // 负数表示扣除
                                beginRecord.setCalcType("扣除出料前累计");
                                beginRecord.setHitTime(rBeginData.getTime());
                                rollerRecordList.add(beginRecord);
                            }
                        }
                    }
@@ -320,71 +439,75 @@
                        detail.setPipeNum(channel);
                        detail.setEquNo(equNo);
                        detail.setShiftCode(shift);
                        detail.setShiftStartTime(stimDate);
                        detail.setShiftEndTime(etimDate);
                        detail.setShiftStartTime(calcStartDate);
                        detail.setShiftEndTime(calcEndDate);
                        detail.setOutput(currentRollerOutput);
                        rollerDetailList.add(detail);
                    }
                    // ================= 包装机产量统计 =================
                    Double currentPackerOutput = 0.0;
                    // 1. 查询班次结束时的产量快照
                    // 1) 查询统计结束时刻 calcEnd 的快照值 Qty(calcEnd)
                    LambdaQueryWrapper<PackerTimeData> plqw = new LambdaQueryWrapper<>();
                    plqw.le(PackerTimeData::getTime, shiftEndStr)
                    plqw.le(PackerTimeData::getTime, calcEndStr)
                            .eq(PackerTimeData::getKey, packerKey)
                            .ge(PackerTimeData::getTime, tenMinBeforeShiftEnd)
                            .ge(PackerTimeData::getTime, tenMinBeforeCalcEnd)
                            .orderByDesc(PackerTimeData::getTime)
                            .isNotNull(PackerTimeData::getQty)
                            .gt(PackerTimeData::getQty, 0)
                            .last("LIMIT 1");
                    PackerTimeData pData = packerTimeDataMapper.selectOne(plqw);
                    PackerTimeData pData = packerTimeDataService.selectOne(plqw);
                    if (pData != null) {
                        // 初始产量设为班次结束时的累计值
                        // 先把统计结束时刻累计值加进来:current = Qty(calcEnd)
                        currentPackerOutput += pData.getQty();
                        // 2. 处理“扣头”
                        if (distimebegin.after(stimDate)) {
                            LocalDateTime distBeginTime = LocalDateTime.ofInstant(distimebegin.toInstant(), zone);
                            String distBeginStr = distBeginTime.format(formatter);
                            String tenMinBeforeDistBegin = distBeginTime.minusMinutes(10).format(formatter);
                        // 记录过程:班次截止累计
                        StoreSilkDetailVo endRecord = new StoreSilkDetailVo();
                        endRecord.setFsNum(fsNum.substring(2, 3));
                        endRecord.setSiloNum(containerNum);
                        endRecord.setPipeNum(channel);
                        endRecord.setEquNo(equNo);
                        endRecord.setShiftCode(shift);
                        endRecord.setShiftStartTime(calcStartDate);
                        endRecord.setShiftEndTime(calcEndDate);
                        endRecord.setOutput(pData.getQty());
                        endRecord.setCalcType("班次截止累计");
                        endRecord.setHitTime(pData.getTime());
                        packerRecordList.add(endRecord);
                        // 2) 扣“头”:如果统计开始时刻晚于班次开始,则减去 Qty(calcStart)
                        if (calcStartDate.after(stimDate)) {
                            LocalDateTime calcStartLdt = LocalDateTime.ofInstant(calcStartDate.toInstant(), zone);
                            String calcStartStr = calcStartLdt.format(formatter);
                            String tenMinBeforeCalcStart = calcStartLdt.minusMinutes(10).format(formatter);
                            LambdaQueryWrapper<PackerTimeData> beginPlqw = new LambdaQueryWrapper<>();
                            beginPlqw.le(PackerTimeData::getTime, distBeginStr)
                                    .eq(PackerTimeData::getKey, packerKey)
                                    .ge(PackerTimeData::getTime, tenMinBeforeDistBegin)
                                    .orderByDesc(PackerTimeData::getTime)
                                    .isNotNull(PackerTimeData::getQty)
                                    .gt(PackerTimeData::getQty, 0)
                                    .last("LIMIT 1");
                            beginPlqw.le(PackerTimeData::getTime, calcStartStr)
                                .eq(PackerTimeData::getKey, packerKey)
                                .ge(PackerTimeData::getTime, tenMinBeforeCalcStart)
                                .orderByDesc(PackerTimeData::getTime)
                                .isNotNull(PackerTimeData::getQty)
                                .gt(PackerTimeData::getQty, 0)
                                .last("LIMIT 1");
                            PackerTimeData pBeginData = packerTimeDataMapper.selectOne(beginPlqw);
                            PackerTimeData pBeginData = packerTimeDataService.selectOne(beginPlqw);
                            if (pBeginData != null) {
                                currentPackerOutput -= pBeginData.getQty();
                            }
                        }
                        // 3. 处理“扣尾”
                        if (etimDate.after(distimeend)) {
                            LocalDateTime distEndTime = LocalDateTime.ofInstant(distimeend.toInstant(), zone);
                            String distEndStr = distEndTime.format(formatter);
                            String tenMinBeforeDistEnd = distEndTime.minusMinutes(10).format(formatter);
                            LambdaQueryWrapper<PackerTimeData> endPlqw = new LambdaQueryWrapper<>();
                            endPlqw.le(PackerTimeData::getTime, distEndStr)
                                    .eq(PackerTimeData::getKey, packerKey)
                                    .ge(PackerTimeData::getTime, tenMinBeforeDistEnd)
                                    .orderByDesc(PackerTimeData::getTime)
                                    .isNotNull(PackerTimeData::getQty)
                                    .gt(PackerTimeData::getQty, 0)
                                    .last("LIMIT 1");
                            PackerTimeData pEndData = packerTimeDataMapper.selectOne(endPlqw);
                            if (pEndData != null) {
                                double qtyDelta = pData.getQty() - pEndData.getQty();
                                if (qtyDelta > 0) {
                                    currentPackerOutput -= qtyDelta;
                                }
                                // 记录过程:扣除头部产量
                                StoreSilkDetailVo beginRecord = new StoreSilkDetailVo();
                                beginRecord.setFsNum(fsNum.substring(2, 3));
                                beginRecord.setSiloNum(containerNum);
                                beginRecord.setPipeNum(channel);
                                beginRecord.setEquNo(equNo);
                                beginRecord.setShiftCode(shift);
                                beginRecord.setShiftStartTime(calcStartDate);
                                beginRecord.setShiftEndTime(calcEndDate);
                                beginRecord.setOutput(-pBeginData.getQty()); // 负数表示扣除
                                beginRecord.setCalcType("扣除出料前累计");
                                beginRecord.setHitTime(pBeginData.getTime());
                                packerRecordList.add(beginRecord);
                            }
                        }
                    }
@@ -398,17 +521,20 @@
                        detail.setPipeNum(channel);
                        detail.setEquNo(equNo);
                        detail.setShiftCode(shift);
                        detail.setShiftStartTime(stimDate);
                        detail.setShiftEndTime(etimDate);
                        detail.setShiftStartTime(calcStartDate);
                        detail.setShiftEndTime(calcEndDate);
                        detail.setOutput(currentPackerOutput);
                        packerDetailList.add(detail);
                    }
                }
            }
            // 将汇总结果与明细结果回写到 StoreSilkInfoVo,供前端列表/抽屉明细展示使用
            storeSilkInfoVo.setRollerOutput(rollerOutput);
            storeSilkInfoVo.setPackerOutput(packerOutput);
            storeSilkInfoVo.setRollerDetailList(rollerDetailList);
            storeSilkInfoVo.setPackerDetailList(packerDetailList);
            storeSilkInfoVo.setRollerRecordList(rollerRecordList);
            storeSilkInfoVo.setPackerRecordList(packerRecordList);
        }
@@ -521,15 +647,50 @@
    private LambdaQueryWrapper<StoreSilkInfo> buildQueryWrapper(StoreSilkInfoBo bo) {
        Map<String, Object> params = bo.getParams();
        LambdaQueryWrapper<StoreSilkInfo> lqw = Wrappers.lambdaQuery();
        lqw.orderByAsc(StoreSilkInfo::getId);
        lqw.like(StringUtils.isNotBlank(bo.getMaterialname()), StoreSilkInfo::getMaterialname, bo.getMaterialname());
        lqw.eq(StringUtils.isNotBlank(bo.getBatchcode()), StoreSilkInfo::getBatchcode, bo.getBatchcode());
        lqw.eq(bo.getActualstarttime() != null, StoreSilkInfo::getActualstarttime, bo.getActualstarttime());
        lqw.like(StringUtils.isNotBlank(bo.getBatchcode()), StoreSilkInfo::getBatchcode, bo.getBatchcode());
        if (bo.getActualstarttime() != null) {
            ZoneId zone = ZoneId.systemDefault();
            LocalDate day = bo.getActualstarttime().toInstant().atZone(zone).toLocalDate();
            Date dayStart = Date.from(day.atStartOfDay(zone).toInstant());
            Date nextDayStart = Date.from(day.plusDays(1).atStartOfDay(zone).toInstant());
            lqw.ge(StoreSilkInfo::getActualstarttime, dayStart);
            lqw.lt(StoreSilkInfo::getActualstarttime, nextDayStart);
        }
        lqw.eq(bo.getDistimebegin() != null, StoreSilkInfo::getDistimebegin, bo.getDistimebegin());
        lqw.eq(bo.getDistimeend() != null, StoreSilkInfo::getDistimeend, bo.getDistimeend());
        lqw.eq(StringUtils.isNotBlank(bo.getSiloid()), StoreSilkInfo::getSiloid, bo.getSiloid());
        lqw.between(params.get("beginTime") != null && params.get("endTime") != null,
                StoreSilkInfo::getDistimeend, params.get("beginTime"), params.get("endTime"));
        if (StringUtils.isNotBlank(bo.getSiloid())) {
            // 支持多个柜号查询,以逗号分隔
            String[] siloids = bo.getSiloid().split(",");
            lqw.and(wrapper -> {
                for (String val : siloids) {
                    val = val.trim();
                    if (StringUtils.isBlank(val)) {
                        continue;
                    }
                    String finalVal = val;
                    // 使用 OR 连接多个柜号条件
                    wrapper.or(w -> {
                        try {
                            int num = Integer.parseInt(finalVal);
                            String padded = String.format("%02d", num);
                            // 匹配 _1 或 _01 (兼容不补零和补零的情况)
                            w.likeLeft(StoreSilkInfo::getSiloid, "_" + num)
                                    .or()
                                    .likeLeft(StoreSilkInfo::getSiloid, "_" + padded);
                        } catch (NumberFormatException e) {
                            // 非数字则按原值匹配
                            w.eq(StoreSilkInfo::getSiloid, finalVal);
                        }
                    });
                }
            });
        }
        if (params.get("beginTime") != null && params.get("endTime") != null) {
            lqw.apply("distimeend BETWEEN TO_DATE({0}, 'YYYY-MM-DD HH24:MI:SS') AND TO_DATE({1}, 'YYYY-MM-DD HH24:MI:SS')",
                    params.get("beginTime"),
                    params.get("endTime"));
        }
        return lqw;
    }
@@ -545,7 +706,6 @@
        validEntityBeforeSave(add);
        boolean flag = baseMapper.insert(add) > 0;
        if (flag) {
            bo.setId(add.getId());
        }
        return flag;
    }