package com.aps.macroplanner.data;

import com.aps.common.util.ParamValidator;
import com.aps.entity.*;
import com.aps.entity.basic.Material;
import com.aps.entity.basic.MaterialSupply;
import com.aps.mapper.EquipShiftCapacityMapper;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.aps.mapper.EquipinfoMapper;
import com.aps.mapper.ErpPurchaseOrderMapper;
import com.aps.mapper.MaterialInfoMapper;
import com.aps.mapper.MaterialPurchaseMapper;
import com.aps.mapper.PlanResourceMapper;
import com.aps.mapper.ProdLaunchOrderMapper;
import com.aps.mapper.PurchaseReceiptMapper;
import com.aps.mapper.RoutingDetailEquipMapper;
import com.aps.mapper.RoutingHeaderMapper;
import com.aps.mapper.RoutingsupportingMapper;
import com.aps.mapper.SjzPfWhStockMapper;
import com.aps.mapper.StockMapper;
import com.aps.service.ApsTimeConfigService;
import com.aps.service.LanuchService;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.math.BigDecimal;
import java.time.Duration;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;

/**
 * 数据库工艺物料类 → macroplanner 实体转换器。
 *
 * <p>将 {@code PlanResultService.execute2} 处理的数据库"工艺物料类"实体
 * （{@link RoutingHeader}/{@link RoutingDetail}/{@link Routingsupporting}/
 * {@link Material}/{@link Stock} 等）转换为 {@link RoutingTestDataBuilder}
 * 所用的 macroplanner 实体（{@link Product}/{@link Operation}/{@link Routing}/
 * {@link OperationInput}/{@link StockingPoint}/{@link InitialInventory}/
 * {@link InTransitSupply}），返回填充好的 {@link TestDataBuilder}，
 * 供 {@code MacroPlannerOptimizer} 直接消费。</p>
 *
 * <h3>设计要点</h3>
 * <pre>
 *   1. 独立的 Spring @Service，接收 sceneId 自行查询数据库
 *   2. 通过同包静态内部类 Sink extends TestDataBuilder 访问 protected 字段
 *   3. 利用 Routing.expand() 自动生成工序间 WIP，只手动补 MP 原材料投料
 *   4. 消耗因子 factor = Routingsupporting.spentQty / Routingsupporting.mainQty
 *   5. Period/UnitPeriod/SalesDemand/InventorySpec/SupplySpec/KPIWeights 用默认值或从订单推导
 * </pre>
 *
 * <h3>核心映射关系</h3>
 * <pre>
 *   Product          ← Material (MaterialInfo)
 *   StockingPoint    ← Stock.storeId / Routingsupporting.storeId
 *   Operation        ← RoutingDetail (无产出, 由 Routing.expand() 配置)
 *   Routing          ← RoutingHeader + RoutingDetail 列表 (调用 expand())
 *   OperationInput   ← Routingsupporting (仅 MP 原材料投料)
 *   InitialInventory ← Material.materialStocks (Stock)
 *   InTransitSupply  ← Material.InTransit (MaterialSupply)
 *   SalesDemand      ← ProdLaunchOrder
 * </pre>
 */
@Service
@Slf4j
public class MacroPlannerDataConverter {

    /** 默认周期数 (一周) */
    private static final int DEFAULT_PERIOD_COUNT = 7;
    /** 默认单元最大产能 (小时/天) */
    private static final double DEFAULT_UNIT_MAX_CAPACITY = 24.0;
    /** 默认宽松库存上限 */
    private static final double LOOSE_MAX = 999999.0;

    // ==================== 注入的 Mapper ====================
    @Autowired
    private ProdLaunchOrderMapper prodLaunchOrderMapper;
    @Autowired
    private RoutingHeaderMapper routingHeaderMapper;
    @Autowired
    private RoutingsupportingMapper routingsupportingMapper;
    @Autowired
    private MaterialInfoMapper materialInfoMapper;
    @Autowired
    private StockMapper stockMapper;
    @Autowired
    private MaterialPurchaseMapper materialPurchaseMapper;
    @Autowired
    private ErpPurchaseOrderMapper erpPurchaseOrderMapper;
    @Autowired
    private PurchaseReceiptMapper purchaseReceiptMapper;
    @Autowired
    private SjzPfWhStockMapper sjzPfWhStockMapper;
    @Autowired
    private RoutingDetailEquipMapper routingDetailEquipMapper;
    @Autowired
    private EquipinfoMapper equipinfoMapper;
    @Autowired
    private PlanResourceMapper planResourceMapper;
    @Autowired
    private EquipShiftCapacityMapper equipShiftCapacityMapper;

    // ==================== 注入的 Service ====================
    @Autowired
    private LanuchService lanuchService;
    @Autowired
    private ApsTimeConfigService apsTimeConfigService;

    // ==================== 公开入口 ====================

    /**
     * 将指定场景的数据库工艺物料数据转换为 macroplanner 的 TestDataBuilder。
     *
     * @param sceneId 场景ID
     * @return 填充好的 TestDataBuilder, 可直接传给 MacroPlannerOptimizer
     */
    public TestDataBuilder convert(String sceneId) {
        log.info("开始转换场景数据到 macroplanner: sceneId={}", sceneId);

        ConvertContext ctx = loadRawData(sceneId);
        buildMaterials(ctx);
        buildDailyCapacityByUnitId(ctx);

        Sink sink = new Sink();
        sink.fill(ctx);

        log.info("转换完成: products={}, stockingPoints={}, operations={}, routings={}, operationInputs={}, "
                        + "initialInventories={}, inTransitSupplies={}, salesDemands={}",
                sink.getProducts().size(), sink.getStockingPoints().size(),
                sink.getOperations().size(), sink.getRoutings().size(),
                sink.getOperationInputs().size(), sink.getInitialInventories().size(),
                sink.getInTransitSupplies().size(), sink.getSalesDemands().size());

        return sink;
    }

    // ==================== 步骤1: 数据加载 ====================

    private ConvertContext loadRawData(String sceneId) {
        ConvertContext ctx = new ConvertContext();

        // 1. 订单 (按 sceneId 过滤)
        ctx.prodLaunchOrders = prodLaunchOrderMapper.selectList(
                new LambdaQueryWrapper<ProdLaunchOrder>()
                        .eq(ProdLaunchOrder::getSceneId, sceneId));
        log.info("加载订单: {} 条", ctx.prodLaunchOrders.size());

        // 2. 收集 materialIds
        Set<String> materialIds = ctx.prodLaunchOrders.stream()
                .map(ProdLaunchOrder::getMaterialId)
                .filter(Objects::nonNull)
                .distinct()
                .collect(Collectors.toSet());
        List<Integer> routingIds=null;
        // 3. 工艺路线头表
        if (!materialIds.isEmpty()) {
            ctx.routingHeaders = routingHeaderMapper.selectList(
                    new LambdaQueryWrapper<RoutingHeader>()
                            .in(RoutingHeader::getMaterialId, materialIds));

            routingIds = ctx.routingHeaders.stream()
                    .map(RoutingHeader::getId)
                    .filter(Objects::nonNull)
                    .distinct()
                    .collect(Collectors.toList());
        }
        log.info("加载工艺路线: {} 条", ctx.routingHeaders.size());

        // 4. 工序 (通过 LanuchService 批量查询)
        if (!routingIds.isEmpty()) {
            List<Long> routingIdsLong = routingIds.stream()
                    .map(Long::valueOf)
                    .collect(Collectors.toList());
            List<RoutingDetail> details = lanuchService.getRoutingDetails(routingIdsLong);
            if (details != null) {
                ctx.routingDetails = details;
            }
        }
        log.info("加载工序: {} 条", ctx.routingDetails.size());

        // 5. 工艺物料消耗 (BOM)
        if (!routingIds.isEmpty()) {
            ctx.routingsupportings = routingsupportingMapper.selectList(
                    new LambdaQueryWrapper<Routingsupporting>()
                            .in(Routingsupporting::getRoutingHeaderId, routingIds)
                            .eq(Routingsupporting::getIsdeleted, 0));
        }
        log.info("加载工艺物料消耗: {} 条", ctx.routingsupportings.size());

        // 6. 收集所有 materialId (订单 + 工艺物料消耗 + 工艺路线头表)


        ctx.routingsupportings.forEach(rs -> {
            if (rs.getMaterialId() != null) materialIds.add(rs.getMaterialId());
        });


        // 7. 物料主数据
        if (!materialIds.isEmpty()) {
            ctx.materialInfos = materialInfoMapper.selectList(
                    new LambdaQueryWrapper<MaterialInfo>()
                            .in(MaterialInfo::getId, materialIds));
        }
        log.info("加载物料主数据: {} 条", ctx.materialInfos.size());

        // 8. 库存、采购、在途 (按 materialId 过滤, 避免全表扫描)
        if (!materialIds.isEmpty()) {
            ctx.stocks = stockMapper.selectList(
                    new LambdaQueryWrapper<Stock>()
                            .in(Stock::getMaterialId, materialIds)
                            .eq(Stock::getIsdeleted, 0));
            ctx.materialPurchases = materialPurchaseMapper.selectList(
                    new LambdaQueryWrapper<MaterialPurchase>()
                            .in(MaterialPurchase::getMaterialId, materialIds)
                            .eq(MaterialPurchase::getIsdeleted, 0));
            ctx.erpPurchaseOrders = erpPurchaseOrderMapper.selectList(
                    new LambdaQueryWrapper<ErpPurchaseOrder>()
                            .in(ErpPurchaseOrder::getMaterialId, materialIds)
                            .eq(ErpPurchaseOrder::getIsdeleted, 0));
            ctx.purchaseReceipts = purchaseReceiptMapper.selectList(
                    new LambdaQueryWrapper<PurchaseReceipt>()
                            .in(PurchaseReceipt::getMaterialid, materialIds)
                            .eq(PurchaseReceipt::getIsdeleted, 0));
            ctx.sjzPfWhStocks = sjzPfWhStockMapper.selectList(
                    new LambdaQueryWrapper<SjzPfWhStock>()
                            .in(SjzPfWhStock::getMaterialid, materialIds)
                            .eq(SjzPfWhStock::getIsdeleted, 0));
        }
        log.info("加载库存: {}, 采购: {}, ERP采购订单: {}, 待验: {}, 半成品在途: {}",
                ctx.stocks.size(), ctx.materialPurchases.size(),
                ctx.erpPurchaseOrders.size(), ctx.purchaseReceipts.size(), ctx.sjzPfWhStocks.size());

        // 9. RoutingDetailEquip (工序-设备关联), 按 routingDetailId 加载
        if (!ctx.routingDetails.isEmpty()) {
            List<Long> detailIds = ctx.routingDetails.stream()
                    .map(RoutingDetail::getId)
                    .filter(Objects::nonNull)
                    .distinct()
                    .collect(Collectors.toList());
            if (!detailIds.isEmpty()) {
                ctx.routingDetailEquips = routingDetailEquipMapper.selectList(
                        new LambdaQueryWrapper<RoutingDetailEquip>()
                                .in(RoutingDetailEquip::getRoutingDetailId, detailIds)
                                .eq(RoutingDetailEquip::getIsdeleted, 0));
            }
        }
        log.info("加载工序设备关联: {} 条", ctx.routingDetailEquips.size());

        // 10. 设备数据: Equipinfo + PlanResource (按 RoutingDetailEquip.equipId 过滤)
        Set<Long> equipIds = ctx.routingDetailEquips.stream()
                .map(RoutingDetailEquip::getEquipId)
                .filter(Objects::nonNull)
                .collect(Collectors.toSet());
        if (!equipIds.isEmpty()) {
            ctx.planResources = planResourceMapper.selectList(
                    new LambdaQueryWrapper<PlanResource>()
                            .in(PlanResource::getId, equipIds)
                            .eq(PlanResource::getIsdeleted, false));
        }
        // 通过 PlanResource.referenceId 查询 Equipinfo
        Set<Integer> equipinfoIds = ctx.planResources.stream()
                .map(PlanResource::getReferenceId)
                .filter(Objects::nonNull)
                .collect(Collectors.toSet());
        if (!equipinfoIds.isEmpty()) {
            ctx.equipinfos = equipinfoMapper.selectList(
                    new LambdaQueryWrapper<Equipinfo>()
                            .in(Equipinfo::getId, equipinfoIds)
                            .eq(Equipinfo::getIsdeleted, false));
        }
        log.info("加载设备资源: {}, 设备信息: {}", ctx.planResources.size(), ctx.equipinfos.size());

        // 11. 设备产能日历: EquipShiftCapacity (每天一条, validTimePeriods=有效时间段JSON)
        Set<Long> planResourceIds = ctx.planResources.stream()
                .map(PlanResource::getId)
                .filter(Objects::nonNull)
                .map(Long::valueOf)
                .collect(Collectors.toSet());
        if (!planResourceIds.isEmpty()) {
            ctx.equipShiftCapacities = equipShiftCapacityMapper.selectList(
                    new LambdaQueryWrapper<EquipShiftCapacity>()
                            .in(EquipShiftCapacity::getPlanResourceId, planResourceIds)
                            .eq(EquipShiftCapacity::getIsDeleted, 0));
        }
        log.info("加载设备产能日历(EquipShiftCapacity): {} 条", ctx.equipShiftCapacities.size());

        // 12. baseTime
        ApsTimeConfig timeConfig = apsTimeConfigService.getOne(new LambdaQueryWrapper<>());
        ctx.baseTime = (timeConfig != null && timeConfig.getBaseTime() != null)
                ? timeConfig.getBaseTime() : LocalDateTime.now();

        return ctx;
    }

    // ==================== 步骤2: 构建 Material 业务对象 ====================
    // 参考 PlanResultService.getMaterials() (PlanResultService.java:2700-2849), 去掉缓存写入

    private void buildMaterials(ConvertContext ctx) {
        // 构建索引
        Map<String, List<Stock>> stocksByMaterialId = ctx.stocks.stream()
                .filter(s -> s.getMaterialId() != null)
                .collect(Collectors.groupingBy(Stock::getMaterialId));
        Map<String, List<MaterialPurchase>> purchasesByMaterialId = ctx.materialPurchases.stream()
                .filter(p -> p.getMaterialId() != null)
                .collect(Collectors.groupingBy(MaterialPurchase::getMaterialId));
        Map<String, List<PurchaseReceipt>> receiptsByMaterialId = ctx.purchaseReceipts.stream()
                .filter(r -> r.getMaterialid() != null)
                .collect(Collectors.groupingBy(PurchaseReceipt::getMaterialid));
        Map<String, List<ErpPurchaseOrder>> erpOrdersByMaterialId = ctx.erpPurchaseOrders.stream()
                .filter(e -> e.getMaterialId() != null)
                .collect(Collectors.groupingBy(ErpPurchaseOrder::getMaterialId));
        Map<String, List<SjzPfWhStock>> sjzByMaterialId = ctx.sjzPfWhStocks.stream()
                .filter(s -> s.getMaterialid() != null)
                .collect(Collectors.groupingBy(SjzPfWhStock::getMaterialid));

        for (MaterialInfo m : ctx.materialInfos) {
            if (m.getMaterialTypeName() == null) {
                // 无物料类型名, 跳过 (参照 getMaterials 逻辑)
                continue;
            }

            Material material = new Material();
            material.setId(m.getId());
            material.setMaterialType(m.getMaterialType());
            material.setMaterialTypeName(m.getMaterialTypeName());
            material.setCode(m.getCode());
            material.setName(m.getName());
            material.setMaxProduction(m.getMaxProduction());
            material.setMinProduction(m.getMinProduction());

            // 库存
            List<Stock> materialStocks = stocksByMaterialId.getOrDefault(m.getId(), Collections.emptyList());
            if (!materialStocks.isEmpty()) {
                double stock = materialStocks.stream().mapToDouble(Stock::getTotal).sum();
                material.setCurrentStock(stock);
                material.setMaterialStocks(new ArrayList<>(materialStocks));
            }

            // 采购供应商
            List<MaterialPurchase> purchases = purchasesByMaterialId.getOrDefault(m.getId(), Collections.emptyList());
            if (!purchases.isEmpty()) {
                material.setMaterialPurchases(new ArrayList<>(purchases));
            }

            // 在途物料 (区分 MP 原材料 vs 半成品/成品)
            List<MaterialSupply> inTransit = new ArrayList<>();
            if ("MP".equals(m.getMaterialTypeName())) {
                // 原材料: 待验 (PurchaseReceipt) + 采购订单 (ErpPurchaseOrder, 加检验周期)
                buildMpInTransit(inTransit, m.getId(),
                        receiptsByMaterialId.getOrDefault(m.getId(), Collections.emptyList()),
                        erpOrdersByMaterialId.getOrDefault(m.getId(), Collections.emptyList()),
                        purchases);
            } else {
                // 半成品/成品: SjzPfWhStock
                buildNonMpInTransit(inTransit, m.getId(),
                        sjzByMaterialId.getOrDefault(m.getId(), Collections.emptyList()));
            }
            material.setInTransit(inTransit);

            ctx.materialByMaterialId.put(m.getId(), material);
        }
        log.info("构建 Material 业务对象: {} 个", ctx.materialByMaterialId.size());
    }

    /**
     * 构建 MP 原材料的在途物料 (待验 + 采购订单)。
     * 参考 PlanResultService.getMaterials() 第 2783-2826 行。
     */
    private void buildMpInTransit(List<MaterialSupply> inTransit, String materialId,
                                  List<PurchaseReceipt> receipts,
                                  List<ErpPurchaseOrder> erpOrders,
                                  List<MaterialPurchase> purchases) {
        // 待验
        for (PurchaseReceipt pr : receipts) {
            LocalDateTime dt = pr.getExp5();
            if (dt == null && pr.getExp1() != null) {
                try {
                    dt = ParamValidator.parseDateTime(pr.getExp1() + " 00:00:00", "");
                } catch (Exception e) {
                    log.warn("解析待验到货时间失败: materialId={}, exp1={}", materialId, pr.getExp1());
                    continue;
                }
            }
            if (dt == null) {
                continue;
            }
            MaterialSupply ms = new MaterialSupply();
            ms.setArrivalTime(dt);
            ms.setQuantity(pr.getFjl1Sl());
            inTransit.add(ms);
        }
        // 采购订单 (可用时间 = 到货时间 + 检验周期)
        for (ErpPurchaseOrder epo : erpOrders) {
            if (epo.getArrivalDate() == null) {
                continue;
            }
            int checkDay = 0;
            if (epo.getManufacturerId() != null && purchases != null) {
                String manufacturerIdStr = String.valueOf(epo.getManufacturerId());
                Optional<MaterialPurchase> matched = purchases.stream()
                        .filter(p -> p.getSupplyId() != null && p.getSupplyId().equals(manufacturerIdStr))
                        .findFirst();
                if (matched.isPresent() && matched.get().getInspectionCycle() != null) {
                    checkDay = matched.get().getInspectionCycle();
                }
            }
            MaterialSupply ms = new MaterialSupply();
            ms.setArrivalTime(epo.getArrivalDate().plusDays(checkDay));
            ms.setQuantity(epo.getPurchaseQty());
            inTransit.add(ms);
        }
    }

    /**
     * 构建半成品/成品的在途物料 (SjzPfWhStock)。
     * 参考 PlanResultService.getMaterials() 第 2827-2840 行。
     */
    private void buildNonMpInTransit(List<MaterialSupply> inTransit, String materialId,
                                     List<SjzPfWhStock> sjzStocks) {
        for (SjzPfWhStock sjz : sjzStocks) {
            LocalDateTime dt = null;
            if (sjz.getExp1() != null) {
                try {
                    dt = ParamValidator.parseDateTime(sjz.getExp1() + " 00:00:00", "");
                } catch (Exception e) {
                    log.warn("解析半成品在途时间失败: materialId={}, exp1={}", materialId, sjz.getExp1());
                    continue;
                }
            }
            if (dt == null) {
                continue;
            }
            MaterialSupply ms = new MaterialSupply();
            ms.setArrivalTime(dt);
            ms.setQuantity(sjz.getFjl1Sl());
            inTransit.add(ms);
        }
    }

    // ==================== 步骤3: 从 EQUIP_SHIFT_CAPACITY 读取设备日产能 ====================

    /**
     * 从 EquipShiftCapacity.validTimePeriods 解析每台设备每天的可用时间段, 计算日产能。
     *
     * <p>validTimePeriods 格式: [{"StartTime":"2026-04-09 08:00:00","EndTime":"2026-04-09 18:00:00"}]
     * 每天一条记录, 直接求和得到日可用小时数。</p>
     *
     * <p>结果:
     *   ctx.dailyCapacityByUnitId: unitId → 平均日产能 (小时, 含效率系数)
     *   ctx.holidayDatesByUnitId: unitId → 日产能为0的日期 (视为节假日)
     * </p>
     */
    private void buildDailyCapacityByUnitId(ConvertContext ctx) {
        // 读取时间配置, 获取维度 + 计划周期
        ApsTimeConfig timeConfig = apsTimeConfigService.getOne(new LambdaQueryWrapper<>());
        LocalDate baseDate = ctx.baseTime.toLocalDate();

        // 维度: 默认 DAY
        ctx.periodDimension = "DAY";
        if (timeConfig != null && timeConfig.getPeriodDimension() != null
                && !timeConfig.getPeriodDimension().trim().isEmpty()) {
            ctx.periodDimension = timeConfig.getPeriodDimension().trim().toUpperCase();
        }

        // 时间范围: horizonSeconds = endCount - startCount (秒)
        long horizonSeconds = 86400 * DEFAULT_PERIOD_COUNT;
        if (timeConfig != null && timeConfig.getStartCount() != null && timeConfig.getEndCount() != null) {
            long diff = timeConfig.getEndCount().longValue() - timeConfig.getStartCount().longValue();
            if (diff > 0) {
                horizonSeconds = diff;
            }
        }
        int totalDays = (int) Math.max(1, horizonSeconds / 86400);
        LocalDate horizonEnd = baseDate.plusDays(totalDays);

        // 按维度计算 periodCount
        ctx.periodCount = calculatePeriodCount(ctx.periodDimension, baseDate, horizonEnd);
        log.info("排产周期: dimension={}, periodCount={}, 总天数={}", ctx.periodDimension, ctx.periodCount, totalDays);

        // planResourceId → PlanResource.id
        Map<Long, PlanResource> prById = ctx.planResources.stream()
                .collect(Collectors.toMap(p -> Long.valueOf(p.getId()), p -> p, (a, b) -> a));

        ObjectMapper mapper = new ObjectMapper();
        for (EquipShiftCapacity esc : ctx.equipShiftCapacities) {
            Long prId = esc.getPlanResourceId();
            if (prId == null || esc.getCapacityDate() == null) {
                continue;
            }
            PlanResource pr = prById.get(prId);
            if (pr == null) {
                continue;
            }
            String unitId = "EQUIP_" + pr.getId();

            double dailyHours = 0.0;
            if (esc.getValidTimePeriods() != null && !esc.getValidTimePeriods().trim().isEmpty()) {
                try {
                    List<Map<String, String>> periods = mapper.readValue(
                            esc.getValidTimePeriods(),
                            new TypeReference<List<Map<String, String>>>() {});
                    for (Map<String, String> tp : periods) {
                        String startStr = tp.get("StartTime");
                        String endStr = tp.get("EndTime");
                        if (startStr != null && endStr != null) {
                            LocalDateTime start = LocalDateTime.parse(startStr.replace(" ", "T"));
                            LocalDateTime end = LocalDateTime.parse(endStr.replace(" ", "T"));
                            double hours = Duration.between(start, end).toMinutes() / 60.0;
                            dailyHours += Math.max(0, hours);
                        }
                    }
                } catch (Exception e) {
                    log.warn("解析 validTimePeriods 失败: unitId={}, date={}, json={}",
                            unitId, esc.getCapacityDate(), esc.getValidTimePeriods(), e);
                }
            }

            // 应用效率系数
            double efficiency = 1.0;
            if (esc.getEfficiencyCoeff() != null && esc.getEfficiencyCoeff() > 0) {
                efficiency = esc.getEfficiencyCoeff();
            }
            dailyHours *= efficiency;

            ctx.dailyHoursByUnitId
                    .computeIfAbsent(unitId, k -> new LinkedHashMap<>())
                    .put(esc.getCapacityDate().toLocalDate(), dailyHours);
        }

        // 汇总: 平均日产能 + 节假日
        for (Map.Entry<String, Map<LocalDate, Double>> entry : ctx.dailyHoursByUnitId.entrySet()) {
            String unitId = entry.getKey();
            double total = 0;
            int nonZeroDays = 0;
            Set<LocalDate> holidayDates = new HashSet<>();
            for (Map.Entry<LocalDate, Double> de : entry.getValue().entrySet()) {
                if (de.getValue() <= 0) {
                    holidayDates.add(de.getKey());
                } else {
                    total += de.getValue();
                    nonZeroDays++;
                }
            }
            double avgDaily = nonZeroDays > 0 ? total / nonZeroDays : 16.0;
            ctx.dailyCapacityByUnitId.put(unitId, avgDaily);
            if (!holidayDates.isEmpty()) {
                ctx.holidayDatesByUnitId.put(unitId, holidayDates);
            }
        }

        log.info("设备日产能(EquipShiftCapacity): {} 台设备, {} 台有节假日, 默认={}h",
                ctx.dailyCapacityByUnitId.size(), ctx.holidayDatesByUnitId.size(), 16.0);
    }

    /**
     * 按维度计算周期数。
     *
     * @param dimension  维度: DAY / WEEK / MONTH
     * @param baseDate   起始日期
     * @param horizonEnd 计划截止日期 (不包含)
     * @return 周期数
     */
    private static int calculatePeriodCount(String dimension, LocalDate baseDate, LocalDate horizonEnd) {
        switch (dimension) {
            case "WEEK":
                // 按周: 从 baseDate 开始, 每7天一周, 不足7天也算一周
                long totalDays = java.time.temporal.ChronoUnit.DAYS.between(baseDate, horizonEnd);
                return (int) Math.max(1, Math.ceil(totalDays / 7.0));
            case "MONTH":
                // 按月: 从 baseDate 所在月开始, 跨越的自然月数
                int months = 0;
                LocalDate cursor = baseDate.withDayOfMonth(1);
                while (cursor.isBefore(horizonEnd)) {
                    cursor = cursor.plusMonths(1);
                    months++;
                }
                return Math.max(1, months);
            case "DAY":
            default:
                long days = java.time.temporal.ChronoUnit.DAYS.between(baseDate, horizonEnd);
                return (int) Math.max(1, days);
        }
    }

    /**
     * 计算给定日期范围内的有效工作日数 (不含周日)。
     */
    private static int calculateWorkDays(LocalDate start, LocalDate endExclusive) {
        int count = 0;
        LocalDate d = start;
        while (d.isBefore(endExclusive)) {
            if (d.getDayOfWeek().getValue() != 7) { // 非周日
                count++;
            }
            d = d.plusDays(1);
        }
        return count;
    }

    // ==================== Sink: 继承 TestDataBuilder, 直接操作 protected 字段 ====================

    /**
     * TestDataBuilder 的同包子类, 通过 protected 字段直接填充数据。
     * 与 {@link ComprehensiveTestDataBuilder} / {@link RoutingTestDataBuilder} 模式一致。
     */
    private static class Sink extends TestDataBuilder {

        Sink() {
            super(true); // 跳过父类默认 build
        }

        void fill(ConvertContext ctx) {
            fillProductsAndStockingPoints(ctx);
            fillOperationsAndRoutings(ctx);
            fillOperationInputs(ctx);
            fillInitialInventoryAndInTransit(ctx);
            fillPeriodsAndUnitPeriods(ctx);
            fillSalesDemands(ctx);
            fillInventorySpecsAndSupplySpecs(ctx);
            fillKpiWeights();
        }

        // ---------- 步骤3: Product + StockingPoint ----------

        private void fillProductsAndStockingPoints(ConvertContext ctx) {
            // 3.1 为每个 Material 创建 Product
            for (Material m : ctx.materialByMaterialId.values()) {
                String name = pickName(m.getName(), m.getCode(), m.getId());
                Product p = new Product(m.getId(), name);
                products.add(p);
                ctx.productByMaterialId.put(m.getId(), p);
            }

            // 3.2 收集所有 storeId (库存 + 投料仓库), 建立 storeId → storeName 映射
            Map<Long, String> storeNameMap = new HashMap<>();
            for (Material m : ctx.materialByMaterialId.values()) {
                if (m.getMaterialStocks() != null) {
                    for (Stock s : m.getMaterialStocks()) {
                        if (s.getStoreId() != null && s.getStoreName() != null) {
                            storeNameMap.putIfAbsent(s.getStoreId(), s.getStoreName());
                        }
                    }
                }
            }
            for (Routingsupporting rs : ctx.routingsupportings) {
                if (rs.getStoreId() != null && rs.getStoreName() != null) {
                    storeNameMap.putIfAbsent(rs.getStoreId(), rs.getStoreName());
                }
            }

            Set<Long> storeIds = new HashSet<>();
            storeIds.addAll(storeNameMap.keySet());
            // 补充只有 Stock 有但没 name 的 storeId
            for (Material m : ctx.materialByMaterialId.values()) {
                if (m.getMaterialStocks() != null) {
                    for (Stock s : m.getMaterialStocks()) {
                        if (s.getStoreId() != null) storeIds.add(s.getStoreId());
                    }
                }
            }
            for (Routingsupporting rs : ctx.routingsupportings) {
                if (rs.getStoreId() != null) storeIds.add(rs.getStoreId());
            }

            for (Long sid : storeIds) {
                String spName = storeNameMap.getOrDefault(sid, "仓库_" + sid);
                StockingPoint sp = new StockingPoint("SP_" + sid, spName);
                stockingPoints.add(sp);
                ctx.spByStoreId.put(sid, sp);
            }

            // 3.3 ProductSpMapping + 记录每个物料的成品库 (首个库存点)
            for (Material m : ctx.materialByMaterialId.values()) {
                Product p = ctx.productByMaterialId.get(m.getId());
                StockingPoint firstSp = null;
                if (m.getMaterialStocks() != null) {
                    for (Stock s : m.getMaterialStocks()) {
                        if (s.getStoreId() == null) {
                            continue;
                        }
                        StockingPoint sp = ctx.spByStoreId.get(s.getStoreId());
                        if (sp != null) {
                            productSpMappings.add(new ProductSpMapping(p, sp));
                            if (firstSp == null) {
                                firstSp = sp;
                            }
                        }
                    }
                }
                if (firstSp == null) {
                    // 无库存记录, 建默认成品库
                    firstSp = new StockingPoint("SP_FG_" + m.getId(),
                            "成品库_" + pickName(m.getName(), m.getId()));
                    stockingPoints.add(firstSp);
                    productSpMappings.add(new ProductSpMapping(p, firstSp));
                }
                ctx.finalSpByMaterialId.put(m.getId(), firstSp);
            }
            log.info("构建 Product: {}, StockingPoint: {}", products.size(), stockingPoints.size());
        }

        // ---------- 步骤4: Operation + Routing (调用 expand) ----------

        private void fillOperationsAndRoutings(ConvertContext ctx) {
            // 4.1 工序按 routingHeaderId 分组, 按 taskSeq 升序排序
            Map<Integer, List<RoutingDetail>> detailsByHeader = new HashMap<>();
            for (RoutingDetail rd : ctx.routingDetails) {
                if (rd.getRoutingHeaderId() == null) {
                    continue;
                }
                int hid = rd.getRoutingHeaderId().intValue();
                detailsByHeader.computeIfAbsent(hid, k -> new ArrayList<>()).add(rd);
            }
            detailsByHeader.values().forEach(list ->
                    list.sort(Comparator.comparing(rd ->
                            rd.getTaskSeq() == null ? Long.MAX_VALUE : rd.getTaskSeq())));

            // 4.2 每个 RoutingHeader 构建 Routing
            for (RoutingHeader rh : ctx.routingHeaders) {
                if (Boolean.TRUE.equals(rh.getIsDeleted())) {
                    continue;
                }
                if (rh.getMaterialId() == null) {
                    continue;
                }
                Product product = ctx.productByMaterialId.get(rh.getMaterialId());
                if (product == null) {
                    log.warn("跳过工艺路线 {}: materialId={} 对应的物料不存在", rh.getId(), rh.getMaterialId());
                    continue;
                }
                StockingPoint finalSp = ctx.finalSpByMaterialId.get(rh.getMaterialId());
                if (finalSp == null) {
                    log.warn("跳过工艺路线 {}: 找不到成品库", rh.getId());
                    continue;
                }

                String routingName = pickName(rh.getName(), rh.getCode(), "工艺路线_" + rh.getId());
                Routing routing = new Routing("R_" + rh.getId(), routingName, product, finalSp);

                List<RoutingDetail> details = detailsByHeader.getOrDefault(rh.getId(), Collections.emptyList());
                // 构建索引: routingDetailId → List<RoutingDetailEquip>
                Map<Long, List<RoutingDetailEquip>> equipsByDetailId = ctx.routingDetailEquips.stream()
                        .filter(e -> e.getRoutingDetailId() != null)
                        .collect(Collectors.groupingBy(RoutingDetailEquip::getRoutingDetailId));

                for (RoutingDetail rd : details) {
                    if (rd.getIsDeleted() != null && rd.getIsDeleted() != 0) {
                        continue;
                    }
                    String opName = pickName(rd.getName(), "工序" + rd.getTaskSeq());

                    // 从 RoutingDetailEquip 创建 UnitOperation 列表 (每个设备一个 UnitOperation)
                    List<RoutingDetailEquip> equips = equipsByDetailId.getOrDefault(rd.getId(), Collections.emptyList());
                    List<UnitOperation> unitOps;
                    if (!equips.isEmpty()) {
                        unitOps = equips.stream()
                                .filter(e -> e.getEquipId() != null)
                                .map(e -> {
                                    String unitId = "EQUIP_" + e.getEquipId();
                                    double capCoeff = (e.getDuration() != null) ? e.getDuration().doubleValue() : 1.0;
                                    boolean lotSize = e.getOneBatchQuantity() != null
                                            && e.getOneBatchQuantity().compareTo(BigDecimal.ZERO) > 0;
                                    double lotSizeVal = lotSize ? e.getOneBatchQuantity().doubleValue() : 0.0;
                                    return new UnitOperation(unitId, capCoeff, lotSize, lotSizeVal, 1.0);
                                })
                                .collect(Collectors.toList());
                    } else {
                        // 无 RoutingDetailEquip: 回退到 equipTypeId
                        String unitId = "EQUIP_" + (rd.getEquipTypeId() != null ? rd.getEquipTypeId() : "DEFAULT");
                        double capCoeff = (rd.getRuntime() != null) ? rd.getRuntime().doubleValue() : 1.0;
                        unitOps = Arrays.asList(new UnitOperation(unitId, capCoeff, false, 0.0, 1.0));
                    }

                    Operation op = new Operation(
                            "OP_" + rh.getId() + "_" + rd.getId(),
                            opName, unitOps,
                            1.0, 0);
                    routing.addOperation(op);
                    ctx.operationByRoutingDetailId.put(rd.getId(), op);
                }

                if (routing.getOperations().isEmpty()) {
                    log.warn("跳过工艺路线 {}: 无有效工序", rh.getId());
                    continue;
                }

                routings.add(routing);
                operations.addAll(routing.getOperations());
                ctx.routingByHeaderId.put(rh.getId(), routing);

                // 调用 expand() 自动生成中间 WIP 库存点 + BOM 输入 + 工序产出配置
                try {
                    RoutingExpansion expansion = routing.expand();
                    stockingPoints.addAll(expansion.getStockingPoints());
                    productSpMappings.addAll(expansion.getProductSpMappings());
                    initialInventories.addAll(expansion.getInitialInventories());
                    operationInputs.addAll(expansion.getOperationInputs());
                } catch (Exception e) {
                    log.error("工艺路线 {} 展开失败: {}", rh.getId(), e.getMessage(), e);
                }
            }
            log.info("构建 Routing: {}, Operation: {}", routings.size(), operations.size());
        }

        // ---------- 步骤5: OperationInput (仅 MP 原材料投料) ----------

        private void fillOperationInputs(ConvertContext ctx) {
            // 仅 MP 原材料作为投料; 半成品由各自 Routing 产出 + RoutingConstraint 管理, 不重复投料
            for (Routingsupporting rs : ctx.routingsupportings) {
                Operation op = ctx.operationByRoutingDetailId.get(rs.getRoutingDetailId());
                if (op == null) {
                    continue;
                }
                if (rs.getMaterialId() == null) {
                    continue;
                }

                Material inputMaterial = ctx.materialByMaterialId.get(rs.getMaterialId());
                if (inputMaterial == null) {
                    continue;
                }
                if (!"MP".equals(inputMaterial.getMaterialTypeName())) {
                    // 半成品/成品不作为投料, 由各自 Routing 产出
                    continue;
                }

                if (rs.getMainQty() == null || rs.getMainQty().compareTo(BigDecimal.ZERO) == 0) {
                    log.warn("跳过主量为0的BOM项: routingDetailId={}, materialId={}",
                            rs.getRoutingDetailId(), rs.getMaterialId());
                    continue;
                }
                if (rs.getSpentQty() == null) {
                    throw new IllegalStateException(
                            "BOM消耗量为空: " + rs.getMaterialNumber() + ", routingDetailId=" + rs.getRoutingDetailId());
                }
                double factor = rs.getSpentQty().doubleValue() / rs.getMainQty().doubleValue();

                Product inputProduct = ctx.productByMaterialId.get(rs.getMaterialId());
                StockingPoint inputSp = resolveInputSp(ctx, rs, inputProduct);

                operationInputs.add(new OperationInput(op, inputProduct, inputSp, factor));
            }

            // 为无产出工序的 MP 原材料补充"采购 Operation"
            // 根据 MATERIAL_PURCHASE 创建供应商 unit:
            //   - 有 MATERIAL_PURCHASE → 每供应商一个 Operation, leadTimeDays = purchaseCycle
            //   - 无 MATERIAL_PURCHASE → 通用供应商 unit, relativeDuration=1, leadTimeDays=0, UnitPeriod 无限
            for (Material m : ctx.materialByMaterialId.values()) {
                if (!"MP".equals(m.getMaterialTypeName())) {
                    continue;
                }
                Product p = ctx.productByMaterialId.get(m.getId());
                if (p == null) {
                    continue;
                }
                boolean hasProducer = operations.stream()
                        .anyMatch(op -> op.producesProduct(p.getId()));
                if (!hasProducer) {
                    StockingPoint sp = ctx.finalSpByMaterialId.get(m.getId());
                    if (sp == null) {
                        sp = new StockingPoint("SP_PROCURE_" + m.getId(),
                                "采购库_" + pickName(m.getName(), m.getId()));
                        stockingPoints.add(sp);
                        productSpMappings.add(new ProductSpMapping(p, sp));
                        ctx.finalSpByMaterialId.put(m.getId(), sp);
                    }

                    List<MaterialPurchase> purchases = m.getMaterialPurchases();
                    if (purchases != null && !purchases.isEmpty()) {
                        // 有供应商: 按每个供应商创建独立的采购 Operation, leadTimeDays = purchaseCycle
                        for (MaterialPurchase mp : purchases) {
                            String unitId = "SUPPLIER_" + (mp.getSupplyId() != null ? mp.getSupplyId() : "UNKNOWN");
                            int leadTimeDays = mp.getPurchaseCycle() != null ? mp.getPurchaseCycle() : 0;
                            Operation procureOp = new Operation(
                                    "OP_PROCURE_" + m.getId() + "_" + (mp.getSupplyId() != null ? mp.getSupplyId() : "X"),
                                    "采购-" + pickName(m.getName(), m.getId())
                                            + (mp.getSupplyName() != null ? "-" + mp.getSupplyName() : ""),
                                    Collections.singletonList(
                                            new UnitOperation(unitId, 0.5, false, 0.0, 1.0)),
                                    new OperationOutput(p, sp),
                                    1.0, leadTimeDays);
                            operations.add(procureOp);
                            log.info("为 MP 原材料 {} 创建采购 Operation (供应商={}, leadTime={}天)",
                                    m.getId(), mp.getSupplyName(), leadTimeDays);
                        }
                    } else {
                        // 无供应商: 使用通用供应商, 无限产能
                        String unitId = "UNIT_DEFAULT_SUPPLIER";
                        ctx.unlimitedUnitIds.add(unitId);
                        Operation procureOp = new Operation(
                                "OP_PROCURE_" + m.getId(),
                                "采购-" + pickName(m.getName(), m.getId()),
                                Collections.singletonList(
                                        new UnitOperation(unitId, 0.5, false, 0.0, 1.0)),
                                new OperationOutput(p, sp),
                                1.0, 0);
                        operations.add(procureOp);
                        log.info("为 MP 原材料 {} 创建通用采购 Operation (无供应商, 无限产能)", m.getId());
                    }
                }
            }
            log.info("构建 OperationInput: {}", operationInputs.size());
        }

        /**
         * 解析投料来源库存点: 优先用 Routingsupporting.storeId, 找不到则用物料默认 SP。
         */
        private StockingPoint resolveInputSp(ConvertContext ctx, Routingsupporting rs, Product inputProduct) {
            if (rs.getStoreId() != null) {
                StockingPoint sp = ctx.spByStoreId.get(rs.getStoreId());
                if (sp != null) {
                    return sp;
                }
                // 投料仓库未在库存中出现, 新建
                String spName = rs.getStoreName() != null ? rs.getStoreName() : "仓库_" + rs.getStoreId();
                sp = new StockingPoint("SP_" + rs.getStoreId(), spName);
                stockingPoints.add(sp);
                ctx.spByStoreId.put(rs.getStoreId(), sp);
                productSpMappings.add(new ProductSpMapping(inputProduct, sp));
                return sp;
            }
            // 无仓库信息, 用该物料的默认 SP
            StockingPoint sp = ctx.finalSpByMaterialId.get(rs.getMaterialId());
            if (sp == null) {
                sp = new StockingPoint("SP_DEFAULT_" + rs.getMaterialId(),
                        "默认库_" + rs.getMaterialId());
                stockingPoints.add(sp);
                productSpMappings.add(new ProductSpMapping(inputProduct, sp));
                ctx.finalSpByMaterialId.put(rs.getMaterialId(), sp);
            }
            return sp;
        }

        // ---------- 步骤6: InitialInventory + InTransitSupply ----------

        private void fillInitialInventoryAndInTransit(ConvertContext ctx) {
            // 已有的 (productId, spId) 集合 (来自 expand 的 0 库存 WIP)
            Set<String> existingKeys = new HashSet<>();
            for (InitialInventory ii : initialInventories) {
                existingKeys.add(ii.getProduct().getId() + "_" + ii.getStockingPoint().getId());
            }

            for (Material m : ctx.materialByMaterialId.values()) {
                Product p = ctx.productByMaterialId.get(m.getId());
                if (p == null) {
                    continue;
                }
                // 初始库存
                if (m.getMaterialStocks() != null) {
                    for (Stock s : m.getMaterialStocks()) {
                        if (s.getStoreId() == null) {
                            continue;
                        }
                        StockingPoint sp = ctx.spByStoreId.get(s.getStoreId());
                        if (sp == null) {
                            continue;
                        }
                        String key = p.getId() + "_" + sp.getId();
                        if (existingKeys.contains(key)) {
                            continue; // 已有 (WIP 0库存), 跳过避免重复
                        }
                        initialInventories.add(new InitialInventory(p, sp, s.getTotal()));
                        existingKeys.add(key);
                    }
                }

                // 在途供应
                StockingPoint defaultSp = ctx.finalSpByMaterialId.get(m.getId());
                if (defaultSp == null || m.getInTransit() == null) {
                    continue;
                }
                for (MaterialSupply ms : m.getInTransit()) {
                    if (ms.getArrivalTime() == null) {
                        continue;
                    }
                    inTransitSupplies.add(new InTransitSupply(
                            p, defaultSp, ms.getArrivalTime().toLocalDate(), ms.getQuantity()));
                }
            }
            log.info("构建 InitialInventory: {}, InTransitSupply: {}",
                    initialInventories.size(), inTransitSupplies.size());
        }

        // ---------- 步骤7: Period + UnitPeriod (按维度创建周期, 聚合日产能) ----------

        private void fillPeriodsAndUnitPeriods(ConvertContext ctx) {
            LocalDate baseDate = ctx.baseTime.toLocalDate();
            int periodCount = ctx.periodCount;
            String dimension = ctx.periodDimension;

            // 计算 horizonEnd (与 buildDailyCapacityByUnitId 中一致)
            ApsTimeConfig timeConfig = apsTimeConfigService.getOne(new LambdaQueryWrapper<>());
            long horizonSeconds = 86400 * DEFAULT_PERIOD_COUNT;
            if (timeConfig != null && timeConfig.getStartCount() != null && timeConfig.getEndCount() != null) {
                long diff = timeConfig.getEndCount().longValue() - timeConfig.getStartCount().longValue();
                if (diff > 0) {
                    horizonSeconds = diff;
                }
            }
            int totalDays = (int) Math.max(1, horizonSeconds / 86400);
            LocalDate horizonEnd = baseDate.plusDays(totalDays);

            // 按维度创建 Period
            switch (dimension) {
                case "WEEK":
                    createWeekPeriods(baseDate, periodCount, horizonEnd);
                    break;
                case "MONTH":
                    createMonthPeriods(baseDate, periodCount, horizonEnd);
                    break;
                case "DAY":
                default:
                    createDayPeriods(baseDate, periodCount);
                    break;
            }

            // 收集所有 unitId (遍历每个 Operation 的每个 UnitOperation)
            Set<String> unitIds = new LinkedHashSet<>();
            for (Operation op : operations) {
                for (UnitOperation uo : op.getUnitOperations()) {
                    unitIds.add(uo.getUnitId());
                }
            }

            double DEFAULT_DAILY_HOURS = 16.0;

            for (Period p : periods) {
                LocalDate periodStart = p.getStartDate();
                LocalDate periodEnd = periodStart.plusDays((long) p.getDurationInDays());

                for (String unitId : unitIds) {
                    // 无限产能设备 (通用供应商): 直接创建 unlimited UnitPeriod
                    if (ctx.unlimitedUnitIds.contains(unitId)) {
                        unitPeriods.add(UnitPeriod.unlimited(unitId, p));
                        continue;
                    }

                    double periodMaxCapacity;
                    Map<LocalDate, Double> hourlyData = ctx.dailyHoursByUnitId.get(unitId);
                    if (hourlyData != null && !hourlyData.isEmpty()) {
                        // 有 EquipShiftCapacity 数据: 按实际日产能加和
                        double totalHours = 0;
                        for (Map.Entry<LocalDate, Double> e : hourlyData.entrySet()) {
                            if (!e.getKey().isBefore(periodStart) && e.getKey().isBefore(periodEnd)) {
                                totalHours += e.getValue();
                            }
                        }
                        periodMaxCapacity = totalHours;
                    } else {
                        // 无 EquipShiftCapacity 数据: 使用平均日产能 × 工作日天数
                        double dailyCapacity = ctx.dailyCapacityByUnitId.getOrDefault(unitId, DEFAULT_DAILY_HOURS);
                        int workDays = calculateWorkDays(periodStart, periodEnd);
                        periodMaxCapacity = dailyCapacity * workDays;
                    }

                    if (periodMaxCapacity <= 0) {
                        int workDays = calculateWorkDays(periodStart, periodEnd);
                        periodMaxCapacity = DEFAULT_DAILY_HOURS * Math.max(1, workDays);
                    }
                    unitPeriods.add(new UnitPeriod(unitId, p, 0.0, periodMaxCapacity, false));
                }
            }
            log.info("构建 Period: {} (dimension={}, 共{}天), UnitPeriod: {}",
                    periods.size(), dimension, totalDays, unitPeriods.size());
        }

        /** DAY 维度: 每天一个 Period */
        private void createDayPeriods(LocalDate baseDate, int periodCount) {
            for (int i = 0; i < periodCount; i++) {
                periods.add(new Period(i, formatDayName(baseDate.plusDays(i)), 1.0, baseDate.plusDays(i)));
            }
        }

        /** WEEK 维度: 每周一个 Period (周一起始, 7天, 最后一周可能不足7天) */
        private void createWeekPeriods(LocalDate baseDate, int periodCount, LocalDate horizonEnd) {
            for (int i = 0; i < periodCount; i++) {
                LocalDate weekStart = baseDate.plusDays(i * 7L);
                LocalDate weekEnd = weekStart.plusDays(7);
                if (weekEnd.isAfter(horizonEnd)) {
                    weekEnd = horizonEnd;
                }
                long durationDays = java.time.temporal.ChronoUnit.DAYS.between(weekStart, weekEnd);
                periods.add(new Period(i, "W" + (i + 1) + "(" + weekStart + ")", (double) durationDays, weekStart));
            }
        }

        /** MONTH 维度: 每月一个 Period (按自然月, 首尾月可能不足月) */
        private void createMonthPeriods(LocalDate baseDate, int periodCount, LocalDate horizonEnd) {
            LocalDate cursor = baseDate.withDayOfMonth(1);
            for (int i = 0; i < periodCount; i++) {
                LocalDate monthStart = cursor;
                LocalDate monthEnd = cursor.plusMonths(1);
                if (monthEnd.isAfter(horizonEnd)) {
                    monthEnd = horizonEnd;
                }
                long durationDays = java.time.temporal.ChronoUnit.DAYS.between(monthStart, monthEnd);
                periods.add(new Period(i, formatMonthName(monthStart), (double) durationDays, monthStart));
                cursor = cursor.plusMonths(1);
            }
        }

        private static String formatDayName(LocalDate date) {
            return String.format("D%02d(%02d-%02d)", date.getDayOfYear(), date.getMonthValue(), date.getDayOfMonth());
        }

        private static String formatMonthName(LocalDate date) {
            return String.format("M%d-%02d", date.getYear(), date.getMonthValue());
        }

        // ---------- 步骤8: SalesDemand (从订单推导) ----------

        private void fillSalesDemands(ConvertContext ctx) {
            if (periods.isEmpty()) {
                return;
            }
            for (ProdLaunchOrder plo : ctx.prodLaunchOrders) {
                if (plo.getMaterialId() == null) {
                    continue;
                }
                Product p = ctx.productByMaterialId.get(plo.getMaterialId());
                if (p == null) {
                    continue;
                }
                StockingPoint sp = ctx.finalSpByMaterialId.get(plo.getMaterialId());
                if (sp == null) {
                    continue;
                }

                Period targetPeriod = null;
                if (plo.getEndDate() != null) {
                    LocalDate dueDate = plo.getEndDate().toLocalDate();
                    for (Period per : periods) {
                        if (per.contains(dueDate)) {
                            targetPeriod = per;
                            break;
                        }
                    }
                }
                if (targetPeriod == null) {
                    // 找不到匹配周期, 用最后一个周期
                    targetPeriod = periods.get(periods.size() - 1);
                }

                double priority = plo.getOrderPriority() != null ? plo.getOrderPriority() : 1;
                salesDemands.add(new SalesDemand(p, sp, targetPeriod, plo.getQuantity(), priority));
            }
            log.info("构建 SalesDemand: {}", salesDemands.size());
        }

        // ---------- 步骤9: InventorySpec + SupplySpec (默认宽松) ----------

        private void fillInventorySpecsAndSupplySpecs(ConvertContext ctx) {
            // InventorySpec: 每个 (Product, StockingPoint) × Period, 默认宽松约束
            Set<String> invSpecKeys = new HashSet<>();
            for (ProductSpMapping m : productSpMappings) {
                for (Period p : periods) {
                    String key = m.getProduct().getId() + "_" + m.getStockingPoint().getId() + "_" + p.getIndex();
                    if (invSpecKeys.add(key)) {
                        inventorySpecs.add(new InventorySpec(m.getProduct(), m.getStockingPoint(), p,
                                0.0, 0.0, LOOSE_MAX, true, true, true));
                    }
                }
            }

            // SupplySpec: 每个 Routing 的最后一道工序 (产出成品) + 采购 Operation
            for (Routing r : routings) {
                List<Operation> ops = r.getOperations();
                if (ops.isEmpty()) {
                    continue;
                }
                Operation lastOp = ops.get(ops.size() - 1);
                supplySpecs.add(new SupplySpec("Supply-" + r.getId(),
                        0.0, 0.0, LOOSE_MAX, false, Collections.singletonList(lastOp)));
            }
            for (Operation op : operations) {
                if (op.getId().startsWith("OP_PROCURE_")) {
                    supplySpecs.add(new SupplySpec("Supply-" + op.getId(),
                            0.0, 0.0, LOOSE_MAX, false, Collections.singletonList(op)));
                }
            }
            log.info("构建 InventorySpec: {}, SupplySpec: {}", inventorySpecs.size(), supplySpecs.size());
        }

        // ---------- 步骤10: KPIWeights (默认值, 复用 RoutingTestDataBuilder) ----------

        private void fillKpiWeights() {
            kpiWeights = new KPIWeights(
                    100.0,  // fulfillmentWeight
                    10.0,   // lotSizeWeight
                    5.0,    // maxInventoryLevelWeight
                    5.0,    // minInventoryLevelWeight
                    8.0,    // targetInventoryLevelWeight
                    3.0,    // unitCapacityWeight
                    8.0,    // supplyTargetWeight
                    5.0,    // minSupplyWeight
                    5.0,    // maxSupplyWeight
                    1.0,    // salesDemandPriorityWeight
                    20.0,   // postponementPenaltyWeight
                    5.0     // processMaxQuantityWeight
            );
        }

        // ---------- 工具方法 ----------

        /**
         * 按优先级选取第一个非空字符串。
         */
        private static String pickName(String... candidates) {
            for (String c : candidates) {
                if (c != null && !c.isEmpty()) {
                    return c;
                }
            }
            return "unknown";
        }
    }

    // ==================== ConvertContext: 承载所有加载的数据库实体 + 转换中间映射 ====================

    private static class ConvertContext {
        // 数据库加载的原始实体
        List<ProdLaunchOrder> prodLaunchOrders = new ArrayList<>();
        List<RoutingHeader> routingHeaders = new ArrayList<>();
        List<RoutingDetail> routingDetails = new ArrayList<>();
        List<Routingsupporting> routingsupportings = new ArrayList<>();
        List<MaterialInfo> materialInfos = new ArrayList<>();
        List<Stock> stocks = new ArrayList<>();
        List<MaterialPurchase> materialPurchases = new ArrayList<>();
        List<ErpPurchaseOrder> erpPurchaseOrders = new ArrayList<>();
        List<PurchaseReceipt> purchaseReceipts = new ArrayList<>();
        List<SjzPfWhStock> sjzPfWhStocks = new ArrayList<>();
        List<RoutingDetailEquip> routingDetailEquips = new ArrayList<>();
        List<Equipinfo> equipinfos = new ArrayList<>();
        List<PlanResource> planResources = new ArrayList<>();
        List<EquipShiftCapacity> equipShiftCapacities = new ArrayList<>();
        LocalDateTime baseTime;

        // 转换过程的中间映射
        /** materialId → Material 业务对象 */
        Map<String, Material> materialByMaterialId = new HashMap<>();
        /** materialId → macroplanner Product */
        Map<String, Product> productByMaterialId = new HashMap<>();
        /** storeId → StockingPoint */
        Map<Long, StockingPoint> spByStoreId = new HashMap<>();
        /** routingHeaderId → macroplanner Routing */
        Map<Integer, Routing> routingByHeaderId = new HashMap<>();
        /** routingDetailId → macroplanner Operation */
        Map<Long, Operation> operationByRoutingDetailId = new HashMap<>();
        /** materialId → 成品/半成品库 StockingPoint (首个库存点) */
        Map<String, StockingPoint> finalSpByMaterialId = new HashMap<>();
        /** 周期维度: DAY/WEEK/MONTH, 默认 DAY */
        String periodDimension = "DAY";
        /** unitId ("EQUIP_" + PlanResource.id) → 日产能 (小时), 含日历+效率系数 */
        Map<String, Double> dailyCapacityByUnitId = new HashMap<>();
        /** unitId → 每天实际产能 (LocalDate → 小时), 从 EquipShiftCapacity 解析 */
        Map<String, Map<LocalDate, Double>> dailyHoursByUnitId = new HashMap<>();
        /** unitId → 节假日日期集合 */
        Map<String, Set<LocalDate>> holidayDatesByUnitId = new HashMap<>();
        /** unitId 集合: 产能无上限的设备 (如通用供应商, 无 MATERIAL_PURCHASE 时) */
        Set<String> unlimitedUnitIds = new HashSet<>();
        /** 排产周期数, 从 ApsTimeConfig 计算, 维度为 DAY 时是天数, WEEK 时是周数, MONTH 时是月数 */
        int periodCount = 7;
    }
}
