gaoluyang
昨天 8c65f898ed6e5b0c7a94c792c4562f6ec831dccd
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/feedback/MesProFeedbackServiceImpl.java
@@ -1,16 +1,20 @@
package cn.iocoder.yudao.module.mes.service.pro.feedback;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.ObjUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.mes.controller.admin.pro.feedback.vo.*;
import cn.iocoder.yudao.module.mes.enums.MesBizTypeConstants;
import cn.iocoder.yudao.module.mes.enums.md.autocode.MesMdAutoCodeRuleCodeEnum;
import cn.iocoder.yudao.module.mes.enums.pro.MesProFeedbackTypeEnum;
import cn.iocoder.yudao.module.mes.service.md.autocode.MesMdAutoCodeRecordService;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.feedback.MesProFeedbackAuxDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.feedback.MesProFeedbackDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.process.MesProProcessDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.route.MesProRouteProcessDO;
@@ -21,10 +25,17 @@
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.itemconsume.MesWmItemConsumeDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.productproduce.MesWmProductProduceDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.productproduce.MesWmProductProduceLineDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.ipqc.MesQcIpqcDO;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.feedback.MesProFeedbackAuxMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.feedback.MesProFeedbackMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.qc.ipqc.MesQcIpqcMapper;
import cn.iocoder.yudao.module.mes.enums.pro.MesProFeedbackStatusEnum;
import cn.iocoder.yudao.module.mes.enums.pro.MesProProcessBillingTypeEnum;
import cn.iocoder.yudao.module.mes.enums.pro.MesProTaskStatusEnum;
import cn.iocoder.yudao.module.mes.enums.qc.MesQcStatusEnum;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmQualityStatusEnum;
import cn.iocoder.yudao.module.mes.service.qc.ipqc.MesQcIpqcService;
import cn.iocoder.yudao.module.mes.service.qc.ipqc.MesQcIpqcWageFieldService;
import cn.iocoder.yudao.module.mes.service.pro.process.MesProProcessService;
import cn.iocoder.yudao.module.mes.service.pro.route.MesProRouteProcessService;
import cn.iocoder.yudao.module.mes.service.pro.task.MesProTaskService;
@@ -32,6 +43,11 @@
import cn.iocoder.yudao.module.mes.service.pro.workorder.MesProWorkOrderProcessService;
import cn.iocoder.yudao.module.mes.service.pro.workorder.MesProWorkOrderService;
import cn.iocoder.yudao.module.mes.service.pro.workorder.MesProWorkOrderParamRecordService;
import cn.iocoder.yudao.module.mes.service.pro.wage.MesProFeedbackQualityResult;
import cn.iocoder.yudao.module.mes.service.pro.wage.MesProFeedbackWageBreakdown;
import cn.iocoder.yudao.module.mes.service.pro.wage.MesProFeedbackWageCalculator;
import cn.iocoder.yudao.module.mes.service.pro.wage.MesProProcessWageRuleService;
import cn.iocoder.yudao.module.mes.service.pro.worktime.MesProWorktimeService;
import cn.iocoder.yudao.module.mes.service.wm.itemconsume.MesWmItemConsumeService;
import cn.iocoder.yudao.module.mes.service.wm.productproduce.MesWmProductProduceLineService;
import cn.iocoder.yudao.module.mes.service.wm.productproduce.MesWmProductProduceService;
@@ -50,12 +66,17 @@
import org.springframework.validation.annotation.Validated;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.*;
@@ -75,6 +96,22 @@
    @Resource
    private MesProFeedbackMapper feedbackMapper;
    @Resource
    private MesProFeedbackAuxMapper feedbackAuxMapper;
    @Resource
    private MesProWorktimeService worktimeService;
    @Resource
    private MesProFeedbackWageCalculator wageCalculator;
    @Resource
    private MesProProcessWageRuleService wageRuleService;
    @Resource
    private MesQcIpqcMapper ipqcMapper;
    @Resource
    @Lazy // IPQC 完成回写报工,避免循环依赖
    private MesQcIpqcService ipqcService;
    @Resource
    @Lazy // 过程检验单工资字段值(质检填写),避免与 IPQC 主服务循环依赖
    private MesQcIpqcWageFieldService ipqcWageFieldService;
    @Resource
    private MesProWorkOrderService workOrderService;
@@ -122,7 +159,12 @@
                .setId(null)
                .setStatus(MesProFeedbackStatusEnum.PREPARE.getStatus())
                .setItemId(task.getItemId());
        // 2.1 计算标准工时快照与辅助工时明细(只用于考核对比,参数缺失不阻断报工)
        List<MesProFeedbackAuxDO> auxList = worktimeService.calculateForFeedback(feedback, createReqVO.getAuxList());
        // 2.2 按工序计费类型、单价规则、辅助计薪与工资字段快照工资,谁报的工就记在谁名下
        applyProcessBilling(feedback, processService.getProcess(task.getProcessId()), auxList);
        feedbackMapper.insert(feedback);
        saveFeedbackAux(feedback.getId(), auxList);
        // 3. 保存参数值;必填参数校验失败时整体回滚,避免残留草稿
        saveParamValues(task.getId(), createReqVO.getParamValues());
        return feedback.getId();
@@ -140,10 +182,26 @@
        // 2. 更新(自动填充 itemId)
        MesProFeedbackDO updateObj = BeanUtils.toBean(updateReqVO, MesProFeedbackDO.class)
                .setItemId(task.getItemId());
        // 2.1 重新计算标准工时快照与辅助工时明细
        List<MesProFeedbackAuxDO> auxList = worktimeService.calculateForFeedback(updateObj, updateReqVO.getAuxList());
        // 2.2 按工序当前的计费类型/单价规则/辅助计薪/工资字段重算工资(草稿阶段允许改工时/数量)
        applyProcessBilling(updateObj, processService.getProcess(task.getProcessId()), auxList);
        feedbackMapper.updateById(updateObj);
        feedbackAuxMapper.deleteByFeedbackId(updateReqVO.getId());
        saveFeedbackAux(updateReqVO.getId(), auxList);
        equipmentService.resetSnapshot(updateReqVO.getId());
        // 3. 保存参数值;必填参数校验失败时整体回滚
        saveParamValues(task.getId(), updateReqVO.getParamValues());
    }
    private void saveFeedbackAux(Long feedbackId, List<MesProFeedbackAuxDO> auxList) {
        if (CollUtil.isEmpty(auxList)) {
            return;
        }
        auxList.forEach(aux -> {
            aux.setId(null).setFeedbackId(feedbackId);
            feedbackAuxMapper.insert(aux);
        });
    }
    private void saveParamValues(Long taskId, List<MesProFeedbackSaveReqVO.ParamValue> paramValues) {
@@ -164,8 +222,9 @@
        // 1. 校验存在 + 草稿状态
        validateFeedbackStatusPrepare(id);
        // 2. 删除
        // 2. 删除(辅助工时明细一并删除)
        feedbackMapper.deleteById(id);
        feedbackAuxMapper.deleteByFeedbackId(id);
    }
    @Override
@@ -209,6 +268,7 @@
        MesProWorkOrderDO workOrder = workOrderService.getWorkOrder(feedback.getWorkOrderId());
        // 1.2 报工与质检分离:提交报工不再自动生成过程检验单,质检由质检部门手工建单
        if (!processInstanceApi.isGlobalAutoApprove() && workOrder != null
                && Boolean.TRUE.equals(workOrder.getFeedbackApprove())
                && cn.hutool.core.util.StrUtil.isNotBlank(workOrder.getProcessDefinitionKey())) {
@@ -235,7 +295,8 @@
            feedbackMapper.updateById(new MesProFeedbackDO().setId(id)
                    .setStatus(MesProFeedbackStatusEnum.APPROVING.getStatus()));
            MesProFeedbackApproveReqVO approveReqVO = cn.iocoder.yudao.framework.common.util.object.BeanUtils.toBean(reqVO, MesProFeedbackApproveReqVO.class);
            approveFeedback(approveReqVO);
            // 自动审批:过程检验单刚生成尚未填写,不做质检工资前置校验(质量调整留空)
            approveFeedback(approveReqVO, false);
        }
    }
@@ -290,16 +351,21 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public boolean approveFeedback(MesProFeedbackApproveReqVO reqVO) {
        return approveFeedback(reqVO, true);
    }
    private boolean approveFeedback(MesProFeedbackApproveReqVO reqVO, boolean enforceQualityGate) {
        equipmentService.lock();
        // 1.1 校验存在 + 审批中状态
        MesProFeedbackDO feedback = validateFeedbackStatusApproving(reqVO.getId());
        equipmentService.validateApproval(feedback);
        try (var equipmentScope = equipmentService.beginApproval(feedback)) {
            return approveValidatedFeedback(reqVO, feedback);
            return approveValidatedFeedback(reqVO, feedback, enforceQualityGate);
        }
    }
    private boolean approveValidatedFeedback(MesProFeedbackApproveReqVO reqVO, MesProFeedbackDO feedback) {
    private boolean approveValidatedFeedback(MesProFeedbackApproveReqVO reqVO, MesProFeedbackDO feedback,
                                             boolean enforceQualityGate) {
        // 1.2 校验报工数量 > 0
        if (feedback.getFeedbackQuantity() == null
                || feedback.getFeedbackQuantity().compareTo(BigDecimal.ZERO) <= 0) {
@@ -308,22 +374,16 @@
        // 1.3 校验任务未完成
        taskService.validateTaskNotFinished(feedback.getTaskId());
        // 工序检验标识不再控制产出入库:所有新报工产出均需人工质检。
        MesProRouteProcessDO routeProcess = routeProcessService.getRouteProcessByRouteIdAndProcessId(
                feedback.getRouteId(), feedback.getProcessId());
        boolean keyFlag = routeProcess != null && Boolean.TRUE.equals(routeProcess.getKeyFlag());
        // 报工与质检分离:报工审批不再依赖「是否质检」开关,质量数据由质检单回填。
        // 1.4 工资按报工时点的快照重算,避免工序定额事后调整影响已提交的报工
        recalculateWageFromSnapshot(feedback, null);
        // 3. 物料消耗:倒冲工序按本工序投料 BOM 自动扣料;非倒冲工序跳过(物料走手工领料/投料)
        boolean backflushFlag = routeProcess != null && Boolean.TRUE.equals(routeProcess.getBackflushFlag());
        if (backflushFlag) {
            // 3.1 校验倒冲工序必须配置本工序的投料 BOM,避免静默不扣料
            //     (只校验当前报工工序的投料,其他工序投料由各自工序报工时扣除)
        // 3. 物料消耗:只要本工序配置了生产 BOM 就自动扣料,不再依赖倒冲开关;
        //    未配置 BOM 的工序跳过(物料走手工领料/投料),不阻断报工审批
            List<MesProWorkOrderBomDO> boms = workOrderBomService.getWorkOrderBomListByWorkOrderIdAndProcessId(
                    feedback.getWorkOrderId(), feedback.getProcessId());
            if (CollUtil.isEmpty(boms)) {
                throw exception(PRO_FEEDBACK_BACKFLUSH_BOM_REQUIRED);
            }
            // 3.2 生成消耗记录并执行扣减(本工序投料用量比例 × 报工数量,FIFO 扣线边库)
        if (CollUtil.isNotEmpty(boms)) {
            // 3.1 生成消耗记录并执行扣减(本工序投料用量比例 × 报工数量,FIFO 扣线边库)
            MesWmItemConsumeDO itemConsume = itemConsumeService.generateItemConsume(feedback);
            if (itemConsume != null) {
                itemConsumeService.finishItemConsume(itemConsume.getId());
@@ -335,23 +395,23 @@
                .setStatus(MesProFeedbackStatusEnum.UNCHECK.getStatus())
                .setQualifiedQuantity(BigDecimal.ZERO)
                .setUnqualifiedQuantity(BigDecimal.ZERO)
                .setUncheckQuantity(feedback.getFeedbackQuantity()));
                .setUncheckQuantity(feedback.getFeedbackQuantity())
                .setBillingType(feedback.getBillingType())
                .setWageRate(feedback.getWageRate())
                .setWageRuleId(feedback.getWageRuleId())
                .setWorkHours(feedback.getWorkHours())
                .setWageAmount(feedback.getWageAmount())
                .setAuxPaidHours(feedback.getAuxPaidHours())
                .setAuxWageAmount(feedback.getAuxWageAmount())
                .setQualityPassRate(feedback.getQualityPassRate())
                .setQualityCoefficient(feedback.getQualityCoefficient())
                .setQualityDeduction(feedback.getQualityDeduction())
                .setWageBreakdown(feedback.getWageBreakdown()));
        feedback.setQualifiedQuantity(BigDecimal.ZERO).setUnqualifiedQuantity(BigDecimal.ZERO);
        productProduceService.generateProductProduce(feedback, true, reqVO);
        // 6. 根据是否需要检验,决定产出和入库方式
        // 6.2 调整 只要审批成功直接更新
        // 关键工序:更新任务 + 工单的已生产数量;非关键工序:只更新任务的已生产数量(工单只看关键工序产出)
        if (keyFlag) {
        // 每道工序只累计本任务;工单与 MPS 由最后一道工序回写,与关键工序标记无关。
            updateTaskAndWorkOrderByFeedback(feedback);
        } else {
            taskService.updateProducedQuantity(feedback.getTaskId(),
                    feedback.getFeedbackQuantity(),
                    ObjectUtil.defaultIfNull(feedback.getQualifiedQuantity(), BigDecimal.ZERO),
                    ObjectUtil.defaultIfNull(feedback.getUnqualifiedQuantity(), BigDecimal.ZERO));
                checkAndPublishProductionFinished(feedback.getWorkOrderId());
        }
        equipmentService.afterApproved(feedback);
        return false; // 产出等待质检,报工产量已累计
    }
@@ -368,21 +428,17 @@
     *
     * @param feedback 报工单
     */
    private void updateTaskAndWorkOrderByFeedback(MesProFeedbackDO feedback) {
    void updateTaskAndWorkOrderByFeedback(MesProFeedbackDO feedback) {
        // 报工审批只累计产量;合格与不合格数量在质检完成时累计。
        taskService.updateProducedQuantity(feedback.getTaskId(),
                feedback.getFeedbackQuantity(), BigDecimal.ZERO, BigDecimal.ZERO);
        // 3. 更新订单的已生产数量
        // 新增逻辑:只有在该笔订单最后一个工序才更新生产订单的已生产数量
        // 只有最后一道工序的报工计入工单与 MPS,避免中间工序重复累计。
        MesProWorkOrderProcessDO lastProcess = workOrderProcessService.getLastWorkOrderProcessByWorkOrderId(feedback.getWorkOrderId());
        if (lastProcess != null && ObjUtil.equal(lastProcess.getProcessId(), feedback.getProcessId())) {
            workOrderService.updateProducedQuantity(feedback.getWorkOrderId(),
                    feedback.getFeedbackQuantity());
            // 4. 更新 MPS 的已生产数量(如果是销售订单来源)
            MesProMpsRespDTO mps = mpsApi.getMpsByWorkOrderId(feedback.getWorkOrderId());
            if (mps != null) {
                mpsApi.updateMpsProducedQuantity(mps.getId(), feedback.getFeedbackQuantity());
            }
            // MPS 只记录销售订单需求量;实际超产仍由工单和产出入库记录完整保留。
            allocateMpsProducedQuantity(feedback.getWorkOrderId(), feedback.getFeedbackQuantity());
        }
@@ -393,23 +449,45 @@
    /**
     * 检查并发布生产完成事件
     */
    private void checkAndPublishProductionFinished(Long workOrderId) {
        // 1. 获取工单关联的 MPS
        MesProMpsRespDTO mps = mpsApi.getMpsByWorkOrderId(workOrderId);
        if (mps == null || mps.getSaleOrderId() == null
    void checkAndPublishProductionFinished(Long workOrderId) {
        Set<Long> newlyCompletedSaleOrderIds = new LinkedHashSet<>();
        for (MesProMpsRespDTO mps : mpsApi.getMpsListByWorkOrderId(workOrderId)) {
            if (mps.getSaleOrderId() == null
                || ObjUtil.equal(mps.getStatus(), cn.iocoder.yudao.module.mes.enums.pro.MesProMpsStatusEnum.FINISHED.getStatus())
                || ObjUtil.equal(mps.getStatus(), cn.iocoder.yudao.module.mes.enums.pro.MesProMpsStatusEnum.CANCELED.getStatus())) {
            return; // 非销售订单来源的工单,跳过
                continue;
            }
            if (Boolean.TRUE.equals(mpsApi.checkAndFinishMps(mps.getId()))) {
                newlyCompletedSaleOrderIds.add(mps.getSaleOrderId());
            }
        }
        for (Long saleOrderId : newlyCompletedSaleOrderIds) {
            if (mpsApi.checkSaleOrderProductionFinished(saleOrderId)) {
                eventPublisher.publishEvent(new ProductionFinishedEvent(saleOrderId, java.time.LocalDateTime.now()));
            }
        }
        }
        // 2. 检查并自动完成 MPS(如果满足条件)
        if (!Boolean.TRUE.equals(mpsApi.checkAndFinishMps(mps.getId()))) {
            return;
    void allocateMpsProducedQuantity(Long workOrderId, BigDecimal actualQuantity) {
        BigDecimal remaining = actualQuantity;
        for (MesProMpsRespDTO mps : mpsApi.getMpsListByWorkOrderId(workOrderId)) {
            if (remaining.signum() <= 0) {
                break;
        }
        // 3. 检查销售订单所有生产是否完成
        if (mpsApi.checkSaleOrderProductionFinished(mps.getSaleOrderId())) {
            eventPublisher.publishEvent(new ProductionFinishedEvent(mps.getSaleOrderId(), java.time.LocalDateTime.now()));
            if (ObjUtil.equal(mps.getStatus(), cn.iocoder.yudao.module.mes.enums.pro.MesProMpsStatusEnum.CANCELED.getStatus())) {
                continue;
            }
            BigDecimal planned = mps.getQuantity() == null ? BigDecimal.ZERO : mps.getQuantity();
            BigDecimal produced = mps.getQuantityProduced() == null ? BigDecimal.ZERO : mps.getQuantityProduced();
            BigDecimal need = planned.subtract(produced).max(BigDecimal.ZERO);
            BigDecimal assigned = remaining.min(need);
            if (assigned.signum() > 0) {
                mpsApi.updateMpsProducedQuantity(mps.getId(), assigned);
                remaining = remaining.subtract(assigned);
            }
        }
        if (remaining.signum() > 0) {
            log.info("[allocateMpsProducedQuantity] 工单({}) 超产数量({})保留在产出库存,不分配到销售订单", workOrderId, remaining);
        }
    }
@@ -479,6 +557,164 @@
        }
        return task;
    }
    /**
     * 按工序配置把计费类型、单价规则、辅助计薪与工资字段快照到报工单,并计算本次报工工资。
     *
     * <p>口径:
     * <ul>
     *   <li>计时:主工资 = 本次报工工时(小时)× 单价(元/小时);单价大于 0 时必须填写工时</li>
     *   <li>计件:主工资 = 合格数量(未填时取报工数量)× 单价(元/件)</li>
     *   <li>辅助:辅助明细分钟数 ÷ 60 × 辅助小时单价;工序辅助小时工资兜底</li>
     *   <li>字段:系数连乘作用于(主工资 + 辅助工资),再叠加加减金额与计价数量</li>
     * </ul>
     * 前端传入的 billingType/wageRate/wageAmount 一律以后端计算覆盖,防止被篡改。
     * 单价为 0 表示该工序尚未定价,按 0 元计薪,不阻断报工(核算时可按人调整)。
     */
    private void applyProcessBilling(MesProFeedbackDO feedback, MesProProcessDO process,
                                     List<MesProFeedbackAuxDO> auxList) {
        if (process == null) {
            throw exception(PRO_FEEDBACK_PROCESS_NOT_EXISTS, feedback.getProcessId());
        }
        MesProFeedbackWageBreakdown breakdown = wageCalculator.calculate(process,
                feedback.getRouteId(), feedback.getMachineryId(), feedback.getWorkHours(),
                feedback.getFeedbackQuantity(), feedback.getQualifiedQuantity(), auxList,
                feedback.getFeedbackTime() != null ? feedback.getFeedbackTime().toLocalDate() : null,
                getWageFieldValues(feedback.getId()));
        applyWageBreakdown(feedback, breakdown);
    }
    /**
     * 把工资计算结果写回报工单
     */
    private void applyWageBreakdown(MesProFeedbackDO feedback, MesProFeedbackWageBreakdown breakdown) {
        if (CollUtil.isNotEmpty(breakdown.getWarnings())) {
            log.warn("报工工资计算提示:报工单[{}] {}", feedback.getCode(), String.join(";", breakdown.getWarnings()));
        }
        feedback.setBillingType(breakdown.getBillingType())
                .setWageRate(breakdown.getWageRate())
                .setWageRuleId(breakdown.getWageRuleId())
                .setWorkHours(breakdown.getWorkHours())
                .setAuxPaidHours(breakdown.getAuxPaidHours())
                .setAuxWageAmount(breakdown.getAuxWage())
                .setWageAmount(breakdown.getFinalWage())
                .setQualityPassRate(breakdown.getQuality() != null ? breakdown.getQuality().getPassRate() : null)
                .setQualityCoefficient(breakdown.getQuality() != null ? breakdown.getQuality().getCoefficient() : null)
                .setQualityDeduction(breakdown.getQuality() != null && breakdown.getQuality().getDeduction() != null
                        ? breakdown.getQuality().getDeduction() : BigDecimal.ZERO)
                .setWageBreakdown(JsonUtils.toJsonString(breakdown));
    }
    /**
     * 取第一个设备编号作为主计价设备(快捷报工的 machineryIds 为逗号分隔)
     */
    private static Long firstMachineryId(String machineryIds) {
        if (StrUtil.isBlank(machineryIds)) {
            return null;
        }
        for (String part : machineryIds.split(",")) {
            String trimmed = part.trim();
            if (StrUtil.isBlank(trimmed)) {
                continue;
            }
            try {
                return Long.parseLong(trimmed);
            } catch (NumberFormatException ex) {
                return null;
            }
        }
        return null;
    }
    /**
     * 审批时按报工时点的快照重算工资,避免工序配置事后调整影响已提交的报工。
     * 历史数据(无工资拆分快照)则按当前工序配置补齐。
     *
     * @param qualityResult 过程检验汇总;空=非质检工序或自动审批,不做质量调整
     */
    private void recalculateWageFromSnapshot(MesProFeedbackDO feedback, MesProFeedbackQualityResult qualityResult) {
        BigDecimal qualifiedQuantity = qualityResult != null
                ? qualityResult.qualifiedQuantity() : feedback.getQualifiedQuantity();
        // 质检部门在过程检验单上填写的工资字段值,优先于工序固定值
        Map<String, String> wageFieldValues = getWageFieldValues(feedback.getId());
        MesProFeedbackWageBreakdown breakdown;
        if (StrUtil.isBlank(feedback.getWageBreakdown())) {
            breakdown = wageCalculator.calculate(processService.getProcess(feedback.getProcessId()),
                    feedback.getRouteId(), feedback.getMachineryId(), feedback.getWorkHours(),
                    feedback.getFeedbackQuantity(), qualifiedQuantity,
                    feedback.getId() != null ? feedbackAuxMapper.selectListByFeedbackId(feedback.getId())
                            : Collections.emptyList(),
                    feedback.getFeedbackTime() != null ? feedback.getFeedbackTime().toLocalDate() : null,
                    wageFieldValues);
        } else {
            breakdown = wageCalculator.fromSnapshot(feedback, qualifiedQuantity, wageFieldValues);
        }
        if (qualityResult != null) {
            wageCalculator.applyQuality(breakdown, feedback.getProcessId(), feedback.getRouteId(),
                    qualityResult.passRate(), qualityResult.unqualifiedQuantity());
        }
        applyWageBreakdown(feedback, breakdown);
    }
    /**
     * 取报工单关联的过程检验单上质检填写的工资字段值
     *
     * <p>同一报工单存在多张检验单时,已完成的优先;字段未填写时不放入 Map,计算时回退工序固定值。
     */
    private Map<String, String> getWageFieldValues(Long feedbackId) {
        if (feedbackId == null) {
            return Collections.emptyMap();
        }
        List<MesQcIpqcDO> ipqcList = ipqcMapper.selectListBySourceDoc(
                MesBizTypeConstants.PRO_FEEDBACK, feedbackId);
        if (CollUtil.isEmpty(ipqcList)) {
            return Collections.emptyMap();
        }
        List<Long> ipqcIds = new ArrayList<>(ipqcList.size());
        ipqcList.stream()
                .filter(ipqc -> ObjUtil.equal(ipqc.getStatus(), MesQcStatusEnum.FINISHED.getStatus()))
                .map(MesQcIpqcDO::getId).forEach(ipqcIds::add);
        ipqcList.stream()
                .filter(ipqc -> !ObjUtil.equal(ipqc.getStatus(), MesQcStatusEnum.FINISHED.getStatus()))
                .map(MesQcIpqcDO::getId).forEach(ipqcIds::add);
        return ipqcWageFieldService.getValueMapByIpqcIds(ipqcIds);
    }
    /**
     * 校验质检工序的过程检验已完成,并汇总合格率与不合格数量
     */
    private MesProFeedbackQualityResult validateAndGetQualityResult(MesProFeedbackDO feedback) {
        MesProProcessDO process = processService.getProcess(feedback.getProcessId());
        String processName = process != null ? process.getName() : "ID=" + feedback.getProcessId();
        List<MesQcIpqcDO> finishedList = ipqcMapper.selectListBySourceDoc(
                        MesBizTypeConstants.PRO_FEEDBACK, feedback.getId()).stream()
                .filter(ipqc -> ObjUtil.equal(ipqc.getStatus(), MesQcStatusEnum.FINISHED.getStatus()))
                .toList();
        if (CollUtil.isEmpty(finishedList)) {
            throw new ServiceException(PRO_FEEDBACK_QUALITY_INSPECTION_NOT_FINISHED.getCode(),
                    StrUtil.format("工序[{}]为质检工序,报工单[{}]尚未完成过程检验;"
                                    + "请到「质量管理-过程检验」完成检验后再审批",
                            processName, feedback.getCode()));
        }
        BigDecimal checkQuantity = BigDecimal.ZERO;
        BigDecimal qualifiedQuantity = BigDecimal.ZERO;
        BigDecimal unqualifiedQuantity = BigDecimal.ZERO;
        for (MesQcIpqcDO ipqc : finishedList) {
            checkQuantity = checkQuantity.add(ObjectUtil.defaultIfNull(ipqc.getCheckQuantity(), BigDecimal.ZERO));
            qualifiedQuantity = qualifiedQuantity.add(ObjectUtil.defaultIfNull(ipqc.getQualifiedQuantity(), BigDecimal.ZERO));
            unqualifiedQuantity = unqualifiedQuantity.add(
                    ObjectUtil.defaultIfNull(ipqc.getUnqualifiedQuantity(), BigDecimal.ZERO));
        }
        if (checkQuantity.signum() <= 0) {
            throw new ServiceException(PRO_FEEDBACK_QUALITY_INSPECTION_NOT_FINISHED.getCode(),
                    StrUtil.format("工序[{}]的过程检验单[{}]检测数量为 0,无法计算合格率;"
                                    + "请在「质量管理-过程检验」补录检测数量后再审批",
                            processName, finishedList.get(0).getCode()));
        }
        BigDecimal passRate = qualifiedQuantity.multiply(BigDecimal.valueOf(100))
                .divide(checkQuantity, 2, RoundingMode.HALF_UP);
        return new MesProFeedbackQualityResult(passRate, checkQuantity, qualifiedQuantity, unqualifiedQuantity);
    }
    /**
@@ -582,13 +818,32 @@
                .setTaskId(reqVO.getTaskId())
                .setItemId(task.getItemId())
                .setFeedbackQuantity(reqVO.getFeedbackQuantity())
                .setWorkHours(reqVO.getWorkHours())
                .setScheduledQuantity(task.getQuantity())
                .setFeedbackTime(reqVO.getFeedbackTime() != null ? reqVO.getFeedbackTime() : LocalDateTime.now())
                .setMachineryIds(reqVO.getMachineryIds())
                .setMachineryId(firstMachineryId(reqVO.getMachineryIds()))
                .setStatus(MesProFeedbackStatusEnum.APPROVING.getStatus())
                .setRemark(reqVO.getRemark());
        // 4.1 快捷报工默认没有工时录入入口:计时计薪且已定价的工序,必须显式传工时;
        //     批量报工等不传工时的入口会得到可执行的提示,引导到报工页面填写
        MesProProcessDO process = processService.getProcess(task.getProcessId());
        if (process == null) {
            throw exception(PRO_FEEDBACK_PROCESS_NOT_EXISTS, task.getProcessId());
        }
        BigDecimal wageRate = ObjectUtil.defaultIfNull(wageRuleService.resolve(task.getProcessId(), task.getRouteId(),
                feedback.getMachineryId(), process.getBillingType(), LocalDate.now()).getWageRate(), BigDecimal.ZERO);
        if (MesProProcessBillingTypeEnum.TIME == MesProProcessBillingTypeEnum.valueOfType(process.getBillingType())
                && wageRate.signum() > 0 && reqVO.getWorkHours() == null) {
            throw exception(PRO_FEEDBACK_TIME_BILLING_QUICK_UNSUPPORTED, process.getName());
        }
        // 4.2 快捷报工没有辅助项入口,仅按生产长度(报工单位为米时)计算标准工时快照
        List<MesProFeedbackAuxDO> auxList = worktimeService.calculateForFeedback(feedback, null);
        // 4.3 按工序计费类型、单价规则与工资字段快照并计算本次报工工资
        applyProcessBilling(feedback, process, auxList);
        equipmentService.validateSubmission(feedback);
        feedbackMapper.insert(feedback);
        saveFeedbackAux(feedback.getId(), auxList);
        saveParamValues(task.getId(), reqVO.getParamValues());
        // 5. 返回结果
        MesProWorkOrderDO workOrder = workOrderService.getWorkOrder(task.getWorkOrderId());
@@ -608,6 +863,33 @@
                .setTaskStatus(task.getStatus())
                .setWorkOrderStatus(workOrder != null ? workOrder.getStatus() : null)
                .setReportableQuantity(reportableQuantity);
    }
    @Override
    public MesProFeedbackWageBreakdown previewWage(MesProFeedbackWagePreviewReqVO reqVO) {
        MesProTaskDO task = taskService.getTask(reqVO.getTaskId());
        if (task == null) {
            throw exception(PRO_TASK_NOT_EXISTS);
        }
        MesProProcessDO process = processService.getProcess(task.getProcessId());
        if (process == null) {
            throw exception(PRO_FEEDBACK_PROCESS_NOT_EXISTS, task.getProcessId());
        }
        // 借用一个临时报工单对象复用标准工时/辅助工时计算
        MesProFeedbackDO temp = new MesProFeedbackDO()
                .setTaskId(task.getId())
                .setRouteId(task.getRouteId())
                .setProcessId(task.getProcessId())
                .setWorkstationId(task.getWorkstationId())
                .setMachineryId(reqVO.getMachineryId())
                .setWorkHours(reqVO.getWorkHours())
                .setFeedbackQuantity(reqVO.getFeedbackQuantity())
                .setQualifiedQuantity(reqVO.getQualifiedQuantity())
                .setProductionLength(reqVO.getProductionLength());
        List<MesProFeedbackAuxDO> auxList = worktimeService.calculateForFeedback(temp, reqVO.getAuxList());
        return wageCalculator.calculate(process, task.getRouteId(), reqVO.getMachineryId(),
                reqVO.getWorkHours(), reqVO.getFeedbackQuantity(), reqVO.getQualifiedQuantity(), auxList,
                reqVO.getFeedbackDate());
    }
    @Override
@@ -677,6 +959,23 @@
                ObjectUtil.defaultIfNull(unqualifiedQty, BigDecimal.ZERO));
        //5.检查并发布生产完成事件
        checkAndPublishProductionFinished(feedback.getWorkOrderId());
        checkAndAutoFinishAfterLastQc(feedback);
    }
    void checkAndAutoFinishAfterLastQc(MesProFeedbackDO feedback) {
        MesProWorkOrderProcessDO lastProcess = workOrderProcessService
                .getLastWorkOrderProcessByWorkOrderId(feedback.getWorkOrderId());
        if (lastProcess == null || !ObjUtil.equal(lastProcess.getProcessId(), feedback.getProcessId())) {
            return;
        }
        // 同一工单的末道报工全部完成质检后,才允许级联完成工单任务。
        Long pendingCount = feedbackMapper.selectCount(new LambdaQueryWrapperX<MesProFeedbackDO>()
                .eq(MesProFeedbackDO::getWorkOrderId, feedback.getWorkOrderId())
                .eq(MesProFeedbackDO::getProcessId, feedback.getProcessId())
                .ne(MesProFeedbackDO::getStatus, MesProFeedbackStatusEnum.FINISHED.getStatus()));
        if (pendingCount == 0) {
            workOrderService.checkAndAutoFinishWorkOrder(feedback.getWorkOrderId());
        }
    }
    @Override