package com.yhy.module.core.service.workbench.impl;

import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception0;
import static com.yhy.module.core.enums.ErrorCodeConstants.WB_BOQ_DIVISION_BASE_NUMBER_FORMULA_INVALID;
import static com.yhy.module.core.enums.ErrorCodeConstants.WB_BOQ_DIVISION_BOQ_PARENT_MUST_DIVISION;
import static com.yhy.module.core.enums.ErrorCodeConstants.WB_BOQ_DIVISION_COMPILE_TREE_NOT_UNIT;
import static com.yhy.module.core.enums.ErrorCodeConstants.WB_BOQ_DIVISION_DIVISION_MUST_ROOT;
import static com.yhy.module.core.enums.ErrorCodeConstants.WB_BOQ_DIVISION_FORMULA_INVALID;
import static com.yhy.module.core.enums.ErrorCodeConstants.WB_BOQ_DIVISION_HAS_CHILDREN;
import static com.yhy.module.core.enums.ErrorCodeConstants.WB_BOQ_DIVISION_NODE_TYPE_CANNOT_CHANGE;
import static com.yhy.module.core.enums.ErrorCodeConstants.WB_BOQ_DIVISION_NODE_TYPE_ERROR;
import static com.yhy.module.core.enums.ErrorCodeConstants.WB_BOQ_DIVISION_NODE_TYPE_INVALID;
import static com.yhy.module.core.enums.ErrorCodeConstants.WB_BOQ_DIVISION_NOT_EXISTS;
import static com.yhy.module.core.enums.ErrorCodeConstants.WB_BOQ_DIVISION_NOT_SAME_LEVEL;
import static com.yhy.module.core.enums.ErrorCodeConstants.WB_BOQ_DIVISION_PARENT_NOT_EXISTS;
import static com.yhy.module.core.enums.ErrorCodeConstants.WB_BOQ_DIVISION_QUOTA_PARENT_MUST_BOQ;
import static com.yhy.module.core.enums.ErrorCodeConstants.WB_BOQ_DIVISION_ROOT_CANNOT_DELETE;

import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import com.yhy.module.core.controller.admin.workbench.vo.HistoryBoqListReqVO;
import com.yhy.module.core.controller.admin.workbench.vo.WbBoqDivisionRespVO;
import com.yhy.module.core.controller.admin.workbench.vo.WbBoqDivisionSaveReqVO;
import com.yhy.module.core.controller.admin.workbench.vo.WbBoqDivisionSwapSortReqVO;
import com.yhy.module.core.controller.admin.workbench.vo.price.QuotaUnitPriceResult;
import com.yhy.module.core.convert.workbench.WbBoqDivisionConvert;
import com.yhy.module.core.dal.dataobject.quota.QuotaMarketMaterialDO;
import com.yhy.module.core.dal.dataobject.quota.QuotaResourceDO;
import com.yhy.module.core.dal.dataobject.resource.ResourceItemDO;
import com.yhy.module.core.dal.dataobject.resource.ResourceMergedDO;
import com.yhy.module.core.dal.dataobject.workbench.WbBoqDivisionDO;
import com.yhy.module.core.dal.dataobject.workbench.WbBoqMarketMaterialDO;
import com.yhy.module.core.dal.dataobject.workbench.WbBoqResourceDO;
import com.yhy.module.core.dal.dataobject.workbench.WbCompileTreeDO;
import com.yhy.module.core.dal.mysql.calcbaserate.CalcBaseRateItemMapper;
import com.yhy.module.core.dal.mysql.quota.QuotaMarketMaterialMapper;
import com.yhy.module.core.dal.mysql.quota.QuotaResourceMapper;
import com.yhy.module.core.dal.mysql.resource.ResourceItemMapper;
import com.yhy.module.core.dal.mysql.resource.ResourceMergedMapper;
import com.yhy.module.core.dal.mysql.workbench.WbBoqDivisionMapper;
import com.yhy.module.core.dal.mysql.workbench.WbBoqMarketMaterialMapper;
import com.yhy.module.core.dal.mysql.workbench.WbBoqResourceMapper;
import com.yhy.module.core.dal.mysql.workbench.WbCompileTreeMapper;
import com.yhy.module.core.service.workbench.QuotaPriceCalculatorService;
import com.yhy.module.core.service.workbench.QuotaQtyFormulaService;
import com.yhy.module.core.service.workbench.WbBoqDivisionService;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
import javax.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;

/**
 * 工作台分部分项树 Service 实现类
 *
 * @author yhy
 */
@Service
@Validated
@Slf4j
public class WbBoqDivisionServiceImpl implements WbBoqDivisionService {

    @Resource
    private WbBoqDivisionMapper wbBoqDivisionMapper;

    @Resource
    private WbBoqResourceMapper wbBoqResourceMapper;

    @Resource
    private WbBoqMarketMaterialMapper wbBoqMarketMaterialMapper;

    @Resource
    private WbCompileTreeMapper wbCompileTreeMapper;

    @Resource
    private QuotaResourceMapper quotaResourceMapper;

    @Resource
    private QuotaMarketMaterialMapper quotaMarketMaterialMapper;

    @Resource
    private ResourceItemMapper resourceItemMapper;

    @Resource
    private ResourceMergedMapper resourceMergedMapper;

    @Resource
    private QuotaPriceCalculatorService quotaPriceCalculatorService;

    @Resource
    private com.yhy.module.core.dal.mysql.boq.BoqItemTreeMapper boqItemTreeMapper;

    @Resource
    private com.yhy.module.core.dal.mysql.boq.BoqCatalogItemMapper boqCatalogItemMapper;

    @Resource
    private com.yhy.module.core.dal.mysql.quota.QuotaItemMapper quotaItemMapper;

    @Resource
    private com.yhy.module.core.dal.mysql.workbench.WbUnitInfoMapper wbUnitInfoMapper;

    @Resource
    private com.yhy.module.core.dal.mysql.boq.BoqSubItemMapper boqSubItemMapper;

    @Resource
    private com.yhy.module.core.dal.mysql.boq.BoqGuideTreeMapper boqGuideTreeMapper;

    @Resource
    private com.yhy.module.core.dal.mysql.workbench.WbProjectTreeMapper wbProjectTreeMapper;

    @Resource
    private com.yhy.module.core.dal.mysql.quota.QuotaCatalogTreeMapper quotaCatalogTreeMapper;

    @Resource
    private com.yhy.module.core.service.quota.QuotaItemService quotaItemService;

    @Resource
    private QuotaQtyFormulaService quotaQtyFormulaService;

    @Resource
    private CalcBaseRateItemMapper calcBaseRateItemMapper;

    @Resource
    private com.yhy.module.core.service.workbench.WbBoqResourceService wbBoqResourceService;

    @Resource
    private com.yhy.module.core.dal.mysql.quota.QuotaCatalogItemMapper quotaCatalogItemMapper;

    @Resource
    private com.yhy.module.core.dal.mysql.quota.QuotaVariableSettingMapper quotaVariableSettingMapper;

    @Resource
    private com.yhy.module.core.dal.mysql.resource.ResourceCategoryMapper resourceCategoryMapper;

    @Resource
    private com.yhy.module.core.service.workbench.WbSnapshotReadService wbSnapshotReadService;

    @Resource
    private com.yhy.module.core.dal.mysql.config.ConfigUnitDivisionTemplateMapper configUnitDivisionTemplateMapper;

    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long createNode(WbBoqDivisionSaveReqVO createReqVO) {
        // 1. 校验单位工程节点
        validateCompileTreeIsUnit(createReqVO.getCompileTreeId());

        // 2. 校验节点类型
        validateNodeType(createReqVO.getNodeType());

        // 3. 校验父节点和层级关系
        WbBoqDivisionDO parent = null;
        if (createReqVO.getParentId() != null) {
            parent = validateParentExists(createReqVO.getParentId());
        } else if (WbBoqDivisionDO.NODE_TYPE_DIVISION.equals(createReqVO.getNodeType())) {
            // 分部节点如果没有指定父节点，自动挂到 root 节点下
            List<WbBoqDivisionDO> rootNodes = wbBoqDivisionMapper.selectListByNodeType(
                    createReqVO.getCompileTreeId(), WbBoqDivisionDO.NODE_TYPE_ROOT);
            if (!rootNodes.isEmpty()) {
                parent = rootNodes.get(0);
                createReqVO.setParentId(parent.getId());
                log.info("[createNode] 分部节点自动挂到root节点下, compileTreeId={}, rootId={}", 
                        createReqVO.getCompileTreeId(), parent.getId());
            }
        }
        validateHierarchy(createReqVO.getNodeType(), parent);

        // 3.3 如果是清单节点，校验编码在同一单位工程下唯一
        if (WbBoqDivisionDO.NODE_TYPE_BOQ.equals(createReqVO.getNodeType()) 
                && StrUtil.isNotBlank(createReqVO.getCode())) {
            validateBoqCodeUnique(createReqVO.getCompileTreeId(), createReqVO.getCode(), null);
        }

        // 3.5 如果是定额节点且有引用定额基价ID，检查费率模式是否存在，并自动建立绑定
        if (WbBoqDivisionDO.NODE_TYPE_QUOTA.equals(createReqVO.getNodeType()) 
                && createReqVO.getSourceQuotaItemId() != null) {
            Long rateModeId = quotaItemService.getRateModeIdByQuotaItem(createReqVO.getSourceQuotaItemId());
            if (rateModeId == null) {
                log.warn("[createNode] 定额基价关联的定额专业下没有费率模式, sourceQuotaItemId={}", 
                        createReqVO.getSourceQuotaItemId());
                throw exception0(400, "该定额关联的定额专业下没有配置费率模式，无法添加。");
            }
            
            // 自动建立项目级费率模式绑定（如果尚未绑定）
            autoBindRateModeForProject(createReqVO.getCompileTreeId(), createReqVO.getSourceQuotaItemId(), rateModeId);
        }

        // 4. 构建DO
        WbBoqDivisionDO node = WbBoqDivisionConvert.INSTANCE.convert(createReqVO);

        // 4.1 处理 tabType：写入 attributes.tabTypes
        String tabType = createReqVO.getTabType();
        if (StrUtil.isBlank(tabType) && parent != null) {
            // 未指定时从父节点继承
            Map<String, Object> parentAttrs = parent.getAttributes();
            if (parentAttrs != null) {
                // 优先从 tabTypes 数组继承
                Object parentTabTypes = parentAttrs.get("tabTypes");
                if (parentTabTypes instanceof java.util.List && !((java.util.List<?>) parentTabTypes).isEmpty()) {
                    Map<String, Object> attrs = node.getAttributes();
                    if (attrs == null) {
                        attrs = new java.util.HashMap<>();
                    }
                    attrs.put("tabTypes", parentTabTypes);
                    node.setAttributes(attrs);
                    tabType = null; // 已处理，跳过下面的单值逻辑
                } else if (parentAttrs.get("tabType") != null) {
                    // 兼容旧的单值格式
                    tabType = (String) parentAttrs.get("tabType");
                }
            }
        }
        if (StrUtil.isNotBlank(tabType)) {
            Map<String, Object> attrs = node.getAttributes();
            if (attrs == null) {
                attrs = new java.util.HashMap<>();
            }
            List<String> tabTypes = new ArrayList<>();
            tabTypes.add(tabType);
            attrs.put("tabTypes", tabTypes);
            node.setAttributes(attrs);
        }

        // 4.5 定额节点默认设置工程量公式为 QDL
        if (WbBoqDivisionDO.NODE_TYPE_QUOTA.equals(node.getNodeType())) {
            if (StrUtil.isBlank(node.getQuotaQtyFormula())) {
                node.setQuotaQtyFormula("QDL");
            }
            // 如果有公式且有父清单，计算工程量（带单位换算）
            if (StrUtil.isNotBlank(node.getQuotaQtyFormula()) && parent != null 
                    && WbBoqDivisionDO.NODE_TYPE_BOQ.equals(parent.getNodeType())) {
                BigDecimal qdlValue = parent.getQty() != null ? parent.getQty() : BigDecimal.ZERO;
                BigDecimal calculatedQty = quotaQtyFormulaService.calculateQuotaQty(
                        node.getQuotaQtyFormula(), qdlValue, node.getUnit());
                node.setQty(calculatedQty);
            }
        }

        // 5. 设置默认来源类型
        if (node.getSourceType() == null) {
            node.setSourceType(WbBoqDivisionDO.SOURCE_TYPE_MANUAL);
        }

        // 6. 设置排序号（所有节点类型都插入到末尾）
        Integer maxSortOrder = wbBoqDivisionMapper.selectMaxSortOrderByParentId(
                createReqVO.getCompileTreeId(), createReqVO.getParentId());
        node.setSortOrder(maxSortOrder + 1);

        // 7. 设置路径
        node.setPath(buildPath(parent));

        // 8. 插入数据库
        wbBoqDivisionMapper.insert(node);

        // 9. 更新路径（包含自身ID）
        updatePathWithSelfId(node);

        // 10. 如果是定额节点且有引用定额基价ID，复制工料机数据
        if (WbBoqDivisionDO.NODE_TYPE_QUOTA.equals(node.getNodeType()) 
                && createReqVO.getSourceQuotaItemId() != null) {
            copyQuotaResources(node.getId(), node.getTenantId(), createReqVO.getSourceQuotaItemId());
        }

        return node.getId();
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    public void updateNode(WbBoqDivisionSaveReqVO updateReqVO) {
        // 1. 校验存在
        WbBoqDivisionDO existNode = validateNodeExists(updateReqVO.getId());

        // 2. 根目录节点只允许更新费用代号字段
        if (WbBoqDivisionDO.NODE_TYPE_ROOT.equals(existNode.getNodeType())) {
            // 只更新 costCode 字段
            WbBoqDivisionDO updateObj = new WbBoqDivisionDO();
            updateObj.setId(existNode.getId());
            updateObj.setCostCode(updateReqVO.getCostCode());
            wbBoqDivisionMapper.updateById(updateObj);
            return;
        }

        // 3. 不允许修改节点类型
        if (!existNode.getNodeType().equals(updateReqVO.getNodeType())) {
            throw exception(WB_BOQ_DIVISION_NODE_TYPE_CANNOT_CHANGE);
        }

        // 3.5 如果是清单节点且编码有变化，校验编码在同一单位工程下唯一
        if (WbBoqDivisionDO.NODE_TYPE_BOQ.equals(existNode.getNodeType()) 
                && StrUtil.isNotBlank(updateReqVO.getCode())
                && !updateReqVO.getCode().equals(existNode.getCode())) {
            validateBoqCodeUnique(existNode.getCompileTreeId(), updateReqVO.getCode(), existNode.getId());
        }

        // 4. 更新
        WbBoqDivisionDO updateObj = WbBoqDivisionConvert.INSTANCE.convert(updateReqVO);
        updateObj.setId(existNode.getId());
        // 保留不可修改的字段
        updateObj.setCompileTreeId(existNode.getCompileTreeId());
        updateObj.setParentId(existNode.getParentId());
        updateObj.setSortOrder(existNode.getSortOrder());
        updateObj.setPath(existNode.getPath());
        // 合并扩展属性（如果请求中有attributes，则合并到现有属性中）
        if (updateReqVO.getAttributes() != null) {
            Map<String, Object> mergedAttrs = new java.util.HashMap<>();
            if (existNode.getAttributes() != null) {
                mergedAttrs.putAll(existNode.getAttributes());
            }
            mergedAttrs.putAll(updateReqVO.getAttributes());
            
            // 验证基数公式（如果存在），传入当前节点的费用代号用于防止循环引用
            validateBaseNumberFormula(mergedAttrs, updateReqVO.getCostCode());
            
            updateObj.setAttributes(mergedAttrs);
        }

        // 5. 定额节点：处理工程量公式
        if (WbBoqDivisionDO.NODE_TYPE_QUOTA.equals(existNode.getNodeType())) {
            String formula = updateReqVO.getQuotaQtyFormula();
            // 空值恢复为默认公式 QDL
            if (StrUtil.isBlank(formula)) {
                formula = "QDL";
                updateObj.setQuotaQtyFormula(formula);
            }
            // 验证公式合法性
            QuotaQtyFormulaService.FormulaValidationResult validationResult = 
                quotaQtyFormulaService.validateFormula(formula);
            if (!validationResult.isValid()) {
                throw exception(WB_BOQ_DIVISION_FORMULA_INVALID, validationResult.getError());
            }
            // 获取父清单工程量并计算（带单位换算）
            BigDecimal qdlValue = getParentBoqQty(existNode.getParentId());
            // 优先使用更新请求中的单位，否则使用现有单位
            String quotaUnit = updateReqVO.getUnit() != null ? updateReqVO.getUnit() : existNode.getUnit();
            BigDecimal calculatedQty = quotaQtyFormulaService.calculateQuotaQty(formula, qdlValue, quotaUnit);
            updateObj.setQty(calculatedQty);
        }

        // 6. 单独清单（无子节点）：根据基数公式计算工程量
        if (WbBoqDivisionDO.NODE_TYPE_BOQ.equals(existNode.getNodeType())) {
            // 检查是否为单独清单（没有子节点）
            List<WbBoqDivisionDO> children = wbBoqDivisionMapper.selectListByParentId(existNode.getId());
            boolean isStandaloneBoq = CollUtil.isEmpty(children);
            
            if (isStandaloneBoq) {
                // 单独清单：根据基数公式计算工程量
                // 获取树数据（含价格计算，用于费用代号变量求值）
                List<WbBoqDivisionRespVO> divisionTree = getTreeWithPrice(existNode.getCompileTreeId());
                BigDecimal calculatedQty = calculateStandaloneBoqQty(existNode.getCompileTreeId(), updateObj, divisionTree);
                if (calculatedQty != null) {
                    updateObj.setQty(calculatedQty);
                }
            }
        }

        wbBoqDivisionMapper.updateById(updateObj);

        // 7. 有子节点的清单：如果工程量变化，重新计算所有子定额的工程量
        if (WbBoqDivisionDO.NODE_TYPE_BOQ.equals(existNode.getNodeType())) {
            List<WbBoqDivisionDO> children = wbBoqDivisionMapper.selectListByParentId(existNode.getId());
            if (CollUtil.isNotEmpty(children)) {
                BigDecimal oldQty = existNode.getQty();
                BigDecimal newQty = updateReqVO.getQty();
                if (oldQty == null && newQty != null 
                    || oldQty != null && newQty == null 
                    || oldQty != null && oldQty.compareTo(newQty) != 0) {
                    quotaQtyFormulaService.recalculateChildQuotaQty(existNode.getId());
                }
            }
        }
    }

    /**
     * 获取父清单的工程量
     */
    private BigDecimal getParentBoqQty(Long parentId) {
        if (parentId == null) {
            return BigDecimal.ZERO;
        }
        WbBoqDivisionDO parent = wbBoqDivisionMapper.selectById(parentId);
        if (parent == null) {
            return BigDecimal.ZERO;
        }
        if (WbBoqDivisionDO.NODE_TYPE_BOQ.equals(parent.getNodeType())) {
            return parent.getQty() != null ? parent.getQty() : BigDecimal.ZERO;
        }
        return getParentBoqQty(parent.getParentId());
    }

    /**
     * 验证基数公式
     * 参考 QuotaVariableSettingServiceImpl.validateCalcBase 的验证逻辑
     * @param attributes 扩展属性
     * @param currentCostCode 当前节点的费用代号（用于防止循环引用）
     */
    @SuppressWarnings("unchecked")
    private void validateBaseNumberFormula(Map<String, Object> attributes, String currentCostCode) {
        if (attributes == null) {
            return;
        }
        
        Object baseNumberObj = attributes.get("baseNumber");
        if (baseNumberObj == null) {
            return;
        }
        
        if (!(baseNumberObj instanceof Map)) {
            return;
        }
        
        Map<String, Object> baseNumber = (Map<String, Object>) baseNumberObj;
        String formula = (String) baseNumber.get("formula");
        
        // 空公式是允许的
        if (StrUtil.isBlank(formula)) {
            return;
        }
        
        String trimmedFormula = formula.trim();
        
        // 验证公式中不能包含本行的费用代号（防止循环引用）
        if (StrUtil.isNotBlank(currentCostCode)) {
            // 使用正则匹配完整的变量名（避免部分匹配，如 ABC 不应该匹配 ABCD）
            String pattern = "\\b" + java.util.regex.Pattern.quote(currentCostCode) + "\\b";
            if (java.util.regex.Pattern.compile(pattern).matcher(trimmedFormula).find()) {
                throw exception(WB_BOQ_DIVISION_BASE_NUMBER_FORMULA_INVALID, 
                    "公式不能引用本行的费用代号[" + currentCostCode + "]，以免造成无限循环");
            }
        }
        
        // 验证公式语法：只允许字母、数字、运算符和括号
        String validPattern = "^[A-Za-z0-9_+\\-*/().\\s]+$";
        if (!trimmedFormula.matches(validPattern)) {
            throw exception(WB_BOQ_DIVISION_BASE_NUMBER_FORMULA_INVALID, "公式只能包含字母、数字、运算符(+-*/)和括号");
        }
        
        // 验证括号匹配
        int parenCount = 0;
        for (char c : trimmedFormula.toCharArray()) {
            if (c == '(') parenCount++;
            if (c == ')') parenCount--;
            if (parenCount < 0) {
                throw exception(WB_BOQ_DIVISION_BASE_NUMBER_FORMULA_INVALID, "括号不匹配");
            }
        }
        if (parenCount != 0) {
            throw exception(WB_BOQ_DIVISION_BASE_NUMBER_FORMULA_INVALID, "括号不匹配");
        }
        
        // 验证公式不能以运算符或小数点结尾
        if (trimmedFormula.matches(".*[+\\-*/.]$")) {
            throw exception(WB_BOQ_DIVISION_BASE_NUMBER_FORMULA_INVALID, "公式不能以运算符或小数点结尾");
        }
        
        // 验证公式不能以运算符开头（除了负号）
        if (trimmedFormula.matches("^[+*/.].*")) {
            throw exception(WB_BOQ_DIVISION_BASE_NUMBER_FORMULA_INVALID, "公式不能以运算符或小数点开头");
        }
        
        // 验证不能有连续的运算符
        if (trimmedFormula.matches(".*[+\\-*/]{2,}.*")) {
            throw exception(WB_BOQ_DIVISION_BASE_NUMBER_FORMULA_INVALID, "公式不能有连续的运算符");
        }
        
        // 验证小数点格式（不能有多个连续小数点）
        if (trimmedFormula.matches(".*\\.{2,}.*")) {
            throw exception(WB_BOQ_DIVISION_BASE_NUMBER_FORMULA_INVALID, "小数点格式不正确");
        }
        
        // 验证小数点后面不能直接跟运算符或括号
        if (trimmedFormula.matches(".*\\.[+\\-*/()].*")) {
            throw exception(WB_BOQ_DIVISION_BASE_NUMBER_FORMULA_INVALID, "小数点后面必须跟数字");
        }
    }

    /**
     * 计算单独清单的工程量
     * 单独清单的工程量 = 基数公式计算出来的汇总值
     * 
     * @param compileTreeId 编制树ID
     * @param node 清单节点（包含 attributes.calcBase 和 attributes.baseNumberRangeIds）
     * @param divisionTree 已有的分部分项树数据（避免循环调用）
     * @return 计算后的工程量，如果无法计算则返回 null
     */
    @SuppressWarnings("unchecked")
    private BigDecimal calculateStandaloneBoqQty(Long compileTreeId, WbBoqDivisionDO node, List<WbBoqDivisionRespVO> divisionTree) {
        Map<String, Object> attributes = node.getAttributes();
        if (attributes == null) {
            return null;
        }
        
        // 获取基数公式（从 baseNumber.formula 获取）
        Object baseNumberObj = attributes.get("baseNumber");
        if (baseNumberObj == null || !(baseNumberObj instanceof Map)) {
            return null;
        }
        Map<String, Object> baseNumber = (Map<String, Object>) baseNumberObj;
        String formula = (String) baseNumber.get("formula");
        if (StrUtil.isBlank(formula)) {
            return null;
        }
        
        log.info("[calculateStandaloneBoqQty] 原始公式: {}", formula);
        
        // 获取基数范围（从 baseNumberRange.selectedIds 获取）
        List<Long> baseNumberRangeIds = null;
        Object rangeObj = attributes.get("baseNumberRange");
        if (rangeObj instanceof Map) {
            Map<String, Object> rangeMap = (Map<String, Object>) rangeObj;
            Object selectedIdsObj = rangeMap.get("selectedIds");
            if (selectedIdsObj instanceof List) {
                List<?> rangeIdsList = (List<?>) selectedIdsObj;
                baseNumberRangeIds = new ArrayList<>();
                for (Object id : rangeIdsList) {
                    if (id instanceof Number) {
                        baseNumberRangeIds.add(((Number) id).longValue());
                    } else if (id instanceof String) {
                        try {
                            baseNumberRangeIds.add(Long.parseLong((String) id));
                        } catch (NumberFormatException e) {
                            // 忽略无效的ID
                        }
                    }
                }
            }
        }
        
        // 构建变量值映射（从工料机消耗表按分类汇总价格）
        // 排除当前节点自身，避免循环依赖
        Map<String, BigDecimal> variableValueMap = buildVariableValueMapForStandaloneBoq(
            compileTreeId, divisionTree, 
            baseNumberRangeIds != null ? new java.util.HashSet<>(baseNumberRangeIds) : null,
            node.getId() // 传入当前节点ID，用于排除
        );
        
        log.info("[calculateStandaloneBoqQty] 标准变量值映射: {}", variableValueMap);
        
        // 解析公式中的自定义变量（如 aaa123 -> DRGF）
        String resolvedFormula = resolveCustomVariables(formula, compileTreeId, variableValueMap);
        log.info("[calculateStandaloneBoqQty] 解析后公式: {}", resolvedFormula);
        
        // 计算公式
        BigDecimal result = evaluateFormula(resolvedFormula, variableValueMap);
        log.info("[calculateStandaloneBoqQty] 计算结果: {}", result);
        return result;
    }
    
    /**
     * 解析公式中的自定义变量
     * 自定义变量是指在变量设置表中定义的变量（如 aaa123），其 calc_base.formula 指向标准变量（如 DRGF）
     * 
     * @param formula 原始公式
     * @param compileTreeId 编制树ID
     * @param variableValueMap 标准变量值映射
     * @return 解析后的公式（自定义变量被替换为其 calc_base.formula）
     */
    @SuppressWarnings("unchecked")
    private String resolveCustomVariables(String formula, Long compileTreeId, Map<String, BigDecimal> variableValueMap) {
        if (StrUtil.isBlank(formula)) {
            return formula;
        }
        
        // 提取公式中的所有变量名
        java.util.regex.Pattern pattern = java.util.regex.Pattern.compile("[A-Za-z][A-Za-z0-9_]*");
        java.util.regex.Matcher matcher = pattern.matcher(formula);
        java.util.Set<String> variableNames = new java.util.HashSet<>();
        while (matcher.find()) {
            variableNames.add(matcher.group());
        }
        
        if (variableNames.isEmpty()) {
            return formula;
        }
        
        // 查找自定义变量（不在标准变量映射中的变量）
        java.util.Set<String> customVariables = new java.util.HashSet<>();
        for (String varName : variableNames) {
            if (!variableValueMap.containsKey(varName)) {
                customVariables.add(varName);
            }
        }
        
        if (customVariables.isEmpty()) {
            return formula;
        }
        
        log.info("[resolveCustomVariables] 自定义变量: {}", customVariables);
        
        // 从变量设置表查询自定义变量的 calc_base
        // 由于单独清单可能使用多个定额专业的变量，需要查询所有匹配 code 的变量设置
        String resolvedFormula = formula;
        for (String customVar : customVariables) {
            // 查询所有匹配该 code 的变量设置（可能来自不同的定额专业）
            List<com.yhy.module.core.dal.dataobject.quota.QuotaVariableSettingDO> varSettings = 
                quotaVariableSettingMapper.selectList(
                    new cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX<com.yhy.module.core.dal.dataobject.quota.QuotaVariableSettingDO>()
                        .eq(com.yhy.module.core.dal.dataobject.quota.QuotaVariableSettingDO::getCode, customVar)
                );
            
            // 取第一个匹配的变量设置（假设同名变量的 calc_base 相同）
            if (!varSettings.isEmpty()) {
                com.yhy.module.core.dal.dataobject.quota.QuotaVariableSettingDO varSetting = varSettings.get(0);
                if (varSetting.getCalcBase() != null) {
                    Map<String, Object> calcBase = varSetting.getCalcBase();
                    String innerFormula = (String) calcBase.get("formula");
                    if (StrUtil.isNotBlank(innerFormula)) {
                        log.info("[resolveCustomVariables] 变量 {} -> {}", customVar, innerFormula);
                        // 用括号包裹以保证运算优先级
                        resolvedFormula = resolvedFormula.replaceAll("\\b" + customVar + "\\b", "(" + innerFormula + ")");
                    }
                }
            }
        }
        
        return resolvedFormula;
    }

    /**
     * 为单独清单构建变量值映射
     *
     * 合价计算规则：
     * - 用量 = 定额工程量(quotaQty) × 消耗量(consumeQty)
     * - 合价 = 用量 × 单价
     * - 子工料机(parentId != null)不参与汇总
     * 
     * 变量代号来源：yhy_resource_category 表的四个代码字段
     * - tax_excl_base_code: 除税基价代码（如 DRGF）
     * - tax_incl_base_code: 含税基价代码（如 HDRGF）
     * - tax_excl_compile_code: 除税编制价代码（如 RGF）
     * - tax_incl_compile_code: 含税编制价代码（如 HRGF）
     */
    private Map<String, BigDecimal> buildVariableValueMapForStandaloneBoq(
            Long compileTreeId,
            List<WbBoqDivisionRespVO> divisionTree,
            java.util.Set<Long> baseNumberRangeIds,
            Long excludeNodeId) {
        Map<String, BigDecimal> result = new java.util.HashMap<>();
        boolean hasRange = baseNumberRangeIds != null && !baseNumberRangeIds.isEmpty();

        // 收集范围内的定额节点及其工程量
        java.util.Map<Long, BigDecimal> quotaNodeQtyMap = new java.util.HashMap<>();
        // 收集范围内的统一取费节点
        List<WbBoqDivisionRespVO> unifiedFeeNodes = new ArrayList<>();
        
        if (hasRange) {
            // 如果有基数范围，先收集范围内的所有节点ID（包括子节点）
            java.util.Set<Long> nodesInRange = new java.util.HashSet<>();
            collectNodesInRangeForBoq(divisionTree, baseNumberRangeIds, nodesInRange, false);
            // 排除当前节点
            if (excludeNodeId != null) {
                nodesInRange.remove(excludeNodeId);
            }
            // 从树中收集范围内的定额节点
            collectQuotaNodeQtyMapForBoq(divisionTree, quotaNodeQtyMap, true, nodesInRange, false);
            // 从树中收集范围内的统一取费节点
            collectUnifiedFeeNodesForBoq(divisionTree, unifiedFeeNodes, true, nodesInRange, false);
        } else {
            // 如果没有基数范围，查询整个单位工程的所有定额节点
            log.info("[buildVariableValueMapForStandaloneBoq] 基数范围为空，查询整个单位工程的定额节点, compileTreeId={}", compileTreeId);
            List<WbBoqDivisionDO> allQuotaNodes = wbBoqDivisionMapper.selectList(
                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<WbBoqDivisionDO>()
                    .eq(WbBoqDivisionDO::getCompileTreeId, compileTreeId)
                    .eq(WbBoqDivisionDO::getNodeType, WbBoqDivisionDO.NODE_TYPE_QUOTA)
                    .eq(WbBoqDivisionDO::getDeleted, 0)
            );
            for (WbBoqDivisionDO quota : allQuotaNodes) {
                // 排除当前节点
                if (excludeNodeId != null && excludeNodeId.equals(quota.getId())) {
                    continue;
                }
                BigDecimal qty = quota.getQty() != null ? quota.getQty() : BigDecimal.ONE;
                quotaNodeQtyMap.put(quota.getId(), qty);
            }
            // 无基数范围时，也查询所有统一取费节点
            List<WbBoqDivisionDO> allUnifiedFeeNodes = wbBoqDivisionMapper.selectList(
                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<WbBoqDivisionDO>()
                    .eq(WbBoqDivisionDO::getCompileTreeId, compileTreeId)
                    .eq(WbBoqDivisionDO::getNodeType, WbBoqDivisionDO.NODE_TYPE_UNIFIED_FEE)
                    .eq(WbBoqDivisionDO::getDeleted, 0)
            );
            for (WbBoqDivisionDO ufNode : allUnifiedFeeNodes) {
                if (excludeNodeId != null && excludeNodeId.equals(ufNode.getId())) {
                    continue;
                }
                WbBoqDivisionRespVO vo = new WbBoqDivisionRespVO();
                vo.setId(ufNode.getId());
                vo.setNodeType(ufNode.getNodeType());
                vo.setQty(ufNode.getQty());
                vo.setAttributes(ufNode.getAttributes());
                unifiedFeeNodes.add(vo);
            }
        }

        log.info("[buildVariableValueMapForStandaloneBoq] 定额节点数量: {}, 统一取费节点数量: {}", quotaNodeQtyMap.size(), unifiedFeeNodes.size());

        // 按分类汇总各价格字段
        Map<Long, Map<String, BigDecimal>> categoryPriceMap = new java.util.HashMap<>();

        // 处理普通定额节点的工料机
        if (!quotaNodeQtyMap.isEmpty()) {
            List<WbBoqResourceDO> allResources =
                wbBoqResourceMapper.selectListByDivisionIds(new ArrayList<>(quotaNodeQtyMap.keySet()));

            log.info("[buildVariableValueMapForStandaloneBoq] 工料机数量(含子): {}", allResources != null ? allResources.size() : 0);

            if (allResources != null) {
                for (WbBoqResourceDO resource : allResources) {
                    // 跳过子工料机（复合工料机只取父值）
                    if (resource.getParentId() != null) continue;

                    Long categoryId = resource.getCategoryId();
                    if (categoryId == null) continue;

                    // 获取消耗量（优先调整消耗量）
                    BigDecimal consumeQty = resource.getAdjustConsumeQty() != null
                        ? resource.getAdjustConsumeQty() : resource.getConsumeQty();
                    if (consumeQty == null) consumeQty = BigDecimal.ZERO;

                    // 获取定额工程量
                    BigDecimal quotaQty = quotaNodeQtyMap.get(resource.getDivisionId());
                    if (quotaQty == null) quotaQty = BigDecimal.ONE;

                    // 用量 = 定额工程量 × 消耗量（单位为%的工料机：用量=消耗量）
                    BigDecimal usageQty;
                    if ("%".equals(resource.getUnit())) {
                        usageQty = consumeQty;
                    } else {
                        usageQty = quotaQty.multiply(consumeQty);
                    }

                    Map<String, BigDecimal> priceMap = categoryPriceMap.computeIfAbsent(categoryId, k -> new java.util.HashMap<>());

                    // 合价 = 用量 × 单价
                    addPriceToMapForBoq(priceMap, "tax_excl_base_price", resource.getTaxExclBasePrice(), usageQty);
                    addPriceToMapForBoq(priceMap, "tax_incl_base_price", resource.getTaxInclBasePrice(), usageQty);
                    addPriceToMapForBoq(priceMap, "tax_excl_compile_price", resource.getTaxExclCompilePrice(), usageQty);
                    addPriceToMapForBoq(priceMap, "tax_incl_compile_price", resource.getTaxInclCompilePrice(), usageQty);
                }
            }
        }

        // 将统一取费节点的子目工料机也汇总到分类价格映射中
        if (!unifiedFeeNodes.isEmpty()) {
            aggregateUnifiedFeeResourcesForBoq(unifiedFeeNodes, compileTreeId, categoryPriceMap);
        }

        if (categoryPriceMap.isEmpty()) {
            return result;
        }

        log.info("[buildVariableValueMapForStandaloneBoq] 分类价格映射: {}", categoryPriceMap);

        // 从工料机总类表查询所有分类，构建变量代号到值的映射
        List<com.yhy.module.core.dal.dataobject.resource.ResourceCategoryDO> categories = 
            resourceCategoryMapper.selectList();
        
        for (com.yhy.module.core.dal.dataobject.resource.ResourceCategoryDO category : categories) {
            Map<String, BigDecimal> priceMap = categoryPriceMap.get(category.getId());
            if (priceMap == null) continue;
            
            // 除税基价代码 -> tax_excl_base_price
            if (StrUtil.isNotBlank(category.getTaxExclBaseCode())) {
                BigDecimal value = priceMap.get("tax_excl_base_price");
                if (value != null) {
                    result.put(category.getTaxExclBaseCode(), value);
                }
            }
            // 含税基价代码 -> tax_incl_base_price
            if (StrUtil.isNotBlank(category.getTaxInclBaseCode())) {
                BigDecimal value = priceMap.get("tax_incl_base_price");
                if (value != null) {
                    result.put(category.getTaxInclBaseCode(), value);
                }
            }
            // 除税编制价代码 -> tax_excl_compile_price
            if (StrUtil.isNotBlank(category.getTaxExclCompileCode())) {
                BigDecimal value = priceMap.get("tax_excl_compile_price");
                if (value != null) {
                    result.put(category.getTaxExclCompileCode(), value);
                }
            }
            // 含税编制价代码 -> tax_incl_compile_price
            if (StrUtil.isNotBlank(category.getTaxInclCompileCode())) {
                BigDecimal value = priceMap.get("tax_incl_compile_price");
                if (value != null) {
                    result.put(category.getTaxInclCompileCode(), value);
                }
            }
        }
        
        // 收集分部分项的费用代号（costCode）及其对应的合价（amount）
        collectCostCodeValuesForBoq(divisionTree, result);
        
        log.info("[buildVariableValueMapForStandaloneBoq] 变量值映射: {}", result);

        return result;
    }

    /**
     * 收集分部分项的费用代号及其对应的合价值
     * 遍历分部分项树，将每个节点的 costCode -> amount 添加到变量映射中
     * 
     * 注意：如果节点的 amount 为 null（尚未计算），则尝试从子节点汇总计算
     */
    private void collectCostCodeValuesForBoq(
            List<WbBoqDivisionRespVO> nodes,
            Map<String, BigDecimal> result) {
        if (nodes == null) return;
        
        for (WbBoqDivisionRespVO node : nodes) {
            // 递归处理子节点（先处理子节点，以便计算父节点的合价）
            if (node.getChildren() != null && !node.getChildren().isEmpty()) {
                collectCostCodeValuesForBoq(node.getChildren(), result);
            }
            
            // 如果节点有费用代号，则添加到映射
            String costCode = node.getCostCode();
            if (StrUtil.isNotBlank(costCode)) {
                BigDecimal amount = node.getAmount();
                
                // 如果 amount 为 null，尝试从子节点汇总计算
                if (amount == null && node.getChildren() != null && !node.getChildren().isEmpty()) {
                    amount = calculateNodeAmountFromChildren(node);
                }
                
                if (amount != null) {
                    // 费用代号可能重复，使用累加方式
                    result.merge(costCode, amount, BigDecimal::add);
                }
            }
        }
    }
    
    /**
     * 从子节点汇总计算节点的合价
     * 用于费用代号变量求值时，节点的 amount 尚未计算的情况
     */
    private BigDecimal calculateNodeAmountFromChildren(WbBoqDivisionRespVO node) {
        List<WbBoqDivisionRespVO> children = node.getChildren();
        if (children == null || children.isEmpty()) {
            return null;
        }
        
        BigDecimal total = BigDecimal.ZERO;
        for (WbBoqDivisionRespVO child : children) {
            BigDecimal childAmount = child.getAmount();
            // 如果子节点的 amount 也为 null，递归计算
            if (childAmount == null && child.getChildren() != null && !child.getChildren().isEmpty()) {
                childAmount = calculateNodeAmountFromChildren(child);
            }
            if (childAmount != null) {
                total = total.add(childAmount);
            }
        }
        return total;
    }

    private void addPriceToMapForBoq(Map<String, BigDecimal> priceMap, String priceField, 
            BigDecimal unitPrice, BigDecimal qty) {
        if (unitPrice == null) return;
        BigDecimal amount = unitPrice.multiply(qty);
        priceMap.merge(priceField, amount, BigDecimal::add);
    }

    private void collectNodesInRangeForBoq(
            List<WbBoqDivisionRespVO> nodes,
            java.util.Set<Long> selectedIds,
            java.util.Set<Long> result,
            boolean parentSelected) {
        if (nodes == null) return;
        
        for (WbBoqDivisionRespVO node : nodes) {
            boolean isSelected = selectedIds.contains(node.getId()) || parentSelected;
            if (isSelected) {
                result.add(node.getId());
            }
            if (node.getChildren() != null && !node.getChildren().isEmpty()) {
                collectNodesInRangeForBoq(node.getChildren(), selectedIds, result, isSelected);
            }
        }
    }

    /**
     * 收集范围内的定额节点ID及其工程量
     */
    private void collectQuotaNodeQtyMapForBoq(
            List<WbBoqDivisionRespVO> nodes,
            java.util.Map<Long, BigDecimal> quotaNodeQtyMap,
            boolean hasRange,
            java.util.Set<Long> nodesInRange,
            boolean parentInRange) {
        if (nodes == null) return;

        for (WbBoqDivisionRespVO node : nodes) {
            boolean inRange = !hasRange || parentInRange || nodesInRange.contains(node.getId());

            // 跳过统一取费节点，但递归处理其子节点
            if (WbBoqDivisionDO.NODE_TYPE_UNIFIED_FEE.equals(node.getNodeType())) {
                if (node.getChildren() != null && !node.getChildren().isEmpty()) {
                    collectQuotaNodeQtyMapForBoq(node.getChildren(), quotaNodeQtyMap, hasRange, nodesInRange, inRange);
                }
                continue;
            }

            // 收集定额节点及其工程量
            if (WbBoqDivisionDO.NODE_TYPE_QUOTA.equals(node.getNodeType()) && inRange) {
                BigDecimal qty = node.getQty() != null ? node.getQty() : BigDecimal.ONE;
                quotaNodeQtyMap.put(node.getId(), qty);
            }

            // 递归处理子节点
            if (node.getChildren() != null && !node.getChildren().isEmpty()) {
                collectQuotaNodeQtyMapForBoq(node.getChildren(), quotaNodeQtyMap, hasRange, nodesInRange, inRange);
            }
        }
    }

    /**
     * 收集范围内的统一取费节点（unified_fee）
     * 用于基数设置汇总值计算时，将统一取费涉及的子目工料机也纳入分类汇总
     */
    private void collectUnifiedFeeNodesForBoq(
            List<WbBoqDivisionRespVO> nodes,
            List<WbBoqDivisionRespVO> unifiedFeeNodes,
            boolean hasRange,
            java.util.Set<Long> nodesInRange,
            boolean parentInRange) {
        if (nodes == null) return;

        for (WbBoqDivisionRespVO node : nodes) {
            boolean inRange = !hasRange || parentInRange || nodesInRange.contains(node.getId());

            if (WbBoqDivisionDO.NODE_TYPE_UNIFIED_FEE.equals(node.getNodeType()) && inRange) {
                unifiedFeeNodes.add(node);
            }

            // 递归处理子节点
            if (node.getChildren() != null && !node.getChildren().isEmpty()) {
                collectUnifiedFeeNodesForBoq(node.getChildren(), unifiedFeeNodes, hasRange, nodesInRange, inRange);
            }
        }
    }

    /**
     * 将统一取费节点的子目工料机汇总到分类价格映射中
     *
     * 统一取费节点没有自己的 yhy_wb_boq_resource 数据，需要溯源到后台统一取费设置的子定额及其子目工料机：
     * unified_fee 节点 → attributes.sourceUnifiedFeeSettingId → 快照 WbUnifiedFeeSettingDO
     * → 子费用(child) → WbUnifiedFeeResourceDO → ResourceItemDO（获取价格和分类）
     */
    private void aggregateUnifiedFeeResourcesForBoq(
            List<WbBoqDivisionRespVO> unifiedFeeNodes,
            Long compileTreeId,
            Map<Long, Map<String, BigDecimal>> categoryPriceMap) {
        if (unifiedFeeNodes == null || unifiedFeeNodes.isEmpty()) return;

        for (WbBoqDivisionRespVO ufNode : unifiedFeeNodes) {
            // 通过 QuotaPriceCalculatorService 获取统一取费节点的分类合价
            // 该方法内部会：获取母定额列表 → 获取母定额分类合价 → 用calcBase公式计算%工料机合价 → 按categoryId汇总
            Map<Long, com.yhy.module.core.controller.admin.workbench.vo.price.CategoryPriceSum> ufCategorySums =
                quotaPriceCalculatorService.getUnifiedFeeCategorySums(ufNode.getId());

            if (ufCategorySums == null || ufCategorySums.isEmpty()) {
                log.info("[基数汇总-统一取费] 统一取费节点无分类合价, nodeId={}", ufNode.getId());
                continue;
            }

            log.info("[基数汇总-统一取费] nodeId={}, 分类合价={}", ufNode.getId(), ufCategorySums);

            // 将统一取费的分类合价合并到总的 categoryPriceMap
            for (Map.Entry<Long, com.yhy.module.core.controller.admin.workbench.vo.price.CategoryPriceSum> entry : ufCategorySums.entrySet()) {
                Long categoryId = entry.getKey();
                com.yhy.module.core.controller.admin.workbench.vo.price.CategoryPriceSum sum = entry.getValue();

                Map<String, BigDecimal> priceMap = categoryPriceMap.computeIfAbsent(categoryId, k -> new java.util.HashMap<>());
                priceMap.merge("tax_excl_base_price", sum.getTaxExclBaseSum() != null ? sum.getTaxExclBaseSum() : BigDecimal.ZERO, BigDecimal::add);
                priceMap.merge("tax_incl_base_price", sum.getTaxInclBaseSum() != null ? sum.getTaxInclBaseSum() : BigDecimal.ZERO, BigDecimal::add);
                priceMap.merge("tax_excl_compile_price", sum.getTaxExclCompileSum() != null ? sum.getTaxExclCompileSum() : BigDecimal.ZERO, BigDecimal::add);
                priceMap.merge("tax_incl_compile_price", sum.getTaxInclCompileSum() != null ? sum.getTaxInclCompileSum() : BigDecimal.ZERO, BigDecimal::add);
            }
        }
    }

    /**
     * 计算公式
     */
    private BigDecimal evaluateFormula(String formula, Map<String, BigDecimal> variableValueMap) {
        if (StrUtil.isBlank(formula)) {
            return null;
        }
        
        try {
            // 替换公式中的变量为对应的值
            String expression = formula;
            for (Map.Entry<String, BigDecimal> entry : variableValueMap.entrySet()) {
                String code = entry.getKey();
                BigDecimal value = entry.getValue();
                expression = expression.replaceAll("\\b" + code + "\\b", value.toPlainString());
            }
            
            // 将未知变量替换为0
            expression = expression.replaceAll("[A-Za-z][A-Za-z0-9_]*", "0");
            
            log.debug("[evaluateFormula] 公式: {}, 表达式: {}", formula, expression);
            
            // 使用 JavaScript 引擎计算表达式
            javax.script.ScriptEngineManager manager = new javax.script.ScriptEngineManager();
            javax.script.ScriptEngine engine = manager.getEngineByName("JavaScript");
            if (engine == null) {
                engine = manager.getEngineByName("nashorn");
            }
            if (engine == null) {
                log.warn("[evaluateFormula] 无法获取脚本引擎，无法计算公式: {}", formula);
                return null;
            }
            
            Object result = engine.eval(expression);
            if (result instanceof Number) {
                return new BigDecimal(result.toString()).setScale(3, java.math.RoundingMode.HALF_UP);
            }
            return null;
        } catch (Exception e) {
            log.warn("[evaluateFormula] 计算公式失败，公式: {}, 错误: {}", formula, e.getMessage());
            return null;
        }
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    public void deleteNode(Long id) {
        // 1. 校验存在
        WbBoqDivisionDO node = validateNodeExists(id);

        // 2. 禁止删除根目录节点
        if (WbBoqDivisionDO.NODE_TYPE_ROOT.equals(node.getNodeType())) {
            throw exception(WB_BOQ_DIVISION_ROOT_CANNOT_DELETE);
        }

        // 3. 校验是否有子节点
        Long childCount = wbBoqDivisionMapper.selectCountByParentId(id);
        if (childCount > 0) {
            throw exception(WB_BOQ_DIVISION_HAS_CHILDREN);
        }

        // 4. 如果是定额节点，删除关联的工料机
        if (WbBoqDivisionDO.NODE_TYPE_QUOTA.equals(node.getNodeType())) {
            wbBoqResourceMapper.deleteByDivisionId(id);
        }

        // 5. 删除节点
        wbBoqDivisionMapper.deleteById(id);
    }

    @Override
    public WbBoqDivisionRespVO getNode(Long id) {
        WbBoqDivisionDO node = wbBoqDivisionMapper.selectById(id);
        if (node == null) {
            return null;
        }
        return WbBoqDivisionConvert.INSTANCE.convert(node);
    }

    @Override
    public List<WbBoqDivisionRespVO> getTree(Long compileTreeId) {
        List<WbBoqDivisionDO> list = wbBoqDivisionMapper.selectListByCompileTreeId(compileTreeId);
        if (CollUtil.isEmpty(list)) {
            return Collections.emptyList();
        }
        
        // 填充定额节点的sourceCatalogItemId和sourceCatalogPath（用于取费章节过滤）
        List<WbBoqDivisionRespVO> voList = list.stream()
                .map(WbBoqDivisionConvert.INSTANCE::convert)
                .collect(Collectors.toList());
        
        // 批量查询定额项的catalogItemId
        List<Long> quotaItemIds = list.stream()
                .filter(node -> WbBoqDivisionDO.NODE_TYPE_QUOTA.equals(node.getNodeType()))
                .filter(node -> node.getSourceQuotaItemId() != null)
                .map(WbBoqDivisionDO::getSourceQuotaItemId)
                .distinct()
                .collect(Collectors.toList());
        Map<Long, Long> quotaItemToCatalogMap = new java.util.HashMap<>();
        if (CollUtil.isNotEmpty(quotaItemIds)) {
            List<com.yhy.module.core.dal.dataobject.quota.QuotaItemDO> quotaItems = 
                    quotaItemMapper.selectBatchIds(quotaItemIds);
            for (com.yhy.module.core.dal.dataobject.quota.QuotaItemDO item : quotaItems) {
                if (item.getCatalogItemId() != null) {
                    quotaItemToCatalogMap.put(item.getId(), item.getCatalogItemId());
                }
            }
        }
        
        // 批量查询定额子目录的path
        List<Long> catalogItemIds = new java.util.ArrayList<>(quotaItemToCatalogMap.values());
        Map<Long, String[]> catalogItemToPathMap = new java.util.HashMap<>();
        if (CollUtil.isNotEmpty(catalogItemIds)) {
            List<com.yhy.module.core.dal.dataobject.quota.QuotaCatalogTreeDO> catalogTrees = 
                    quotaCatalogTreeMapper.selectBatchIds(catalogItemIds);
            for (com.yhy.module.core.dal.dataobject.quota.QuotaCatalogTreeDO tree : catalogTrees) {
                if (tree.getPath() != null) {
                    catalogItemToPathMap.put(tree.getId(), tree.getPath());
                }
            }
        }
        
        // 设置定额节点的sourceCatalogItemId和sourceCatalogPath
        Map<Long, WbBoqDivisionDO> doMap = list.stream()
                .collect(Collectors.toMap(WbBoqDivisionDO::getId, node -> node));
        for (WbBoqDivisionRespVO vo : voList) {
            if (WbBoqDivisionDO.NODE_TYPE_QUOTA.equals(vo.getNodeType())) {
                WbBoqDivisionDO doDO = doMap.get(vo.getId());
                if (doDO != null && doDO.getSourceQuotaItemId() != null) {
                    Long catalogItemId = quotaItemToCatalogMap.get(doDO.getSourceQuotaItemId());
                    vo.setSourceCatalogItemId(catalogItemId);
                    if (catalogItemId != null) {
                        String[] catalogPath = catalogItemToPathMap.get(catalogItemId);
                        vo.setSourceCatalogPath(catalogPath);
                        log.info("[getTree] 定额节点: {}, catalogItemId: {}, catalogPath: {}", 
                                vo.getName(), catalogItemId, java.util.Arrays.toString(catalogPath));
                    }
                }
            }
        }
        
        return buildTreeFromVOList(voList);
    }

    @Override
    public List<WbBoqDivisionRespVO> getTree(Long compileTreeId, Boolean excludeZeroQtyBoq) {
        List<WbBoqDivisionRespVO> tree = getTree(compileTreeId);
        if (Boolean.TRUE.equals(excludeZeroQtyBoq)) {
            filterZeroQtyBoqNodes(tree);
        }
        return tree;
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    public void swapSort(WbBoqDivisionSwapSortReqVO swapReqVO) {
        // 1. 校验两个节点存在
        WbBoqDivisionDO node1 = validateNodeExists(swapReqVO.getId1());
        WbBoqDivisionDO node2 = validateNodeExists(swapReqVO.getId2());

        // 2. 校验是否同级
        if (!Objects.equals(node1.getParentId(), node2.getParentId())) {
            throw exception(WB_BOQ_DIVISION_NOT_SAME_LEVEL);
        }

        // 3. 交换排序号
        Integer tempSortOrder = node1.getSortOrder();
        node1.setSortOrder(node2.getSortOrder());
        node2.setSortOrder(tempSortOrder);

        wbBoqDivisionMapper.updateById(node1);
        wbBoqDivisionMapper.updateById(node2);
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    public void deleteByCompileTreeId(Long compileTreeId) {
        // 1. 查询所有定额节点
        List<WbBoqDivisionDO> quotaNodes = wbBoqDivisionMapper.selectListByNodeType(
                compileTreeId, WbBoqDivisionDO.NODE_TYPE_QUOTA);

        // 2. 删除所有定额节点关联的工料机
        for (WbBoqDivisionDO quotaNode : quotaNodes) {
            wbBoqResourceMapper.deleteByDivisionId(quotaNode.getId());
        }

        // 3. 删除所有分部分项节点
        List<WbBoqDivisionDO> allNodes = wbBoqDivisionMapper.selectListByCompileTreeId(compileTreeId);
        for (WbBoqDivisionDO node : allNodes) {
            wbBoqDivisionMapper.deleteById(node.getId());
        }
    }

    @Override
    public List<WbBoqDivisionRespVO> getTreeWithPrice(Long compileTreeId) {
        return getTreeWithPrice(compileTreeId, false);
    }

    @Override
    public List<WbBoqDivisionRespVO> getTreeWithPrice(Long compileTreeId, boolean debug) {
        // 1. 获取所有节点
        List<WbBoqDivisionDO> list = wbBoqDivisionMapper.selectListByCompileTreeId(compileTreeId);
        if (CollUtil.isEmpty(list)) {
            return Collections.emptyList();
        }

        // 2. 转换为VO
        List<WbBoqDivisionRespVO> voList = list.stream()
                .map(WbBoqDivisionConvert.INSTANCE::convert)
                .collect(Collectors.toList());

        // 3. 获取所有定额节点ID和sourceQuotaItemId
        List<Long> quotaDivisionIds = list.stream()
                .filter(node -> WbBoqDivisionDO.NODE_TYPE_QUOTA.equals(node.getNodeType()))
                .map(WbBoqDivisionDO::getId)
                .collect(Collectors.toList());

        // 3.1 批量查询定额项的catalogItemId，用于取费章节过滤
        List<Long> quotaItemIds = list.stream()
                .filter(node -> WbBoqDivisionDO.NODE_TYPE_QUOTA.equals(node.getNodeType()))
                .filter(node -> node.getSourceQuotaItemId() != null)
                .map(WbBoqDivisionDO::getSourceQuotaItemId)
                .distinct()
                .collect(Collectors.toList());
        Map<Long, Long> quotaItemToCatalogMap = new java.util.HashMap<>();
        if (CollUtil.isNotEmpty(quotaItemIds)) {
            List<com.yhy.module.core.dal.dataobject.quota.QuotaItemDO> quotaItems = 
                    quotaItemMapper.selectBatchIds(quotaItemIds);
            for (com.yhy.module.core.dal.dataobject.quota.QuotaItemDO item : quotaItems) {
                if (item.getCatalogItemId() != null) {
                    quotaItemToCatalogMap.put(item.getId(), item.getCatalogItemId());
                }
            }
        }
        
        // 3.2 批量查询定额子目录的path，用于取费章节过滤（支持父节点匹配）
        List<Long> catalogItemIds = new java.util.ArrayList<>(quotaItemToCatalogMap.values());
        Map<Long, String[]> catalogItemToPathMap = new java.util.HashMap<>();
        if (CollUtil.isNotEmpty(catalogItemIds)) {
            List<com.yhy.module.core.dal.dataobject.quota.QuotaCatalogTreeDO> catalogTrees = 
                    quotaCatalogTreeMapper.selectBatchIds(catalogItemIds);
            for (com.yhy.module.core.dal.dataobject.quota.QuotaCatalogTreeDO tree : catalogTrees) {
                if (tree.getPath() != null) {
                    catalogItemToPathMap.put(tree.getId(), tree.getPath());
                }
            }
        }

        // 4. 批量计算定额单价
        Map<Long, QuotaUnitPriceResult> priceResultMap = new java.util.HashMap<>();
        if (CollUtil.isNotEmpty(quotaDivisionIds)) {
            log.debug("[getTreeWithPrice] 开始计算定额单价 - 定额节点数量={}", quotaDivisionIds.size());
            
            // 构建 divisionId -> quotaItemId 的映射
            Map<Long, Long> divisionQuotaMap = list.stream()
                    .filter(node -> WbBoqDivisionDO.NODE_TYPE_QUOTA.equals(node.getNodeType()))
                    .filter(node -> node.getSourceQuotaItemId() != null)
                    .collect(Collectors.toMap(WbBoqDivisionDO::getId, WbBoqDivisionDO::getSourceQuotaItemId));

            // 逐个计算定额单价（后续可优化为批量计算）
            for (Long divisionId : quotaDivisionIds) {
                Long quotaItemId = divisionQuotaMap.get(divisionId);
                if (quotaItemId != null) {
                    QuotaUnitPriceResult result = quotaPriceCalculatorService
                            .calculateQuotaUnitPrice(quotaItemId, divisionId, debug);
                    priceResultMap.put(divisionId, result);
                }
            }
            
            log.debug("[getTreeWithPrice] 定额单价计算完成 - 成功数量={}", priceResultMap.size());
        }

        // 5. 设置定额节点的单价和合价
        Map<Long, WbBoqDivisionDO> doMap = list.stream()
                .collect(Collectors.toMap(WbBoqDivisionDO::getId, node -> node));
        
        for (WbBoqDivisionRespVO vo : voList) {
            if (WbBoqDivisionDO.NODE_TYPE_QUOTA.equals(vo.getNodeType())) {
                // 设置sourceCatalogItemId和sourceCatalogPath（用于取费章节过滤）
                WbBoqDivisionDO doDO = doMap.get(vo.getId());
                if (doDO != null && doDO.getSourceQuotaItemId() != null) {
                    Long catalogItemId = quotaItemToCatalogMap.get(doDO.getSourceQuotaItemId());
                    vo.setSourceCatalogItemId(catalogItemId);
                    // 设置子目录路径（包含所有祖先节点ID）
                    if (catalogItemId != null) {
                        String[] catalogPath = catalogItemToPathMap.get(catalogItemId);
                        vo.setSourceCatalogPath(catalogPath);
                    }
                }
                
                QuotaUnitPriceResult priceResult = priceResultMap.get(vo.getId());
                if (priceResult != null && priceResult.getSuccess()) {
                    vo.setUnitPrice(priceResult.getUnitPrice());
                    
                    // 计算合价 = 工程量 × 单价
                    if (doDO != null && doDO.getQty() != null) {
                        BigDecimal amount = doDO.getQty().multiply(priceResult.getUnitPrice());
                        vo.setAmount(amount);
                    }
                    
                    // 如果是调试模式，设置调试信息
                    if (debug && priceResult.getFeeItemDetails() != null) {
                        // 可以将调试信息存储到VO的扩展字段中
                        // vo.setPriceDebugInfo(priceResult);
                    }
                } else {
                    // 计算失败，设置为0
                    vo.setUnitPrice(BigDecimal.ZERO);
                    vo.setAmount(BigDecimal.ZERO);
                    if (priceResult != null) {
                        log.warn("[getTreeWithPrice] 定额单价计算失败 - divisionId={}, error={}", 
                                vo.getId(), priceResult.getErrorMessage());
                    }
                }
            } else if ("unified_fee".equals(vo.getNodeType())) {
                // 统一取费节点单价计算
                QuotaUnitPriceResult priceResult = quotaPriceCalculatorService
                        .calculateUnifiedFeeParentPrice(vo.getId(), debug);
                if (priceResult != null && Boolean.TRUE.equals(priceResult.getSuccess())) {
                    vo.setUnitPrice(priceResult.getUnitPrice());
                    
                    // 统一取费节点的工程量默认为1
                    WbBoqDivisionDO doDO = doMap.get(vo.getId());
                    BigDecimal qty = (doDO != null && doDO.getQty() != null) ? doDO.getQty() : BigDecimal.ONE;
                    BigDecimal amount = qty.multiply(priceResult.getUnitPrice());
                    vo.setAmount(amount);
                } else {
                    vo.setUnitPrice(BigDecimal.ZERO);
                    vo.setAmount(BigDecimal.ZERO);
                    if (priceResult != null) {
                        log.warn("[getTreeWithPrice] 统一取费单价计算失败 - divisionId={}, error={}", 
                                vo.getId(), priceResult.getErrorMessage());
                    }
                }
            }
        }

        // 6. 构建父子关系（提前到这里，以便后续计算能使用 children）
        Map<Long, List<WbBoqDivisionRespVO>> parentIdMap = voList.stream()
                .filter(vo -> vo.getParentId() != null)
                .collect(Collectors.groupingBy(WbBoqDivisionRespVO::getParentId));

        // 对子节点按sortOrder排序
        voList.forEach(vo -> {
            List<WbBoqDivisionRespVO> children = parentIdMap.get(vo.getId());
            if (children != null) {
                children.sort((a, b) -> Integer.compare(
                        a.getSortOrder() != null ? a.getSortOrder() : 0,
                        b.getSortOrder() != null ? b.getSortOrder() : 0));
            }
            vo.setChildren(children);
        });

        // 7. 汇总清单和分部的合价
        calculateParentAmounts(voList, parentIdMap);

        // 7.5 实时计算单独清单的工程量（无子节点的 boq 类型节点）
        // 在构建父子关系和汇总合价之后，以便费用代号变量能正确求值
        List<WbBoqDivisionRespVO> standaloneBoqList = voList.stream()
                .filter(vo -> WbBoqDivisionDO.NODE_TYPE_BOQ.equals(vo.getNodeType()))
                .filter(vo -> CollUtil.isEmpty(vo.getChildren())) // 无子节点
                .filter(vo -> {
                    // 检查是否有基数公式
                    Map<String, Object> attributes = vo.getAttributes();
                    if (attributes == null) return false;
                    Object baseNumberObj = attributes.get("baseNumber");
                    if (!(baseNumberObj instanceof Map)) return false;
                    Map<String, Object> baseNumber = (Map<String, Object>) baseNumberObj;
                    String formula = (String) baseNumber.get("formula");
                    return StrUtil.isNotBlank(formula);
                })
                .collect(Collectors.toList());
        
        if (CollUtil.isNotEmpty(standaloneBoqList)) {
            log.info("[getTreeWithPrice] 发现 {} 个单独清单需要计算工程量", standaloneBoqList.size());
            // 获取根节点列表（已构建父子关系）
            List<WbBoqDivisionRespVO> treeWithChildren = voList.stream()
                    .filter(vo -> vo.getParentId() == null)
                    .collect(Collectors.toList());
            for (WbBoqDivisionRespVO vo : standaloneBoqList) {
                WbBoqDivisionDO doDO = doMap.get(vo.getId());
                if (doDO != null) {
                    try {
                        // 传入已构建父子关系的树数据，以便费用代号变量能正确求值
                        BigDecimal calculatedQty = calculateStandaloneBoqQty(compileTreeId, doDO, treeWithChildren);
                        if (calculatedQty != null) {
                            // 更新数据库
                            doDO.setQty(calculatedQty);
                            wbBoqDivisionMapper.updateById(doDO);
                            // 更新VO（用于立即显示）
                            vo.setQty(calculatedQty);
                            log.info("[getTreeWithPrice] 单独清单 {} 工程量计算完成: {}", vo.getId(), calculatedQty);
                        }
                    } catch (Exception e) {
                        log.warn("[getTreeWithPrice] 单独清单 {} 工程量计算失败: {}", vo.getId(), e.getMessage());
                    }
                }
            }
        }

        // 8. 返回根节点（分部节点）
        return voList.stream()
                .filter(vo -> vo.getParentId() == null)
                .collect(Collectors.toList());
    }

    /**
     * 递归计算父节点的合价和单价
     * - 定额/统一取费：合价已在前面步骤计算完成
     * - 清单合价 = 子定额合价之和（含统一取费等）
     * - 清单单价 = 清单合价 / 清单工程量（无工程量或为0时单价为空）
     * - 分部合价 = 子节点（清单或子分部）合价之和
     * - 根节点合价 = 子分部合价之和
     * 
     * 使用递归方式从叶子节点向上汇总，支持多级分部结构
     */
    private void calculateParentAmounts(List<WbBoqDivisionRespVO> voList, 
            Map<Long, List<WbBoqDivisionRespVO>> parentIdMap) {
        // 找到根节点，从根节点开始递归计算
        List<WbBoqDivisionRespVO> rootNodes = voList.stream()
                .filter(vo -> vo.getParentId() == null)
                .collect(Collectors.toList());
        
        for (WbBoqDivisionRespVO root : rootNodes) {
            calculateNodeAmount(root);
        }
    }
    
    /**
     * 递归计算单个节点的合价（深度优先，先计算子节点再计算当前节点）
     * 直接使用 node.getChildren() 获取子节点，因为父子关系已在之前设置
     * 合价保留2位小数，四舍五入
     */
    private BigDecimal calculateNodeAmount(WbBoqDivisionRespVO node) {
        String nodeType = node.getNodeType();
        List<WbBoqDivisionRespVO> children = node.getChildren();
        
        // 定额和统一取费节点：合价已计算，保留2位小数后返回
        if (WbBoqDivisionDO.NODE_TYPE_QUOTA.equals(nodeType) 
                || WbBoqDivisionDO.NODE_TYPE_UNIFIED_FEE.equals(nodeType)) {
            BigDecimal amount = node.getAmount() != null ? node.getAmount() : BigDecimal.ZERO;
            amount = amount.setScale(2, java.math.RoundingMode.HALF_UP);
            node.setAmount(amount);
            // 定额单价保留2位小数
            if (node.getUnitPrice() != null) {
                node.setUnitPrice(node.getUnitPrice().setScale(2, java.math.RoundingMode.HALF_UP));
            }
            // 定额工程量保留3位小数
            if (node.getQty() != null) {
                node.setQty(node.getQty().setScale(3, java.math.RoundingMode.HALF_UP));
            }
            return amount;
        }
        
        // 清单节点
        if (WbBoqDivisionDO.NODE_TYPE_BOQ.equals(nodeType)) {
            // 有子节点的清单：合价 = 子定额合价之和
            if (CollUtil.isNotEmpty(children)) {
                BigDecimal totalAmount = BigDecimal.ZERO;
                for (WbBoqDivisionRespVO child : children) {
                    BigDecimal childAmount = calculateNodeAmount(child);
                    totalAmount = totalAmount.add(childAmount);
                }
                // 合价保留2位小数
                totalAmount = totalAmount.setScale(2, java.math.RoundingMode.HALF_UP);
                node.setAmount(totalAmount);
                
                // 清单工程量保留3位小数
                BigDecimal qty = node.getQty();
                if (qty != null) {
                    qty = qty.setScale(3, java.math.RoundingMode.HALF_UP);
                    node.setQty(qty);
                }
                // 清单单价 = 合价 / 工程量（无工程量或为0时单价为空），单价保留2位小数
                if (qty != null && qty.compareTo(BigDecimal.ZERO) != 0) {
                    BigDecimal unitPrice = totalAmount.divide(qty, 10, java.math.RoundingMode.HALF_UP);
                    unitPrice = unitPrice.setScale(2, java.math.RoundingMode.HALF_UP);
                    node.setUnitPrice(unitPrice);
                } else {
                    node.setUnitPrice(null);
                }
                return totalAmount;
            }
            
            // 单独清单（无子节点）：合价 = 工程量 × 费率%
            // 工程量已在保存时由基数公式计算并存储
            BigDecimal qty = node.getQty();
            BigDecimal rate = node.getRate();
            if (qty != null) {
                qty = qty.setScale(3, java.math.RoundingMode.HALF_UP);
                node.setQty(qty);
            }
            if (qty != null && rate != null) {
                // 合价 = 工程量 × 费率%（费率是百分比，需要除以100）
                BigDecimal amount = qty.multiply(rate).divide(new BigDecimal("100"), 2, java.math.RoundingMode.HALF_UP);
                node.setAmount(amount);
                return amount;
            }
            BigDecimal amount = node.getAmount() != null ? node.getAmount() : BigDecimal.ZERO;
            amount = amount.setScale(2, java.math.RoundingMode.HALF_UP);
            node.setAmount(amount);
            return amount;
        }
        
        // 分部节点或根节点：合价 = 子节点（清单或子分部）合价之和
        if (WbBoqDivisionDO.NODE_TYPE_DIVISION.equals(nodeType) 
                || WbBoqDivisionDO.NODE_TYPE_ROOT.equals(nodeType)) {
            if (CollUtil.isNotEmpty(children)) {
                BigDecimal totalAmount = BigDecimal.ZERO;
                for (WbBoqDivisionRespVO child : children) {
                    BigDecimal childAmount = calculateNodeAmount(child);
                    totalAmount = totalAmount.add(childAmount);
                }
                // 合价保留2位小数
                totalAmount = totalAmount.setScale(2, java.math.RoundingMode.HALF_UP);
                node.setAmount(totalAmount);
                return totalAmount;
            }
            BigDecimal amount = node.getAmount() != null ? node.getAmount() : BigDecimal.ZERO;
            amount = amount.setScale(2, java.math.RoundingMode.HALF_UP);
            node.setAmount(amount);
            return amount;
        }
        
        // 其他类型节点，返回已有合价（保留2位小数）
        BigDecimal amount = node.getAmount() != null ? node.getAmount() : BigDecimal.ZERO;
        amount = amount.setScale(2, java.math.RoundingMode.HALF_UP);
        node.setAmount(amount);
        return amount;
    }

    /**
     * 校验编制模式树节点是否为单位工程
     */
    private void validateCompileTreeIsUnit(Long compileTreeId) {
        WbCompileTreeDO compileTree = wbCompileTreeMapper.selectById(compileTreeId);
        if (compileTree == null || !WbCompileTreeDO.NODE_TYPE_UNIT.equals(compileTree.getNodeType())) {
            throw exception(WB_BOQ_DIVISION_COMPILE_TREE_NOT_UNIT);
        }
    }

    /**
     * 校验节点类型
     */
    private void validateNodeType(String nodeType) {
        if (!WbBoqDivisionDO.NODE_TYPE_ROOT.equals(nodeType)
                && !WbBoqDivisionDO.NODE_TYPE_DIVISION.equals(nodeType)
                && !WbBoqDivisionDO.NODE_TYPE_BOQ.equals(nodeType)
                && !WbBoqDivisionDO.NODE_TYPE_QUOTA.equals(nodeType)) {
            throw exception(WB_BOQ_DIVISION_NODE_TYPE_INVALID);
        }
    }

    /**
     * 校验父节点存在
     */
    private WbBoqDivisionDO validateParentExists(Long parentId) {
        WbBoqDivisionDO parent = wbBoqDivisionMapper.selectById(parentId);
        if (parent == null) {
            throw exception(WB_BOQ_DIVISION_PARENT_NOT_EXISTS);
        }
        return parent;
    }

    /**
     * 校验层级关系
     */
    private void validateHierarchy(String nodeType, WbBoqDivisionDO parent) {
        // 定额是最后一级，不能在定额下创建普通子节点（但允许unified_fee节点）
        if (parent != null && WbBoqDivisionDO.NODE_TYPE_QUOTA.equals(parent.getNodeType())) {
            if (!WbBoqDivisionDO.NODE_TYPE_UNIFIED_FEE.equals(nodeType)) {
                throw exception0(400, "定额是最后一级节点，不能在定额下创建子节点");
            }
        }
        
        if (WbBoqDivisionDO.NODE_TYPE_ROOT.equals(nodeType)) {
            // 根目录只能作为顶级节点（无父节点）
            if (parent != null) {
                throw exception(WB_BOQ_DIVISION_DIVISION_MUST_ROOT);
            }
        } else if (WbBoqDivisionDO.NODE_TYPE_DIVISION.equals(nodeType)) {
            // 分部可以在根目录或其他分部下创建（支持子分部嵌套），或作为顶级节点（兼容旧数据）
            if (parent != null 
                    && !WbBoqDivisionDO.NODE_TYPE_ROOT.equals(parent.getNodeType())
                    && !WbBoqDivisionDO.NODE_TYPE_DIVISION.equals(parent.getNodeType())) {
                throw exception(WB_BOQ_DIVISION_DIVISION_MUST_ROOT);
            }
        } else if (WbBoqDivisionDO.NODE_TYPE_BOQ.equals(nodeType)) {
            // 清单可以在根目录或分部下创建
            if (parent == null || 
                    (!WbBoqDivisionDO.NODE_TYPE_ROOT.equals(parent.getNodeType()) 
                     && !WbBoqDivisionDO.NODE_TYPE_DIVISION.equals(parent.getNodeType()))) {
                throw exception(WB_BOQ_DIVISION_BOQ_PARENT_MUST_DIVISION);
            }
        } else if (WbBoqDivisionDO.NODE_TYPE_QUOTA.equals(nodeType)) {
            // 定额只能在清单下创建
            if (parent == null || !WbBoqDivisionDO.NODE_TYPE_BOQ.equals(parent.getNodeType())) {
                throw exception(WB_BOQ_DIVISION_QUOTA_PARENT_MUST_BOQ);
            }
        }
    }

    /**
     * 校验节点存在
     */
    private WbBoqDivisionDO validateNodeExists(Long id) {
        WbBoqDivisionDO node = wbBoqDivisionMapper.selectById(id);
        if (node == null) {
            throw exception(WB_BOQ_DIVISION_NOT_EXISTS);
        }
        return node;
    }

    /**
     * 校验清单编码在同一单位工程下唯一
     * @param compileTreeId 单位工程ID
     * @param code 清单编码
     * @param excludeId 排除的节点ID（更新时排除自身）
     */
    private void validateBoqCodeUnique(Long compileTreeId, String code, Long excludeId) {
        Long count = wbBoqDivisionMapper.countBoqByCodeAndCompileTreeId(compileTreeId, code, excludeId);
        if (count > 0) {
            throw exception0(400, "清单编码[" + code + "]在当前单位工程下已存在，请使用其他编码");
        }
    }

    /**
     * 构建路径
     */
    private String[] buildPath(WbBoqDivisionDO parent) {
        if (parent == null || parent.getPath() == null) {
            return new String[]{};
        }
        return parent.getPath();
    }

    /**
     * 更新路径（包含自身ID）
     */
    private void updatePathWithSelfId(WbBoqDivisionDO node) {
        List<String> pathList = node.getPath() != null
                ? new ArrayList<>(Arrays.asList(node.getPath()))
                : new ArrayList<>();
        pathList.add(String.valueOf(node.getId()));
        node.setPath(pathList.toArray(new String[0]));
        wbBoqDivisionMapper.updateById(node);
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long copyFromGuide(Long compileTreeId, Long parentId, Long boqSubItemId, Long sourceBoqItemTreeId) {
        // 1. 校验单位工程节点
        validateCompileTreeIsUnit(compileTreeId);

        // 2. 校验父节点必须是分部
        WbBoqDivisionDO parent = validateParentExists(parentId);
        if (!WbBoqDivisionDO.NODE_TYPE_DIVISION.equals(parent.getNodeType())) {
            throw exception(WB_BOQ_DIVISION_BOQ_PARENT_MUST_DIVISION);
        }

        // 3. 获取清单子目信息（忽略租户，标准库数据）
        com.yhy.module.core.dal.dataobject.boq.BoqSubItemDO subItem = 
                cn.iocoder.yudao.framework.tenant.core.util.TenantUtils.executeIgnore(
                        () -> boqSubItemMapper.selectById(boqSubItemId));
        if (subItem == null) {
            throw exception0(400, "清单子目不存在");
        }

        // 4. 创建清单（boq）节点
        WbBoqDivisionSaveReqVO boqReqVO = new WbBoqDivisionSaveReqVO();
        boqReqVO.setCompileTreeId(compileTreeId);
        boqReqVO.setParentId(parentId);
        boqReqVO.setNodeType(WbBoqDivisionDO.NODE_TYPE_BOQ);
        boqReqVO.setCode(subItem.getCode());
        boqReqVO.setName(subItem.getName());
        boqReqVO.setUnit(subItem.getUnit());
        boqReqVO.setFeature(subItem.getFeatures());  // 复制清单子目的项目特征
        boqReqVO.setSourceBoqItemTreeId(sourceBoqItemTreeId);
        Long newBoqId = createNode(boqReqVO);
        log.info("[copyFromGuide] 创建清单节点, boqSubItemId={}, newBoqId={}", boqSubItemId, newBoqId);

        // 5. 加载指引树中的定额节点并逐个创建（忽略租户，标准库数据）
        List<com.yhy.module.core.dal.dataobject.boq.BoqGuideTreeDO> guideNodes = 
                cn.iocoder.yudao.framework.tenant.core.util.TenantUtils.executeIgnore(
                        () -> boqGuideTreeMapper.selectListByBoqSubItemId(boqSubItemId));
        int quotaCount = 0;
        for (com.yhy.module.core.dal.dataobject.boq.BoqGuideTreeDO guideNode : guideNodes) {
            if (!"quota".equals(guideNode.getNodeType()) || guideNode.getQuotaCatalogItemId() == null) {
                continue;
            }
            try {
                WbBoqDivisionSaveReqVO quotaReqVO = new WbBoqDivisionSaveReqVO();
                quotaReqVO.setCompileTreeId(compileTreeId);
                quotaReqVO.setParentId(newBoqId);
                quotaReqVO.setNodeType(WbBoqDivisionDO.NODE_TYPE_QUOTA);
                quotaReqVO.setCode(guideNode.getCode());
                quotaReqVO.setName(guideNode.getName());
                quotaReqVO.setUnit(guideNode.getUnit());
                quotaReqVO.setSourceQuotaItemId(guideNode.getQuotaCatalogItemId());
                createNode(quotaReqVO);
                quotaCount++;
            } catch (Exception e) {
                log.warn("[copyFromGuide] 创建定额节点失败, guideNodeId={}, error={}", guideNode.getId(), e.getMessage());
            }
        }
        log.info("[copyFromGuide] 完成, newBoqId={}, 定额数={}", newBoqId, quotaCount);
        return newBoqId;
    }

    /**
     * 构建树形结构
     */
    private List<WbBoqDivisionRespVO> buildTree(List<WbBoqDivisionDO> list) {
        if (CollUtil.isEmpty(list)) {
            return Collections.emptyList();
        }

        // 转换为VO
        List<WbBoqDivisionRespVO> voList = list.stream()
                .map(WbBoqDivisionConvert.INSTANCE::convert)
                .collect(Collectors.toList());

        return buildTreeFromVOList(voList);
    }

    /**
     * 从VO列表构建树形结构
     */
    private List<WbBoqDivisionRespVO> buildTreeFromVOList(List<WbBoqDivisionRespVO> voList) {
        if (CollUtil.isEmpty(voList)) {
            return Collections.emptyList();
        }

        // 构建父子关系
        Map<Long, List<WbBoqDivisionRespVO>> parentIdMap = voList.stream()
                .filter(vo -> vo.getParentId() != null)
                .collect(Collectors.groupingBy(WbBoqDivisionRespVO::getParentId));

        // 对子节点按sortOrder排序
        voList.forEach(vo -> {
            List<WbBoqDivisionRespVO> children = parentIdMap.get(vo.getId());
            if (children != null) {
                children.sort((a, b) -> Integer.compare(
                        a.getSortOrder() != null ? a.getSortOrder() : 0,
                        b.getSortOrder() != null ? b.getSortOrder() : 0));
            }
            vo.setChildren(children);
        });

        // 返回根节点（分部节点）
        return voList.stream()
                .filter(vo -> vo.getParentId() == null)
                .collect(Collectors.toList());
    }

    /**
     * 获取节点类型的排序优先级
     */
    private int getNodeTypeOrder(String nodeType) {
        if (WbBoqDivisionDO.NODE_TYPE_DIVISION.equals(nodeType)) {
            return 1;
        } else if (WbBoqDivisionDO.NODE_TYPE_BOQ.equals(nodeType)) {
            return 2;
        } else if (WbBoqDivisionDO.NODE_TYPE_QUOTA.equals(nodeType)) {
            return 3;
        }
        return 0;
    }

    /**
     * 从定额基价复制工料机数据到分部分项工料机表
     *
     * @param divisionId 分部分项定额节点ID
     * @param tenantId 租户ID
     * @param quotaItemId 定额基价ID
     */
    private void copyQuotaResources(Long divisionId, Long tenantId, Long quotaItemId) {
        // 1. 查询定额基价的工料机组成
        List<QuotaResourceDO> quotaResources = quotaResourceMapper.selectListByQuotaItemId(quotaItemId);
        if (CollUtil.isEmpty(quotaResources)) {
            log.info("定额基价 {} 没有工料机组成数据", quotaItemId);
            return;
        }

        // 2. 批量查询资源项信息
        List<Long> resourceItemIds = quotaResources.stream()
                .map(QuotaResourceDO::getResourceItemId)
                .collect(Collectors.toList());
        Map<Long, ResourceItemDO> resourceItemMap = resourceItemMapper.selectBatchIds(resourceItemIds)
                .stream()
                .collect(Collectors.toMap(ResourceItemDO::getId, r -> r));

        // 3. 转换并插入分部分项工料机表
        int sortOrder = 0;
        int childCount = 0;
        for (QuotaResourceDO quotaResource : quotaResources) {
            ResourceItemDO resourceItem = resourceItemMap.get(quotaResource.getResourceItemId());
            
            // 判断是否为复合工料机
            boolean isMerged = resourceItem != null && resourceItem.getIsMerged() != null && resourceItem.getIsMerged() == 1;
            
            WbBoqResourceDO wbResource = WbBoqResourceDO.builder()
                    .tenantId(tenantId)
                    .divisionId(divisionId)
                    .sourceResourceItemId(quotaResource.getResourceItemId())
                    .sourceQuotaResourceId(quotaResource.getId())
                    .resourceType(resourceItem != null ? resourceItem.getType() : null)
                    .code(resourceItem != null ? resourceItem.getCode() : null)
                    .name(resourceItem != null ? resourceItem.getName() : null)
                    .spec(resourceItem != null ? resourceItem.getSpec() : null)
                    .unit(resourceItem != null ? resourceItem.getUnit() : null)
                    .consumeQty(quotaResource.getAdjustedDosage() != null 
                            ? quotaResource.getAdjustedDosage() 
                            : quotaResource.getDosage())
                    .baseConsumeQty(quotaResource.getDosage())
                    // 普通工料机复制价格字段，复合工料机价格为空（由子项汇总）
                    .taxRate(!isMerged && resourceItem != null ? resourceItem.getTaxRate() : null)
                    .taxExclBasePrice(!isMerged && resourceItem != null ? resourceItem.getTaxExclBasePrice() : null)
                    .taxInclBasePrice(!isMerged && resourceItem != null ? resourceItem.getTaxInclBasePrice() : null)
                    .taxExclCompilePrice(!isMerged && resourceItem != null ? resourceItem.getTaxExclCompilePrice() : null)
                    .taxInclCompilePrice(!isMerged && resourceItem != null ? resourceItem.getTaxInclCompilePrice() : null)
                    .categoryId(resourceItem != null ? resourceItem.getCategoryId() : null)
                    .sortOrder(++sortOrder)
                    .sourceType(WbBoqResourceDO.SOURCE_TYPE_SYSTEM)
                    .build();
            
            wbBoqResourceMapper.insert(wbResource);
            
            // 4. 如果是复合工料机，复制子数据
            if (resourceItem != null && resourceItem.getIsMerged() != null && resourceItem.getIsMerged() == 1) {
                List<ResourceMergedDO> mergedList = resourceMergedMapper.selectByMergedId(resourceItem.getId());
                if (CollUtil.isNotEmpty(mergedList)) {
                    int childSortOrder = 0;
                    for (ResourceMergedDO merged : mergedList) {
                        ResourceItemDO childResourceItem = resourceItemMapper.selectById(merged.getSourceId());
                        if (childResourceItem == null) {
                            continue;
                        }
                        
                        // 子项定额消耗量 = 原定额消耗量 × 父定额消耗量
                        BigDecimal childDosage = merged.getQuotaConsumption();
                        BigDecimal parentDosage = quotaResource.getDosage();
                        BigDecimal effectiveChildDosage = childDosage;
                        if (childDosage != null && parentDosage != null) {
                            effectiveChildDosage = childDosage.multiply(parentDosage);
                        }
                        
                        WbBoqResourceDO childResource = WbBoqResourceDO.builder()
                                .tenantId(tenantId)
                                .divisionId(divisionId)
                                .parentId(wbResource.getId())
                                .sourceResourceItemId(merged.getSourceId())
                                .sourceQuotaResourceId(merged.getId())
                                .resourceType(childResourceItem.getType())
                                .code(childResourceItem.getCode())
                                .name(childResourceItem.getName())
                                .spec(childResourceItem.getSpec())
                                .unit(childResourceItem.getUnit())
                                .consumeQty(effectiveChildDosage)
                                .baseConsumeQty(effectiveChildDosage)
                                .taxRate(childResourceItem.getTaxRate())
                                .taxExclBasePrice(childResourceItem.getTaxExclBasePrice())
                                .taxInclBasePrice(childResourceItem.getTaxInclBasePrice())
                                .taxExclCompilePrice(childResourceItem.getTaxExclCompilePrice())
                                .taxInclCompilePrice(childResourceItem.getTaxInclCompilePrice())
                                .sortOrder(++childSortOrder)
                                .sourceType(WbBoqResourceDO.SOURCE_TYPE_SYSTEM)
                                .build();
                        
                        wbBoqResourceMapper.insert(childResource);
                        childCount++;
                    }
                }
            }
        }
        
        log.info("从定额基价 {} 复制了 {} 条工料机数据（含 {} 条子数据）到分部分项定额节点 {}", 
                quotaItemId, quotaResources.size(), childCount, divisionId);
        
        // 5. 复制市场主材设备
        copyQuotaMarketMaterials(divisionId, tenantId, quotaItemId);
    }
    
    /**
     * 从定额基价复制市场主材设备到分部分项
     */
    private void copyQuotaMarketMaterials(Long divisionId, Long tenantId, Long quotaItemId) {
        // 1. 查询定额基价的市场主材设备
        List<QuotaMarketMaterialDO> quotaMaterials = quotaMarketMaterialMapper.selectListByQuotaItemId(quotaItemId);
        if (CollUtil.isEmpty(quotaMaterials)) {
            return;
        }
        
        // 2. 批量查询资源项信息
        List<Long> resourceItemIds = quotaMaterials.stream()
                .map(QuotaMarketMaterialDO::getResourceItemId)
                .collect(Collectors.toList());
        Map<Long, ResourceItemDO> resourceItemMap = resourceItemMapper.selectBatchIds(resourceItemIds)
                .stream()
                .collect(Collectors.toMap(ResourceItemDO::getId, r -> r));
        
        // 3. 转换并插入
        int sortOrder = 0;
        for (QuotaMarketMaterialDO quotaMaterial : quotaMaterials) {
            ResourceItemDO resourceItem = resourceItemMap.get(quotaMaterial.getResourceItemId());
            
            WbBoqMarketMaterialDO wbMaterial = WbBoqMarketMaterialDO.builder()
                    .tenantId(tenantId)
                    .divisionId(divisionId)
                    .sourceResourceItemId(quotaMaterial.getResourceItemId())
                    .sourceMarketMaterialId(quotaMaterial.getId())
                    .resourceType(resourceItem != null ? resourceItem.getType() : null)
                    .code(resourceItem != null ? resourceItem.getCode() : null)
                    .name(resourceItem != null ? resourceItem.getName() : null)
                    .spec(resourceItem != null ? resourceItem.getSpec() : null)
                    .unit(resourceItem != null ? resourceItem.getUnit() : null)
                    .consumeQty(quotaMaterial.getAdjustedDosage() != null 
                            ? quotaMaterial.getAdjustedDosage() 
                            : quotaMaterial.getDosage())
                    .baseConsumeQty(quotaMaterial.getDosage())
                    .taxRate(resourceItem != null ? resourceItem.getTaxRate() : null)
                    .taxExclBasePrice(resourceItem != null ? resourceItem.getTaxExclBasePrice() : null)
                    .taxInclBasePrice(resourceItem != null ? resourceItem.getTaxInclBasePrice() : null)
                    .taxExclCompilePrice(resourceItem != null ? resourceItem.getTaxExclCompilePrice() : null)
                    .taxInclCompilePrice(resourceItem != null ? resourceItem.getTaxInclCompilePrice() : null)
                    .categoryId(resourceItem != null ? resourceItem.getCategoryId() : null)
                    .sortOrder(++sortOrder)
                    .sourceType(WbBoqMarketMaterialDO.SOURCE_TYPE_SYSTEM)
                    .build();
            
            wbBoqMarketMaterialMapper.insert(wbMaterial);
        }
        
        log.info("从定额基价 {} 复制了 {} 条市场主材设备到分部分项定额节点 {}", 
                quotaItemId, quotaMaterials.size(), divisionId);
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long createRootNode(Long compileTreeId, String unitName) {
        // 1. 校验单位工程节点
        validateCompileTreeIsUnit(compileTreeId);

        // 2. 检查是否已存在根目录节点
        List<WbBoqDivisionDO> existingRoots = wbBoqDivisionMapper.selectListByNodeType(
                compileTreeId, WbBoqDivisionDO.NODE_TYPE_ROOT);
        if (!existingRoots.isEmpty()) {
            log.warn("单位工程 {} 已存在根目录节点，跳过创建", compileTreeId);
            return existingRoots.get(0).getId();
        }

        // 3. 创建根目录节点（名称固定为"单位工程"）
        WbBoqDivisionDO rootNode = WbBoqDivisionDO.builder()
                .compileTreeId(compileTreeId)
                .parentId(null)
                .nodeType(WbBoqDivisionDO.NODE_TYPE_ROOT)
                .sourceType(WbBoqDivisionDO.SOURCE_TYPE_SYSTEM)
                .name("单位工程")
                .sortOrder(0)
                .path(new String[]{})
                .build();

        wbBoqDivisionMapper.insert(rootNode);

        // 4. 更新路径（包含自身ID）
        updatePathWithSelfId(rootNode);

        log.info("为单位工程 {} 创建了分部分项根目录节点 {}", compileTreeId, rootNode.getId());
        return rootNode.getId();
    }

    @Override
    public List<WbBoqDivisionRespVO> getAllBoqNodes() {
        List<WbBoqDivisionDO> boqNodes = wbBoqDivisionMapper.selectAllBoqNodes();
        return boqNodes.stream()
                .map(WbBoqDivisionConvert.INSTANCE::convert)
                .collect(Collectors.toList());
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long copyBoqWithChildren(Long sourceBoqId, Long targetCompileTreeId, Long targetParentId) {
        // 1. 校验源清单节点
        WbBoqDivisionDO sourceBoq = wbBoqDivisionMapper.selectById(sourceBoqId);
        if (sourceBoq == null || !WbBoqDivisionDO.NODE_TYPE_BOQ.equals(sourceBoq.getNodeType())) {
            throw exception(WB_BOQ_DIVISION_NOT_EXISTS);
        }

        // 2. 校验目标单位工程
        validateCompileTreeIsUnit(targetCompileTreeId);

        // 3. 获取目标父节点的路径
        String[] parentPath = new String[]{};
        if (targetParentId != null) {
            WbBoqDivisionDO parent = wbBoqDivisionMapper.selectById(targetParentId);
            if (parent != null && parent.getPath() != null) {
                parentPath = parent.getPath();
            }
        }

        // 4. 获取排序号
        Integer maxSortOrder = wbBoqDivisionMapper.selectMaxSortOrderByParentId(targetCompileTreeId, targetParentId);

        // 5. 复制清单节点
        WbBoqDivisionDO newBoq = WbBoqDivisionDO.builder()
                .compileTreeId(targetCompileTreeId)
                .parentId(targetParentId)
                .nodeType(WbBoqDivisionDO.NODE_TYPE_BOQ)
                .sourceType(WbBoqDivisionDO.SOURCE_TYPE_COPY)
                .sourceBoqCatalogId(sourceBoq.getSourceBoqCatalogId())
                .sourceBoqItemTreeId(sourceBoq.getSourceBoqItemTreeId())
                .code(sourceBoq.getCode())
                .name(sourceBoq.getName())
                .feature(sourceBoq.getFeature())
                .unit(sourceBoq.getUnit())
                .qty(sourceBoq.getQty())
                .lineNo(sourceBoq.getLineNo())
                .sortOrder(maxSortOrder + 1)
                .baseCode(sourceBoq.getBaseCode())
                .baseName(sourceBoq.getBaseName())
                .baseUnit(sourceBoq.getBaseUnit())
                .snapshotJson(sourceBoq.getSnapshotJson())
                .attributes(sourceBoq.getAttributes())
                .build();

        wbBoqDivisionMapper.insert(newBoq);

        // 6. 更新路径
        String[] newPath = Arrays.copyOf(parentPath, parentPath.length + 1);
        newPath[parentPath.length] = String.valueOf(newBoq.getId());
        newBoq.setPath(newPath);
        wbBoqDivisionMapper.updateById(newBoq);

        // 7. 复制子节点（定额）
        List<WbBoqDivisionDO> quotaNodes = wbBoqDivisionMapper.selectListByParentId(sourceBoqId);
        for (WbBoqDivisionDO sourceQuota : quotaNodes) {
            if (WbBoqDivisionDO.NODE_TYPE_QUOTA.equals(sourceQuota.getNodeType())) {
                copyQuotaNode(sourceQuota, newBoq.getId(), targetCompileTreeId, newPath);
            }
        }

        log.info("复制清单 {} 到目标位置 compileTreeId={}, parentId={}, 新清单ID={}", 
                sourceBoqId, targetCompileTreeId, targetParentId, newBoq.getId());
        return newBoq.getId();
    }

    /**
     * 复制定额节点（含工料机）
     */
    private void copyQuotaNode(WbBoqDivisionDO sourceQuota, Long newParentId, Long targetCompileTreeId, String[] parentPath) {
        // 1. 获取排序号
        Integer maxSortOrder = wbBoqDivisionMapper.selectMaxSortOrderByParentId(targetCompileTreeId, newParentId);

        // 2. 复制定额节点
        WbBoqDivisionDO newQuota = WbBoqDivisionDO.builder()
                .compileTreeId(targetCompileTreeId)
                .parentId(newParentId)
                .nodeType(WbBoqDivisionDO.NODE_TYPE_QUOTA)
                .sourceType(WbBoqDivisionDO.SOURCE_TYPE_COPY)
                .sourceQuotaItemId(sourceQuota.getSourceQuotaItemId())
                .code(sourceQuota.getCode())
                .name(sourceQuota.getName())
                .feature(sourceQuota.getFeature())
                .unit(sourceQuota.getUnit())
                .qty(sourceQuota.getQty())
                .lineNo(sourceQuota.getLineNo())
                .sortOrder(maxSortOrder + 1)
                .baseCode(sourceQuota.getBaseCode())
                .baseName(sourceQuota.getBaseName())
                .baseUnit(sourceQuota.getBaseUnit())
                .snapshotJson(sourceQuota.getSnapshotJson())
                .attributes(sourceQuota.getAttributes())
                .build();

        wbBoqDivisionMapper.insert(newQuota);

        // 3. 更新路径
        String[] newPath = Arrays.copyOf(parentPath, parentPath.length + 1);
        newPath[parentPath.length] = String.valueOf(newQuota.getId());
        newQuota.setPath(newPath);
        wbBoqDivisionMapper.updateById(newQuota);

        // 4. 复制工料机数据
        copyBoqResources(sourceQuota.getId(), newQuota.getId());

        log.info("复制定额 {} 到新定额 {}", sourceQuota.getId(), newQuota.getId());
    }

    /**
     * 复制工料机数据
     */
    private void copyBoqResources(Long sourceDivisionId, Long targetDivisionId) {
        List<WbBoqResourceDO> sourceResources = wbBoqResourceMapper.selectListByDivisionId(sourceDivisionId);
        if (CollUtil.isEmpty(sourceResources)) {
            return;
        }

        for (WbBoqResourceDO source : sourceResources) {
            WbBoqResourceDO newResource = WbBoqResourceDO.builder()
                    .divisionId(targetDivisionId)
                    .parentId(null) // 父子关系后续处理
                    .sourceResourceItemId(source.getSourceResourceItemId())
                    .sourceQuotaResourceId(source.getSourceQuotaResourceId())
                    .resourceType(source.getResourceType())
                    .code(source.getCode())
                    .name(source.getName())
                    .spec(source.getSpec())
                    .unit(source.getUnit())
                    .categoryId(source.getCategoryId())
                    .consumeQty(source.getConsumeQty())
                    .taxExclBasePrice(source.getTaxExclBasePrice())
                    .taxInclBasePrice(source.getTaxInclBasePrice())
                    .taxExclCompilePrice(source.getTaxExclCompilePrice())
                    .taxInclCompilePrice(source.getTaxInclCompilePrice())
                    .taxRate(source.getTaxRate())
                    .adjustRate(source.getAdjustRate())
                    .sortOrder(source.getSortOrder())
                    .sourceType(source.getSourceType())
                    .baseConsumeQty(source.getBaseConsumeQty())
                    .baseBasePrice(source.getBaseBasePrice())
                    .usageQty(source.getUsageQty())
                    .snapshotJson(source.getSnapshotJson())
                    .attributes(source.getAttributes())
                    .build();

            wbBoqResourceMapper.insert(newResource);
        }

        log.info("复制工料机 {} 条从分部分项 {} 到 {}", sourceResources.size(), sourceDivisionId, targetDivisionId);
    }

    @Override
    public PageResult<WbBoqDivisionRespVO> getHistoryListPage(HistoryBoqListReqVO reqVO) {
        // 0. 历史库过滤：只查询已保存至历史库的项目下的数据
        List<Long> historyProjectIds = wbProjectTreeMapper.selectList(
                new cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX<com.yhy.module.core.dal.dataobject.workbench.WbProjectTreeDO>()
                        .eq(com.yhy.module.core.dal.dataobject.workbench.WbProjectTreeDO::getInHistoryLibrary, true)
                        .eq(com.yhy.module.core.dal.dataobject.workbench.WbProjectTreeDO::getNodeType, "project")
        ).stream().map(com.yhy.module.core.dal.dataobject.workbench.WbProjectTreeDO::getId).collect(Collectors.toList());
        
        if (CollUtil.isEmpty(historyProjectIds)) {
            return new PageResult<>(Collections.emptyList(), 0L);
        }
        
        // 获取这些项目下的所有 compileTreeId
        List<Long> historyCompileTreeIds = wbCompileTreeMapper.selectList(
                new cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX<WbCompileTreeDO>()
                        .in(WbCompileTreeDO::getProjectId, historyProjectIds)
        ).stream().map(WbCompileTreeDO::getId).collect(Collectors.toList());
        
        if (CollUtil.isEmpty(historyCompileTreeIds)) {
            return new PageResult<>(Collections.emptyList(), 0L);
        }
        
        // 1. 查询清单和分部节点（全量查询用于后续过滤和去重）
        List<WbBoqDivisionDO> nodes = wbBoqDivisionMapper.selectHistoryList(reqVO);
        
        // 1.1 过滤：只保留属于历史库项目的数据
        if (CollUtil.isNotEmpty(nodes)) {
            nodes = nodes.stream()
                    .filter(node -> historyCompileTreeIds.contains(node.getCompileTreeId()))
                    .collect(Collectors.toList());
        }
        
        // 1.2 清单章节筛选（通过 sourceBoqItemTreeId 关联 BoqItemTree.boqCatalogItemId）
        if (reqVO.getListChapter() != null && CollUtil.isNotEmpty(nodes)) {
            // 收集所有有 sourceBoqItemTreeId 的节点
            List<Long> allBoqItemTreeIds = nodes.stream()
                    .map(WbBoqDivisionDO::getSourceBoqItemTreeId)
                    .filter(Objects::nonNull)
                    .distinct()
                    .collect(Collectors.toList());
            
            if (CollUtil.isNotEmpty(allBoqItemTreeIds)) {
                // 忽略租户查询清单项树，找出 boqCatalogItemId 匹配的 sourceBoqItemTreeId
                final List<Long> finalAllBoqItemTreeIds = allBoqItemTreeIds;
                final Long listChapterId = reqVO.getListChapter();
                java.util.Set<Long> matchedBoqItemTreeIds = cn.iocoder.yudao.framework.tenant.core.util.TenantUtils.executeIgnore(() -> {
                    List<com.yhy.module.core.dal.dataobject.boq.BoqItemTreeDO> boqItemTrees =
                            boqItemTreeMapper.selectBatchIds(finalAllBoqItemTreeIds);
                    return boqItemTrees.stream()
                            .filter(item -> listChapterId.equals(item.getBoqCatalogItemId()))
                            .map(com.yhy.module.core.dal.dataobject.boq.BoqItemTreeDO::getId)
                            .collect(Collectors.toSet());
                });
                
                nodes = nodes.stream()
                        .filter(node -> node.getSourceBoqItemTreeId() != null 
                                && matchedBoqItemTreeIds.contains(node.getSourceBoqItemTreeId()))
                        .collect(Collectors.toList());
            } else {
                nodes = Collections.emptyList();
            }
        }
        
        // 2. 库类别过滤（通过 yhy_wb_unit_info 表关联）
        if (StrUtil.isNotBlank(reqVO.getLibraryCategory()) && CollUtil.isNotEmpty(nodes)) {
            List<Long> matchedCompileTreeIds = wbUnitInfoMapper.selectCompileTreeIdsByLibraryType(reqVO.getLibraryCategory());
            if (CollUtil.isNotEmpty(matchedCompileTreeIds)) {
                nodes = nodes.stream()
                        .filter(node -> matchedCompileTreeIds.contains(node.getCompileTreeId()))
                        .collect(Collectors.toList());
            } else {
                nodes = Collections.emptyList();
            }
        }
        
        // 2.5 定额专业/定额章节筛选（通过子定额节点 → QuotaItem.catalog_item_id → QuotaCatalogTree）
        if ((reqVO.getQuotaProfession() != null || reqVO.getQuotaChapter() != null) && CollUtil.isNotEmpty(nodes)) {
            // 收集所有 boq/division 节点ID
            List<Long> nodeIds = nodes.stream().map(WbBoqDivisionDO::getId).collect(Collectors.toList());
            
            // 查询这些节点下的所有定额子节点
            List<WbBoqDivisionDO> allQuotaNodes = wbBoqDivisionMapper.selectList(
                    new cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX<WbBoqDivisionDO>()
                            .in(WbBoqDivisionDO::getParentId, nodeIds)
                            .eq(WbBoqDivisionDO::getNodeType, WbBoqDivisionDO.NODE_TYPE_QUOTA)
                            .isNotNull(WbBoqDivisionDO::getSourceQuotaItemId));
            
            if (CollUtil.isNotEmpty(allQuotaNodes)) {
                // 收集所有 sourceQuotaItemId
                List<Long> quotaItemIds = allQuotaNodes.stream()
                        .map(WbBoqDivisionDO::getSourceQuotaItemId)
                        .distinct()
                        .collect(Collectors.toList());
                
                // 忽略租户查询 QuotaItem，获取 catalog_item_id（指向 QuotaCatalogTree 节点）
                final List<Long> finalQuotaItemIds = quotaItemIds;
                // Map: quotaItemId -> catalogTreeNodeId (QuotaCatalogTree.id)
                Map<Long, Long> quotaItemToCatalogTreeMap = cn.iocoder.yudao.framework.tenant.core.util.TenantUtils.executeIgnore(() -> {
                    List<com.yhy.module.core.dal.dataobject.quota.QuotaItemDO> quotaItems =
                            quotaItemMapper.selectBatchIds(finalQuotaItemIds);
                    Map<Long, Long> map = new java.util.HashMap<>();
                    for (com.yhy.module.core.dal.dataobject.quota.QuotaItemDO qi : quotaItems) {
                        if (qi.getCatalogItemId() != null) {
                            map.put(qi.getId(), qi.getCatalogItemId());
                        }
                    }
                    return map;
                });
                
                // 确定匹配的 sourceQuotaItemId 集合
                java.util.Set<Long> matchedQuotaItemIds = new java.util.HashSet<>();
                
                if (reqVO.getQuotaChapter() != null) {
                    // 定额章节筛选：QuotaItem.catalog_item_id = quotaChapter（QuotaCatalogTree节点ID）
                    Long chapterId = reqVO.getQuotaChapter();
                    for (Map.Entry<Long, Long> entry : quotaItemToCatalogTreeMap.entrySet()) {
                        if (chapterId.equals(entry.getValue())) {
                            matchedQuotaItemIds.add(entry.getKey());
                        }
                    }
                } else if (reqVO.getQuotaProfession() != null) {
                    // 定额专业筛选：需要通过 QuotaCatalogTree.catalog_item_id 关联到 QuotaCatalogItem
                    java.util.Set<Long> catalogTreeNodeIds = new java.util.HashSet<>(quotaItemToCatalogTreeMap.values());
                    if (CollUtil.isNotEmpty(catalogTreeNodeIds)) {
                        Long professionId = reqVO.getQuotaProfession();
                        // 忽略租户查询 QuotaCatalogTree 节点
                        java.util.Set<Long> matchedCatalogTreeIds = cn.iocoder.yudao.framework.tenant.core.util.TenantUtils.executeIgnore(() -> {
                            List<com.yhy.module.core.dal.dataobject.quota.QuotaCatalogTreeDO> catalogTrees =
                                    quotaCatalogTreeMapper.selectBatchIds(new java.util.ArrayList<>(catalogTreeNodeIds));
                            return catalogTrees.stream()
                                    .filter(ct -> professionId.equals(ct.getCatalogItemId()))
                                    .map(com.yhy.module.core.dal.dataobject.quota.QuotaCatalogTreeDO::getId)
                                    .collect(Collectors.toSet());
                        });
                        
                        for (Map.Entry<Long, Long> entry : quotaItemToCatalogTreeMap.entrySet()) {
                            if (matchedCatalogTreeIds.contains(entry.getValue())) {
                                matchedQuotaItemIds.add(entry.getKey());
                            }
                        }
                    }
                }
                
                // 找出包含匹配定额的父节点（boq/division）
                java.util.Set<Long> matchedParentIds = allQuotaNodes.stream()
                        .filter(q -> matchedQuotaItemIds.contains(q.getSourceQuotaItemId()))
                        .map(WbBoqDivisionDO::getParentId)
                        .collect(Collectors.toSet());
                
                nodes = nodes.stream()
                        .filter(node -> matchedParentIds.contains(node.getId()))
                        .collect(Collectors.toList());
            } else {
                nodes = Collections.emptyList();
            }
        }
        
        // 3. 去重：按项目特征+定额工料机内容判断唯一性
        if (CollUtil.isNotEmpty(nodes)) {
            nodes = deduplicateByContentHash(nodes);
        }
        
        // 4. 计算总数并进行内存分页
        long total = nodes.size();
        int pageNo = reqVO.getPageNo();
        int pageSize = reqVO.getPageSize();
        int fromIndex = (pageNo - 1) * pageSize;
        int toIndex = Math.min(fromIndex + pageSize, nodes.size());
        
        List<WbBoqDivisionDO> pagedNodes = fromIndex < nodes.size() 
                ? nodes.subList(fromIndex, toIndex) 
                : Collections.emptyList();
        
        // 5. 转换为 VO
        List<WbBoqDivisionRespVO> result = WbBoqDivisionConvert.INSTANCE.convertList(pagedNodes);
        
        // 3. 关联查询清单章节名称
        if (CollUtil.isNotEmpty(result)) {
            // 收集所有 sourceBoqItemTreeId
            List<Long> boqItemTreeIds = result.stream()
                    .map(WbBoqDivisionRespVO::getSourceBoqItemTreeId)
                    .filter(Objects::nonNull)
                    .distinct()
                    .collect(Collectors.toList());
            
            log.debug("查询清单章节，sourceBoqItemTreeIds: {}", boqItemTreeIds);
            
            if (CollUtil.isNotEmpty(boqItemTreeIds)) {
                // 忽略租户过滤查询公共清单项树数据
                final List<Long> finalBoqItemTreeIds = boqItemTreeIds;
                Map<Long, String> boqItemTreeNameMap = cn.iocoder.yudao.framework.tenant.core.util.TenantUtils.executeIgnore(() -> {
                    // 批量查询清单项树
                    List<com.yhy.module.core.dal.dataobject.boq.BoqItemTreeDO> boqItemTrees = 
                            boqItemTreeMapper.selectBatchIds(finalBoqItemTreeIds);
                    log.debug("查询到清单项树数量: {}", boqItemTrees.size());
                    
                    // 收集所有需要查询的根节点ID
                    java.util.Set<Long> rootIds = new java.util.HashSet<>();
                    for (com.yhy.module.core.dal.dataobject.boq.BoqItemTreeDO item : boqItemTrees) {
                        if (item.getPath() != null && item.getPath().length > 0) {
                            try {
                                rootIds.add(Long.parseLong(item.getPath()[0]));
                            } catch (NumberFormatException ignored) {}
                        }
                    }
                    
                    // 批量查询根节点
                    Map<Long, String> rootNameMap = new java.util.HashMap<>();
                    if (CollUtil.isNotEmpty(rootIds)) {
                        List<com.yhy.module.core.dal.dataobject.boq.BoqItemTreeDO> roots = 
                                boqItemTreeMapper.selectBatchIds(new java.util.ArrayList<>(rootIds));
                        for (com.yhy.module.core.dal.dataobject.boq.BoqItemTreeDO root : roots) {
                            rootNameMap.put(root.getId(), root.getName());
                        }
                    }
                    
                    // 构建ID到章节名称的映射
                    Map<Long, String> nameMap = new java.util.HashMap<>();
                    for (com.yhy.module.core.dal.dataobject.boq.BoqItemTreeDO item : boqItemTrees) {
                        String chapterName = item.getName();
                        if (item.getPath() != null && item.getPath().length > 0) {
                            try {
                                Long rootId = Long.parseLong(item.getPath()[0]);
                                chapterName = rootNameMap.getOrDefault(rootId, item.getName());
                            } catch (NumberFormatException ignored) {}
                        }
                        nameMap.put(item.getId(), chapterName);
                    }
                    return nameMap;
                });
                
                // 查询清单专业名称（通过boqCatalogItemId）
                final List<Long> finalBoqItemTreeIds2 = boqItemTreeIds;
                Map<Long, String> boqProfessionNameMap = cn.iocoder.yudao.framework.tenant.core.util.TenantUtils.executeIgnore(() -> {
                    // 批量查询清单项树获取boqCatalogItemId
                    List<com.yhy.module.core.dal.dataobject.boq.BoqItemTreeDO> boqItemTrees = 
                            boqItemTreeMapper.selectBatchIds(finalBoqItemTreeIds2);
                    
                    // 收集所有boqCatalogItemId
                    java.util.Set<Long> catalogItemIds = new java.util.HashSet<>();
                    Map<Long, Long> itemToCatalogMap = new java.util.HashMap<>();
                    for (com.yhy.module.core.dal.dataobject.boq.BoqItemTreeDO item : boqItemTrees) {
                        if (item.getBoqCatalogItemId() != null) {
                            catalogItemIds.add(item.getBoqCatalogItemId());
                            itemToCatalogMap.put(item.getId(), item.getBoqCatalogItemId());
                        }
                    }
                    
                    // 批量查询清单专业
                    Map<Long, String> catalogNameMap = new java.util.HashMap<>();
                    if (CollUtil.isNotEmpty(catalogItemIds)) {
                        List<com.yhy.module.core.dal.dataobject.boq.BoqCatalogItemDO> catalogs = 
                                boqCatalogItemMapper.selectBatchIds(new java.util.ArrayList<>(catalogItemIds));
                        for (com.yhy.module.core.dal.dataobject.boq.BoqCatalogItemDO catalog : catalogs) {
                            catalogNameMap.put(catalog.getId(), catalog.getName());
                        }
                    }
                    
                    // 构建ID到专业名称的映射
                    Map<Long, String> professionMap = new java.util.HashMap<>();
                    for (Map.Entry<Long, Long> entry : itemToCatalogMap.entrySet()) {
                        professionMap.put(entry.getKey(), catalogNameMap.get(entry.getValue()));
                    }
                    return professionMap;
                });
                
                // 查询清单说明（通过boqSubItem）
                final List<Long> finalBoqItemTreeIds3 = boqItemTreeIds;
                Map<Long, String> boqDescriptionMap = cn.iocoder.yudao.framework.tenant.core.util.TenantUtils.executeIgnore(() -> {
                    // 批量查询清单子项
                    List<com.yhy.module.core.dal.dataobject.boq.BoqSubItemDO> boqSubItems = 
                            boqSubItemMapper.selectListByBoqItemTreeIds(finalBoqItemTreeIds3);
                    
                    // 构建ID到清单说明的映射（取第一个子项的说明）
                    Map<Long, String> descMap = new java.util.HashMap<>();
                    for (com.yhy.module.core.dal.dataobject.boq.BoqSubItemDO subItem : boqSubItems) {
                        if (subItem.getBoqItemTreeId() != null && subItem.getDescription() != null) {
                            // 如果已有值则不覆盖（取第一个）
                            descMap.putIfAbsent(subItem.getBoqItemTreeId(), subItem.getDescription());
                        }
                    }
                    return descMap;
                });
                
                // 设置章节名称、专业名称和清单说明
                for (WbBoqDivisionRespVO vo : result) {
                    if (vo.getSourceBoqItemTreeId() != null) {
                        vo.setBoqChapterName(boqItemTreeNameMap.get(vo.getSourceBoqItemTreeId()));
                        vo.setBoqProfessionName(boqProfessionNameMap.get(vo.getSourceBoqItemTreeId()));
                        vo.setBoqDescription(boqDescriptionMap.get(vo.getSourceBoqItemTreeId()));
                    }
                }
            }
        }
        
        return new PageResult<>(result, total);
    }

    /**
     * 按内容哈希去重：清单按项目特征+定额工料机内容判断唯一性，分部按其下所有清单内容判断
     */
    private List<WbBoqDivisionDO> deduplicateByContentHash(List<WbBoqDivisionDO> nodes) {
        if (CollUtil.isEmpty(nodes)) {
            return nodes;
        }
        
        // 收集所有节点ID
        List<Long> nodeIds = nodes.stream().map(WbBoqDivisionDO::getId).collect(Collectors.toList());
        
        // 查询所有清单节点的子定额
        Map<Long, List<WbBoqDivisionDO>> boqToQuotasMap = new java.util.HashMap<>();
        for (WbBoqDivisionDO node : nodes) {
            if (WbBoqDivisionDO.NODE_TYPE_BOQ.equals(node.getNodeType())) {
                List<WbBoqDivisionDO> quotas = wbBoqDivisionMapper.selectListByParentId(node.getId());
                boqToQuotasMap.put(node.getId(), quotas);
            }
        }
        
        // 查询所有定额的工料机
        java.util.Set<Long> allQuotaIds = new java.util.HashSet<>();
        for (List<WbBoqDivisionDO> quotas : boqToQuotasMap.values()) {
            for (WbBoqDivisionDO quota : quotas) {
                allQuotaIds.add(quota.getId());
            }
        }
        
        // 批量查询工料机
        Map<Long, List<WbBoqResourceDO>> quotaToResourcesMap = new java.util.HashMap<>();
        if (CollUtil.isNotEmpty(allQuotaIds)) {
            for (Long quotaId : allQuotaIds) {
                List<WbBoqResourceDO> resources = wbBoqResourceMapper.selectListByDivisionId(quotaId);
                quotaToResourcesMap.put(quotaId, resources);
            }
        }
        
        // 对于分部节点，查询其下所有清单
        Map<Long, List<WbBoqDivisionDO>> divisionToBoqsMap = new java.util.HashMap<>();
        for (WbBoqDivisionDO node : nodes) {
            if (WbBoqDivisionDO.NODE_TYPE_DIVISION.equals(node.getNodeType())) {
                List<WbBoqDivisionDO> boqs = wbBoqDivisionMapper.selectListByParentId(node.getId());
                divisionToBoqsMap.put(node.getId(), boqs.stream()
                        .filter(b -> WbBoqDivisionDO.NODE_TYPE_BOQ.equals(b.getNodeType()))
                        .collect(Collectors.toList()));
                // 也需要查询这些清单的定额和工料机
                for (WbBoqDivisionDO boq : divisionToBoqsMap.get(node.getId())) {
                    if (!boqToQuotasMap.containsKey(boq.getId())) {
                        List<WbBoqDivisionDO> quotas = wbBoqDivisionMapper.selectListByParentId(boq.getId());
                        boqToQuotasMap.put(boq.getId(), quotas);
                        for (WbBoqDivisionDO quota : quotas) {
                            if (!quotaToResourcesMap.containsKey(quota.getId())) {
                                quotaToResourcesMap.put(quota.getId(), wbBoqResourceMapper.selectListByDivisionId(quota.getId()));
                            }
                        }
                    }
                }
            }
        }
        
        // 计算每个节点的内容哈希并去重（分部类型不去重）
        java.util.Set<String> seenHashes = new java.util.HashSet<>();
        List<WbBoqDivisionDO> deduplicated = new java.util.ArrayList<>();
        
        for (WbBoqDivisionDO node : nodes) {
            // 分部类型不参与去重，直接保留
            if (WbBoqDivisionDO.NODE_TYPE_DIVISION.equals(node.getNodeType())) {
                deduplicated.add(node);
                continue;
            }
            // 清单类型按内容哈希去重
            String contentHash = calculateContentHash(node, boqToQuotasMap, quotaToResourcesMap, divisionToBoqsMap);
            if (!seenHashes.contains(contentHash)) {
                seenHashes.add(contentHash);
                deduplicated.add(node);
            }
        }
        
        log.debug("去重前 {} 条，去重后 {} 条", nodes.size(), deduplicated.size());
        return deduplicated;
    }

    /**
     * 计算节点内容哈希
     */
    private String calculateContentHash(WbBoqDivisionDO node, 
                                         Map<Long, List<WbBoqDivisionDO>> boqToQuotasMap,
                                         Map<Long, List<WbBoqResourceDO>> quotaToResourcesMap,
                                         Map<Long, List<WbBoqDivisionDO>> divisionToBoqsMap) {
        StringBuilder sb = new StringBuilder();
        
        if (WbBoqDivisionDO.NODE_TYPE_BOQ.equals(node.getNodeType())) {
            // 清单：项目特征 + 定额工料机内容
            sb.append("BOQ:");
            sb.append(node.getFeature() != null ? node.getFeature() : "");
            sb.append("|");
            
            // 添加定额和工料机信息
            List<WbBoqDivisionDO> quotas = boqToQuotasMap.getOrDefault(node.getId(), Collections.emptyList());
            quotas.sort((a, b) -> {
                int cmp = (a.getCode() != null ? a.getCode() : "").compareTo(b.getCode() != null ? b.getCode() : "");
                return cmp != 0 ? cmp : Long.compare(a.getId(), b.getId());
            });
            
            for (WbBoqDivisionDO quota : quotas) {
                sb.append("Q:").append(quota.getCode()).append(",").append(quota.getName()).append(";");
                List<WbBoqResourceDO> resources = quotaToResourcesMap.getOrDefault(quota.getId(), Collections.emptyList());
                resources.sort((a, b) -> {
                    int cmp = (a.getCode() != null ? a.getCode() : "").compareTo(b.getCode() != null ? b.getCode() : "");
                    return cmp != 0 ? cmp : Long.compare(a.getId(), b.getId());
                });
                for (WbBoqResourceDO res : resources) {
                    sb.append("R:").append(res.getCode()).append(",")
                      .append(res.getName()).append(",")
                      .append(res.getConsumeQty()).append(";");
                }
            }
        } else if (WbBoqDivisionDO.NODE_TYPE_DIVISION.equals(node.getNodeType())) {
            // 分部：所有清单的内容哈希组合
            sb.append("DIV:");
            sb.append(node.getName() != null ? node.getName() : "");
            sb.append("|");
            
            List<WbBoqDivisionDO> boqs = divisionToBoqsMap.getOrDefault(node.getId(), Collections.emptyList());
            boqs.sort((a, b) -> {
                int cmp = (a.getCode() != null ? a.getCode() : "").compareTo(b.getCode() != null ? b.getCode() : "");
                return cmp != 0 ? cmp : Long.compare(a.getId(), b.getId());
            });
            
            for (WbBoqDivisionDO boq : boqs) {
                String boqHash = calculateContentHash(boq, boqToQuotasMap, quotaToResourcesMap, divisionToBoqsMap);
                sb.append(boqHash).append("|");
            }
        }
        
        // 使用 MD5 生成哈希
        return cn.hutool.crypto.digest.DigestUtil.md5Hex(sb.toString());
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long copyDivisionWithChildren(Long sourceDivisionId, Long targetCompileTreeId, Long targetParentId) {
        // 1. 校验源分部节点
        WbBoqDivisionDO sourceDivision = wbBoqDivisionMapper.selectById(sourceDivisionId);
        if (sourceDivision == null) {
            throw exception(WB_BOQ_DIVISION_NOT_EXISTS);
        }
        if (!WbBoqDivisionDO.NODE_TYPE_DIVISION.equals(sourceDivision.getNodeType())) {
            throw exception(WB_BOQ_DIVISION_NODE_TYPE_ERROR);
        }

        // 2. 校验目标单位工程节点
        validateCompileTreeIsUnit(targetCompileTreeId);

        // 3. 获取目标父节点路径
        String[] parentPath;
        if (targetParentId != null) {
            WbBoqDivisionDO targetParent = validateNodeExists(targetParentId);
            parentPath = targetParent.getPath() != null ? targetParent.getPath() : new String[]{String.valueOf(targetParentId)};
        } else {
            // 如果没有父节点，查找根节点
            WbBoqDivisionDO root = wbBoqDivisionMapper.selectOne(new cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX<WbBoqDivisionDO>()
                    .eq(WbBoqDivisionDO::getCompileTreeId, targetCompileTreeId)
                    .eq(WbBoqDivisionDO::getNodeType, WbBoqDivisionDO.NODE_TYPE_ROOT));
            if (root != null) {
                targetParentId = root.getId();
                parentPath = root.getPath() != null ? root.getPath() : new String[]{String.valueOf(root.getId())};
            } else {
                parentPath = new String[0];
            }
        }

        // 4. 获取同级最大排序号
        Integer maxSortOrder = wbBoqDivisionMapper.selectMaxSortOrderByParentId(targetCompileTreeId, targetParentId);

        // 5. 复制分部节点
        WbBoqDivisionDO newDivision = WbBoqDivisionDO.builder()
                .compileTreeId(targetCompileTreeId)
                .parentId(targetParentId)
                .nodeType(WbBoqDivisionDO.NODE_TYPE_DIVISION)
                .sourceType(WbBoqDivisionDO.SOURCE_TYPE_COPY)
                .sourceBoqCatalogId(sourceDivision.getSourceBoqCatalogId())
                .sourceBoqItemTreeId(sourceDivision.getSourceBoqItemTreeId())
                .code(sourceDivision.getCode())
                .name(sourceDivision.getName())
                .feature(sourceDivision.getFeature())
                .unit(sourceDivision.getUnit())
                .qty(sourceDivision.getQty())
                .lineNo(sourceDivision.getLineNo())
                .sortOrder(maxSortOrder + 1)
                .baseCode(sourceDivision.getBaseCode())
                .baseName(sourceDivision.getBaseName())
                .baseUnit(sourceDivision.getBaseUnit())
                .snapshotJson(sourceDivision.getSnapshotJson())
                .attributes(sourceDivision.getAttributes())
                .build();

        wbBoqDivisionMapper.insert(newDivision);

        // 6. 更新路径
        String[] newPath = Arrays.copyOf(parentPath, parentPath.length + 1);
        newPath[parentPath.length] = String.valueOf(newDivision.getId());
        newDivision.setPath(newPath);
        wbBoqDivisionMapper.updateById(newDivision);

        // 7. 递归复制子节点（清单和定额）
        copyDivisionChildren(sourceDivisionId, newDivision.getId(), targetCompileTreeId, newPath);

        log.info("复制分部 {} 到新分部 {}", sourceDivisionId, newDivision.getId());
        return newDivision.getId();
    }

    /**
     * 复制清单节点（含定额和工料机）- 用于分部复制时
     */
    private void copyBoqNode(WbBoqDivisionDO sourceBoq, Long newParentId, Long targetCompileTreeId, String[] parentPath) {
        // 1. 获取排序号
        Integer maxSortOrder = wbBoqDivisionMapper.selectMaxSortOrderByParentId(targetCompileTreeId, newParentId);

        // 2. 复制清单节点
        WbBoqDivisionDO newBoq = WbBoqDivisionDO.builder()
                .compileTreeId(targetCompileTreeId)
                .parentId(newParentId)
                .nodeType(WbBoqDivisionDO.NODE_TYPE_BOQ)
                .sourceType(WbBoqDivisionDO.SOURCE_TYPE_COPY)
                .sourceBoqCatalogId(sourceBoq.getSourceBoqCatalogId())
                .sourceBoqItemTreeId(sourceBoq.getSourceBoqItemTreeId())
                .code(sourceBoq.getCode())
                .name(sourceBoq.getName())
                .feature(sourceBoq.getFeature())
                .unit(sourceBoq.getUnit())
                .qty(sourceBoq.getQty())
                .lineNo(sourceBoq.getLineNo())
                .sortOrder(maxSortOrder + 1)
                .baseCode(sourceBoq.getBaseCode())
                .baseName(sourceBoq.getBaseName())
                .baseUnit(sourceBoq.getBaseUnit())
                .snapshotJson(sourceBoq.getSnapshotJson())
                .attributes(sourceBoq.getAttributes())
                .build();

        wbBoqDivisionMapper.insert(newBoq);

        // 3. 更新路径
        String[] newPath = Arrays.copyOf(parentPath, parentPath.length + 1);
        newPath[parentPath.length] = String.valueOf(newBoq.getId());
        newBoq.setPath(newPath);
        wbBoqDivisionMapper.updateById(newBoq);

        // 4. 复制子节点（定额）
        List<WbBoqDivisionDO> quotaNodes = wbBoqDivisionMapper.selectListByParentId(sourceBoq.getId());
        for (WbBoqDivisionDO sourceQuota : quotaNodes) {
            if (WbBoqDivisionDO.NODE_TYPE_QUOTA.equals(sourceQuota.getNodeType())) {
                copyQuotaNode(sourceQuota, newBoq.getId(), targetCompileTreeId, newPath);
            }
        }

        log.info("复制清单 {} 到新清单 {}", sourceBoq.getId(), newBoq.getId());
    }

    /**
     * 递归复制分部的子节点
     */
    private void copyDivisionChildren(Long sourceParentId, Long newParentId, Long targetCompileTreeId, String[] parentPath) {
        List<WbBoqDivisionDO> children = wbBoqDivisionMapper.selectListByParentId(sourceParentId);
        if (CollUtil.isEmpty(children)) {
            return;
        }

        for (WbBoqDivisionDO child : children) {
            if (WbBoqDivisionDO.NODE_TYPE_BOQ.equals(child.getNodeType())) {
                // 复制清单节点（含定额和工料机）
                copyBoqNode(child, newParentId, targetCompileTreeId, parentPath);
            } else if (WbBoqDivisionDO.NODE_TYPE_DIVISION.equals(child.getNodeType())) {
                // 递归复制子分部
                Integer maxSortOrder = wbBoqDivisionMapper.selectMaxSortOrderByParentId(targetCompileTreeId, newParentId);
                
                WbBoqDivisionDO newChild = WbBoqDivisionDO.builder()
                        .compileTreeId(targetCompileTreeId)
                        .parentId(newParentId)
                        .nodeType(WbBoqDivisionDO.NODE_TYPE_DIVISION)
                        .sourceType(WbBoqDivisionDO.SOURCE_TYPE_COPY)
                        .sourceBoqCatalogId(child.getSourceBoqCatalogId())
                        .sourceBoqItemTreeId(child.getSourceBoqItemTreeId())
                        .code(child.getCode())
                        .name(child.getName())
                        .feature(child.getFeature())
                        .unit(child.getUnit())
                        .qty(child.getQty())
                        .lineNo(child.getLineNo())
                        .sortOrder(maxSortOrder + 1)
                        .baseCode(child.getBaseCode())
                        .baseName(child.getBaseName())
                        .baseUnit(child.getBaseUnit())
                        .snapshotJson(child.getSnapshotJson())
                        .attributes(child.getAttributes())
                        .build();

                wbBoqDivisionMapper.insert(newChild);

                String[] newPath = Arrays.copyOf(parentPath, parentPath.length + 1);
                newPath[parentPath.length] = String.valueOf(newChild.getId());
                newChild.setPath(newPath);
                wbBoqDivisionMapper.updateById(newChild);

                // 递归复制子节点
                copyDivisionChildren(child.getId(), newChild.getId(), targetCompileTreeId, newPath);
            }
        }
    }

    /**
     * 自动建立项目级费率模式绑定（如果尚未绑定）
     * 当添加定额时，自动将定额所属的费率模式绑定到项目
     */
    private void autoBindRateModeForProject(Long compileTreeId, Long sourceQuotaItemId, Long rateModeId) {
        // 1. 获取单位工程节点
        WbCompileTreeDO compileTree = wbCompileTreeMapper.selectById(compileTreeId);
        if (compileTree == null || compileTree.getProjectId() == null) {
            return;
        }

        // 2. 获取项目
        com.yhy.module.core.dal.dataobject.workbench.WbProjectTreeDO project = 
                wbProjectTreeMapper.selectById(compileTree.getProjectId());
        if (project == null) {
            return;
        }

        // 3. 获取定额专业ID
        Long quotaSpecialtyId = quotaItemService.getSpecialtyIdByQuotaItem(sourceQuotaItemId);
        if (quotaSpecialtyId == null) {
            return;
        }

        // 4. 检查是否已经绑定
        Long existingRateModeId = project.getRateModeIdByQuotaSpecialty(quotaSpecialtyId);
        if (existingRateModeId != null) {
            // 已经绑定，不需要重复绑定
            return;
        }

        // 5. 获取费率模式和定额专业名称
        com.yhy.module.core.dal.dataobject.quota.QuotaCatalogItemDO rateMode = 
                quotaCatalogItemMapper.selectById(rateModeId);
        com.yhy.module.core.dal.dataobject.quota.QuotaCatalogItemDO specialty = 
                quotaCatalogItemMapper.selectById(quotaSpecialtyId);
        
        String rateModeName = rateMode != null ? rateMode.getName() : "";
        String quotaSpecialtyName = specialty != null ? specialty.getName() : "";

        // 6. 建立绑定
        project.bindRateMode(quotaSpecialtyId, rateModeId, rateModeName, quotaSpecialtyName);
        wbProjectTreeMapper.updateById(project);

        log.info("[autoBindRateModeForProject] 自动建立费率模式绑定, projectId={}, quotaSpecialtyId={}, rateModeId={}", 
                compileTree.getProjectId(), quotaSpecialtyId, rateModeId);
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    public void updateBoqDivisionBaseRate(com.yhy.module.core.controller.admin.workbench.vo.calcbaserate.UpdateBoqDivisionBaseRateReqVO reqVO) {
        // 1. 校验节点存在且为清单类型
        WbBoqDivisionDO node = validateNodeExists(reqVO.getDivisionId());
        if (!WbBoqDivisionDO.NODE_TYPE_BOQ.equals(node.getNodeType())) {
            throw exception0(400, "只能选择清单节点进行基数费率替换");
        }

        // 2. 获取基数费率项信息
        com.yhy.module.core.dal.dataobject.calcbaserate.CalcBaseRateItemDO item = 
                calcBaseRateItemMapper.selectById(reqVO.getCalcBaseRateItemId());
        if (item == null) {
            throw exception0(400, "基数费率项不存在");
        }

        // 3. 更新清单的费率和备注字段（用基数费率的备注替换清单备注）
        String rateStr = item.getRate();
        if (rateStr != null && rateStr.endsWith("%")) {
            rateStr = rateStr.substring(0, rateStr.length() - 1);
        }
        node.setRate(new java.math.BigDecimal(rateStr));
        node.setRemark(item.getRemark()); // 将基数费率项的备注作为清单备注
        wbBoqDivisionMapper.updateById(node);

        log.info("[updateBoqDivisionBaseRate] 更新清单基数费率成功, divisionId={}, rate={}, remark={}", 
                reqVO.getDivisionId(), item.getRate(), item.getRemark());
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    public void importTemplate(Long compileTreeId, Long catalogItemId) {
        // 1. 校验单位工程节点
        validateCompileTreeIsUnit(compileTreeId);

        // 2. 确保根节点存在
        List<WbBoqDivisionDO> existingRoots = wbBoqDivisionMapper.selectListByNodeType(
                compileTreeId, WbBoqDivisionDO.NODE_TYPE_ROOT);
        Long rootId;
        if (existingRoots.isEmpty()) {
            rootId = createRootNode(compileTreeId, "单位工程");
        } else {
            rootId = existingRoots.get(0).getId();
        }
        WbBoqDivisionDO rootNode = wbBoqDivisionMapper.selectById(rootId);

        // 3. 查询后台所有标签页的模板数据（四个标签页独立存储）
        List<com.yhy.module.core.dal.dataobject.config.ConfigUnitDivisionTemplateDO> templates =
                configUnitDivisionTemplateMapper.selectListByCatalogItemId(catalogItemId);
        if (CollUtil.isEmpty(templates)) {
            log.info("[importTemplate] catalogItemId={} 没有模板数据，跳过", catalogItemId);
            return;
        }

        // 4. 按 path 长度排序（保证父节点先处理），同层按 sortOrder
        List<com.yhy.module.core.dal.dataobject.config.ConfigUnitDivisionTemplateDO> sortedTemplates =
                templates.stream()
                        .sorted((a, b) -> {
                            int depthA = a.getPath() != null ? a.getPath().length : 0;
                            int depthB = b.getPath() != null ? b.getPath().length : 0;
                            if (depthA != depthB) return Integer.compare(depthA, depthB);
                            int sa = a.getSortOrder() != null ? a.getSortOrder() : 0;
                            int sb = b.getSortOrder() != null ? b.getSortOrder() : 0;
                            return Integer.compare(sa, sb);
                        })
                        .collect(Collectors.toList());

        // 5. 复制模板到工作台，维护 ID 映射（旧模板ID → 新工作台ID）
        Map<Long, Long> idMapping = new java.util.HashMap<>();
        int sortCounter = wbBoqDivisionMapper.selectMaxSortOrderByParentId(compileTreeId, rootId);
        for (com.yhy.module.core.dal.dataobject.config.ConfigUnitDivisionTemplateDO tpl : sortedTemplates) {
            // 确定父节点ID
            Long parentId;
            if (tpl.getParentId() == null) {
                parentId = rootId;
            } else {
                parentId = idMapping.get(tpl.getParentId());
                if (parentId == null) {
                    log.warn("[importTemplate] 模板节点 {} 的父节点 {} 未找到映射，跳过", tpl.getId(), tpl.getParentId());
                    continue;
                }
            }

            // 从模板的 tabType 字段直接获取标签页归属（非 division 的设置 tabTypes）
            String tabType = tpl.getTabType();
            List<String> tabTypes = null;
            if (tabType != null && !"division".equals(tabType)) {
                tabTypes = java.util.Collections.singletonList(tabType);
            }

            // 构建工作台节点
            Map<String, Object> attrs = new java.util.HashMap<>();
            if (tpl.getAttributes() != null) {
                attrs.putAll(tpl.getAttributes());
            }
            if (tabTypes != null) {
                attrs.put("tabTypes", tabTypes);
            }

            WbBoqDivisionDO node = WbBoqDivisionDO.builder()
                    .compileTreeId(compileTreeId)
                    .parentId(parentId)
                    .nodeType(tpl.getNodeType())
                    .sourceType(WbBoqDivisionDO.SOURCE_TYPE_SYSTEM)
                    .code(tpl.getCode())
                    .name(tpl.getName())
                    .unit(tpl.getUnit())
                    .rate(tpl.getRate())
                    .remark(tpl.getRemark())
                    .costCode(tpl.getCostCode())
                    .sortOrder(++sortCounter)
                    .attributes(attrs.isEmpty() ? null : attrs)
                    .build();

            // 设置路径
            WbBoqDivisionDO parentNode = parentId.equals(rootId) ? rootNode : wbBoqDivisionMapper.selectById(parentId);
            node.setPath(buildPath(parentNode));

            wbBoqDivisionMapper.insert(node);
            updatePathWithSelfId(node);

            // 记录 ID 映射
            idMapping.put(tpl.getId(), node.getId());

            log.debug("[importTemplate] 复制模板节点: {} -> 工作台节点: {}, tabType={}", tpl.getId(), node.getId(), tabType);
        }

        // 6. 修正基数范围中的节点ID引用（模板ID → 工作台ID）
        for (Map.Entry<Long, Long> entry : idMapping.entrySet()) {
            Long newNodeId = entry.getValue();
            WbBoqDivisionDO node = wbBoqDivisionMapper.selectById(newNodeId);
            if (node == null || node.getAttributes() == null) {
                continue;
            }

            Object rangeObj = node.getAttributes().get("baseNumberRange");
            if (!(rangeObj instanceof Map)) {
                continue;
            }

            @SuppressWarnings("unchecked")
            Map<String, Object> rangeMap = (Map<String, Object>) rangeObj;
            Object selectedIdsObj = rangeMap.get("selectedIds");
            if (!(selectedIdsObj instanceof List)) {
                continue;
            }

            List<?> oldIds = (List<?>) selectedIdsObj;
            if (oldIds.isEmpty()) {
                continue;
            }

            // 将模板ID映射为工作台ID
            List<Object> newIds = new java.util.ArrayList<>();
            for (Object oldId : oldIds) {
                Long oldLong;
                if (oldId instanceof Number) {
                    oldLong = ((Number) oldId).longValue();
                } else {
                    try {
                        oldLong = Long.parseLong(String.valueOf(oldId));
                    } catch (NumberFormatException e) {
                        continue; // 忽略无效ID
                    }
                }
                Long mapped = idMapping.get(oldLong);
                if (mapped != null) {
                    newIds.add(mapped);
                }
                // 映射不到的ID直接丢弃（模板中已删除的节点）
            }

            // 更新 attributes 中的 baseNumberRange
            Map<String, Object> updatedAttrs = new java.util.HashMap<>(node.getAttributes());
            Map<String, Object> updatedRange = new java.util.HashMap<>(rangeMap);
            updatedRange.put("selectedIds", newIds);
            updatedRange.put("count", newIds.size());
            updatedAttrs.put("baseNumberRange", updatedRange);

            node.setAttributes(updatedAttrs);
            wbBoqDivisionMapper.updateById(node);

            log.debug("[importTemplate] 修正基数范围ID: 节点={}, 旧IDs={}, 新IDs={}", newNodeId, oldIds, newIds);
        }

        log.info("[importTemplate] 导入模板完成, compileTreeId={}, catalogItemId={}, 共复制 {} 个节点",
                compileTreeId, catalogItemId, idMapping.size());
    }

    @Override
    public List<WbBoqDivisionRespVO> getTreeByTab(Long compileTreeId, String tabType) {
        // 1. 获取全量数据
        List<WbBoqDivisionDO> allNodes = wbBoqDivisionMapper.selectListByCompileTreeId(compileTreeId);
        if (CollUtil.isEmpty(allNodes)) {
            return Collections.emptyList();
        }

        // 2. 四个标签页独立：每个标签页只显示属于自己的节点
        // division 标签页 = 无 tabTypes 或 tabTypes 为空的节点（默认归属）
        // measure/other/unit_summary = tabTypes 包含对应值的节点
        boolean isDivision = StrUtil.isBlank(tabType) || "division".equals(tabType);

        java.util.Set<Long> matchedIds = new java.util.HashSet<>();
        for (WbBoqDivisionDO node : allNodes) {
            // root 节点始终包含
            if (WbBoqDivisionDO.NODE_TYPE_ROOT.equals(node.getNodeType())) {
                continue;
            }
            Map<String, Object> attrs = node.getAttributes();
            if (isDivision) {
                // 分部分项：只显示没有 tabTypes 或 tabTypes 为空的节点
                if (attrs == null || !attrs.containsKey("tabTypes")) {
                    matchedIds.add(node.getId());
                } else {
                    Object tabTypesObj = attrs.get("tabTypes");
                    if (tabTypesObj instanceof java.util.List && ((java.util.List<?>) tabTypesObj).isEmpty()) {
                        matchedIds.add(node.getId());
                    }
                }
            } else {
                // 其他标签页：tabTypes 包含目标值的节点
                if (attrs != null) {
                    Object tabTypesObj = attrs.get("tabTypes");
                    if (tabTypesObj instanceof java.util.List) {
                        java.util.List<?> tabTypesList = (java.util.List<?>) tabTypesObj;
                        if (tabTypesList.contains(tabType)) {
                            matchedIds.add(node.getId());
                        }
                    }
                    // 兼容旧的 tabType 单值格式
                    if (tabType.equals(attrs.get("tabType"))) {
                        matchedIds.add(node.getId());
                    }
                }
            }
        }

        // 3. 递归收集所有子节点ID
        Map<Long, List<WbBoqDivisionDO>> parentIdMap = allNodes.stream()
                .filter(n -> n.getParentId() != null)
                .collect(Collectors.groupingBy(WbBoqDivisionDO::getParentId));
        java.util.Set<Long> allMatchedIds = new java.util.HashSet<>(matchedIds);
        for (Long matchedId : matchedIds) {
            collectChildIdsForTab(matchedId, parentIdMap, allMatchedIds, tabType);
        }

        // 4. 加入 root 节点，构建树
        List<WbBoqDivisionRespVO> filteredVoList = new ArrayList<>();
        for (WbBoqDivisionDO node : allNodes) {
            if (WbBoqDivisionDO.NODE_TYPE_ROOT.equals(node.getNodeType()) || allMatchedIds.contains(node.getId())) {
                filteredVoList.add(WbBoqDivisionConvert.INSTANCE.convert(node));
            }
        }

        return buildTreeFromVOList(filteredVoList);
    }

    @Override
    public List<WbBoqDivisionRespVO> getTreeByTab(Long compileTreeId, String tabType, Boolean excludeZeroQtyBoq) {
        List<WbBoqDivisionRespVO> tree = getTreeByTab(compileTreeId, tabType);
        if (Boolean.TRUE.equals(excludeZeroQtyBoq)) {
            filterZeroQtyBoqNodes(tree);
        }
        return tree;
    }

    @Override
    public List<WbBoqDivisionRespVO> getTreeForUnifiedFee(Long compileTreeId, String tabType, List<Long> feeChapter) {
        // 1. 获取按标签页过滤后的树（复用现有逻辑）
        List<WbBoqDivisionRespVO> tree = getTreeByTab(compileTreeId, tabType);

        // 2. 过滤掉 unified_fee 类型节点
        filterNodesByType(tree, WbBoqDivisionDO.NODE_TYPE_UNIFIED_FEE);

        // 3. 如果传了 feeChapter，根据取费章节过滤定额，并裁剪空清单
        if (CollUtil.isNotEmpty(feeChapter)) {
            // 3.1 收集树中所有定额节点的 sourceQuotaItemId，批量查询 catalogPath
            List<WbBoqDivisionRespVO> allQuotaNodes = new java.util.ArrayList<>();
            collectNodesByType(tree, WbBoqDivisionDO.NODE_TYPE_QUOTA, allQuotaNodes);

            // 批量查询定额项的 catalogItemId
            List<Long> quotaItemIds = allQuotaNodes.stream()
                    .filter(n -> n.getSourceQuotaItemId() != null)
                    .map(WbBoqDivisionRespVO::getSourceQuotaItemId)
                    .distinct()
                    .collect(Collectors.toList());

            // 构建 sourceQuotaItemId -> catalogPath 的映射
            java.util.Set<Long> matchedQuotaItemIds = new java.util.HashSet<>();
            if (CollUtil.isNotEmpty(quotaItemIds)) {
                Map<Long, Long> quotaItemToCatalogMap = new java.util.HashMap<>();
                List<com.yhy.module.core.dal.dataobject.quota.QuotaItemDO> quotaItems =
                        quotaItemMapper.selectBatchIds(quotaItemIds);
                for (com.yhy.module.core.dal.dataobject.quota.QuotaItemDO item : quotaItems) {
                    if (item.getCatalogItemId() != null) {
                        quotaItemToCatalogMap.put(item.getId(), item.getCatalogItemId());
                    }
                }

                // 批量查询定额子目录的 path
                List<Long> catalogItemIds = new java.util.ArrayList<>(new java.util.HashSet<>(quotaItemToCatalogMap.values()));
                Map<Long, String[]> catalogItemToPathMap = new java.util.HashMap<>();
                if (CollUtil.isNotEmpty(catalogItemIds)) {
                    List<com.yhy.module.core.dal.dataobject.quota.QuotaCatalogTreeDO> catalogTrees =
                            quotaCatalogTreeMapper.selectBatchIds(catalogItemIds);
                    for (com.yhy.module.core.dal.dataobject.quota.QuotaCatalogTreeDO ct : catalogTrees) {
                        if (ct.getPath() != null) {
                            catalogItemToPathMap.put(ct.getId(), ct.getPath());
                        }
                    }
                }

                // 判断每个定额的 catalogPath 是否与 feeChapter 匹配
                java.util.Set<String> feeChapterStrSet = feeChapter.stream()
                        .map(String::valueOf)
                        .collect(Collectors.toSet());
                for (WbBoqDivisionRespVO quotaNode : allQuotaNodes) {
                    if (quotaNode.getSourceQuotaItemId() == null) continue;
                    Long catalogItemId = quotaItemToCatalogMap.get(quotaNode.getSourceQuotaItemId());
                    if (catalogItemId != null) {
                        // catalogItemId 本身匹配
                        if (feeChapterStrSet.contains(String.valueOf(catalogItemId))) {
                            matchedQuotaItemIds.add(quotaNode.getId());
                            continue;
                        }
                        // catalogPath 中任意节点匹配
                        String[] catalogPath = catalogItemToPathMap.get(catalogItemId);
                        if (catalogPath != null) {
                            for (String pathId : catalogPath) {
                                if (feeChapterStrSet.contains(pathId)) {
                                    matchedQuotaItemIds.add(quotaNode.getId());
                                    break;
                                }
                            }
                        }
                    }
                }
            }

            // 3.2 过滤不匹配的定额节点
            filterUnmatchedQuotaNodes(tree, matchedQuotaItemIds);

            // 3.3 裁剪没有定额子节点的空清单
            filterEmptyBoqNodes(tree);
        }

        return tree;
    }

    /**
     * 递归过滤指定类型的节点
     */
    private void filterNodesByType(List<WbBoqDivisionRespVO> nodes, String nodeType) {
        if (nodes == null) return;
        nodes.removeIf(node -> {
            if (node.getChildren() != null) {
                filterNodesByType(node.getChildren(), nodeType);
            }
            return nodeType.equals(node.getNodeType());
        });
    }

    /**
     * 递归收集指定类型的节点
     */
    private void collectNodesByType(List<WbBoqDivisionRespVO> nodes, String nodeType, List<WbBoqDivisionRespVO> result) {
        if (nodes == null) return;
        for (WbBoqDivisionRespVO node : nodes) {
            if (nodeType.equals(node.getNodeType())) {
                result.add(node);
            }
            if (node.getChildren() != null) {
                collectNodesByType(node.getChildren(), nodeType, result);
            }
        }
    }

    /**
     * 递归过滤不在匹配集合中的定额节点
     */
    private void filterUnmatchedQuotaNodes(List<WbBoqDivisionRespVO> nodes, java.util.Set<Long> matchedIds) {
        if (nodes == null) return;
        nodes.removeIf(node -> {
            if (node.getChildren() != null) {
                filterUnmatchedQuotaNodes(node.getChildren(), matchedIds);
            }
            return WbBoqDivisionDO.NODE_TYPE_QUOTA.equals(node.getNodeType()) && !matchedIds.contains(node.getId());
        });
    }

    /**
     * 递归裁剪没有子节点的清单节点
     */
    private void filterEmptyBoqNodes(List<WbBoqDivisionRespVO> nodes) {
        if (nodes == null) return;
        nodes.removeIf(node -> {
            if (node.getChildren() != null) {
                filterEmptyBoqNodes(node.getChildren());
            }
            return WbBoqDivisionDO.NODE_TYPE_BOQ.equals(node.getNodeType())
                    && (node.getChildren() == null || node.getChildren().isEmpty());
        });
    }

    /**
     * 递归过滤工程量为0或空的清单节点（boq类型）
     * 过滤规则：移除 nodeType='boq' 且 qty 为 null 或 0 的节点
     * 保留分部/根/定额等其他类型节点
     */
    private void filterZeroQtyBoqNodes(List<WbBoqDivisionRespVO> nodes) {
        if (nodes == null) return;
        nodes.removeIf(node -> {
            // 先递归处理子节点
            if (node.getChildren() != null) {
                filterZeroQtyBoqNodes(node.getChildren());
            }
            // 只过滤清单节点
            if (WbBoqDivisionDO.NODE_TYPE_BOQ.equals(node.getNodeType())) {
                return node.getQty() == null || node.getQty().compareTo(java.math.BigDecimal.ZERO) == 0;
            }
            return false;
        });
    }

    /**
     * 递归收集子节点ID
     */
    private void collectChildIds(Long parentId, Map<Long, List<WbBoqDivisionDO>> parentIdMap, java.util.Set<Long> result) {
        List<WbBoqDivisionDO> children = parentIdMap.get(parentId);
        if (children == null) return;
        for (WbBoqDivisionDO child : children) {
            result.add(child.getId());
            collectChildIds(child.getId(), parentIdMap, result);
        }
    }

    /**
     * 递归收集子节点ID（标签页感知版本）
     * 如果子节点自身有 tabTypes 且不包含目标 tabType，则跳过该子节点及其后代
     * 如果子节点没有 tabTypes（如定额、工作内容等纯子节点），则收集
     */
    private void collectChildIdsForTab(Long parentId, Map<Long, List<WbBoqDivisionDO>> parentIdMap,
                                        java.util.Set<Long> result, String tabType) {
        List<WbBoqDivisionDO> children = parentIdMap.get(parentId);
        if (children == null) return;
        for (WbBoqDivisionDO child : children) {
            Map<String, Object> attrs = child.getAttributes();
            boolean hasTabTypes = false;
            boolean matchesTab = false;
            if (attrs != null) {
                Object tabTypesObj = attrs.get("tabTypes");
                if (tabTypesObj instanceof java.util.List && !((java.util.List<?>) tabTypesObj).isEmpty()) {
                    hasTabTypes = true;
                    matchesTab = ((java.util.List<?>) tabTypesObj).contains(tabType);
                }
                // 兼容旧的 tabType 单值
                if (!hasTabTypes && attrs.get("tabType") != null) {
                    hasTabTypes = true;
                    matchesTab = tabType.equals(attrs.get("tabType"));
                }
            }
            // 有 tabTypes 但不匹配 → 跳过
            if (hasTabTypes && !matchesTab) {
                continue;
            }
            result.add(child.getId());
            collectChildIdsForTab(child.getId(), parentIdMap, result, tabType);
        }
    }
}
