# ScheduleVsActualServiceImpl.java

    package cloud.wconnect.workperformance.service.sva.scheduleactual.impl;

    import cloud.wconnect.commonlib.cons.BaseConstant;
    import cloud.wconnect.commonlib.pojo.dto.PropertyDTO;
    import cloud.wconnect.commonlib.pojo.dto.internationalization.InternationalizationDateTimeDTO;
    import cloud.wconnect.commonlib.pojo.dto.internationalization.InternationalizationNumberDTO;
    import cloud.wconnect.commonlib.service.AttendancePropertyCodeInternalService;
    import cloud.wconnect.commonlib.util.DateTimeUtils;
    import cloud.wconnect.commonlib.util.InternationalizationUtils;
    import cloud.wconnect.commonlib.util.JsonUtils;
    import cloud.wconnect.workperformance.cons.PrecisionCons;
    import cloud.wconnect.workperformance.cons.sva.SvaReportTypeCons;
    import cloud.wconnect.workperformance.entity.sva.SvaComment;
    import cloud.wconnect.workperformance.entity.sva.avhours.SvaActualHour;
    import cloud.wconnect.workperformance.entity.sva.avhours.SvaScheduleHour;
    import cloud.wconnect.workperformance.entity.sva.employees.AttendanceEmployee;
    import cloud.wconnect.workperformance.entity.sva.employees.SvaDepartmentIgnoreConfig;
    import cloud.wconnect.workperformance.entity.sva.operatingstatistics.OperatingStatistics;
    import cloud.wconnect.workperformance.entity.sva.saleforecast.SaleForecast;
    import cloud.wconnect.workperformance.entity.sva.setting.DepCategoryRecommendHour;
    import cloud.wconnect.workperformance.entity.sva.setting.DepartmentCategory;
    import cloud.wconnect.workperformance.entity.sva.setting.SalaryPayConfig;
    import cloud.wconnect.workperformance.mapper.sva.setting.SalaryPayConfigMapper;
    import cloud.wconnect.workperformance.pojo.bo.sva.DateVersionBO;
    import cloud.wconnect.workperformance.pojo.dto.sva.abnormalthreshold.SvaAbnormalThresholdDTO;
    import cloud.wconnect.workperformance.pojo.dto.sva.schedulevsactual.*;
    import cloud.wconnect.workperformance.pojo.query.sva.schedulevsactual.ScheduleVsActualQuery;
    import cloud.wconnect.workperformance.pojo.vo.sva.scheduleactual.*;
    import cloud.wconnect.workperformance.service.sva.actualschedulehour.ISvaActualHourService;
    import cloud.wconnect.workperformance.service.sva.actualschedulehour.ISvaPayCodeExcludeConfigService;
    import cloud.wconnect.workperformance.service.sva.actualschedulehour.ISvaScheduleHourService;
    import cloud.wconnect.workperformance.service.sva.employees.IAttendanceEmployeeService;
    import cloud.wconnect.workperformance.service.sva.employees.ISvaDepartmentIgnoreConfigService;
    import cloud.wconnect.workperformance.service.sva.operatingstatistics.IOperatingStatisticsService;
    import cloud.wconnect.workperformance.service.sva.saleforecast.ISaleForecastService;
    import cloud.wconnect.workperformance.service.sva.scheduleactual.*;
    import cloud.wconnect.workperformance.service.sva.setting.IDepCategoryRecommendHourService;
    import cloud.wconnect.workperformance.service.sva.setting.IDepCodeToCategoryService;
    import cloud.wconnect.workperformance.service.sva.setting.IDepartmentCategoryService;
    import com.baomidou.dynamic.datasource.annotation.DS;
    import com.fasterxml.jackson.core.type.TypeReference;
    import com.fasterxml.jackson.databind.ObjectMapper;
    import com.google.common.collect.Lists;
    import com.google.common.collect.Sets;
    import jakarta.annotation.Resource;
    import org.apache.commons.collections.CollectionUtils;
    import org.apache.commons.lang3.StringUtils;
    import org.springframework.context.i18n.LocaleContextHolder;
    import org.springframework.lang.Nullable;
    import org.springframework.stereotype.Service;

    import java.math.BigDecimal;
    import java.math.RoundingMode;
    import java.time.LocalDate;
    import java.time.ZoneId;
    import java.time.ZonedDateTime;
    import java.time.format.DateTimeFormatter;
    import java.time.format.TextStyle;
    import java.time.temporal.ChronoUnit;
    import java.util.*;
    import java.util.function.Function;
    import java.util.stream.Collectors;

    /**
     * Schedule vs actual 服务实现类
     *
     * @author zc
     * @date 2023/06/22
     */
    @Service
    @DS("work_performance")
    public class ScheduleVsActualServiceImpl implements IScheduleVsActualService {
        @Resource
        private ISaleForecastService saleForecastService;
        @Resource
        private IOperatingStatisticsService operatingStatisticsService;
        @Resource
        private ISvaActualHourService actualHourService;
        @Resource
        private ISvaScheduleHourService scheduleHourService;
        @Resource
        private IAttendanceEmployeeService employeeService;
        @Resource
        private IDepartmentCategoryService departmentCategoryService;
        @Resource
        private ISvaCommentService svaCommentService;
        @Resource
        private ISvaDataPermissionConfigService svaDataPermissionConfigService;
        @Resource
        private ISvaDepCategoryHoursStatisticsService hoursStatisticsService;
        @Resource
        private ISvaPayCodeExcludeConfigService payCodeExcludeConfigService;
        @Resource
        private AttendancePropertyCodeInternalService attendancePropertyCodeInternalService;
        @Resource
        private IDepCategoryRecommendHourService depCategoryRecommendHourService;
        @Resource
        private IDepCodeToCategoryService depCodeToCategoryService;
        @Resource
        private ISvaDepartmentIgnoreConfigService ignoreConfigService;
        @Resource
        private SalaryPayConfigMapper salaryPayConfigMapper;
        @Resource
        private ObjectMapper objectMapper;
        @Resource
        private ISvaAbnormalThresholdService abnormalThresholdService;

        /**
         * 获取指定日期范围内 Schedule vs actual 数据
         *
         * @param propertyDTO 资产
         * @param query       查询条件
         * @param operatorId  操作人 id
         * @return ScheduleVsActualDTO
         * @author zc
         * @date 2023/6/22
         */
        @Override
        public ScheduleVsActualDTO getScheduleVsActual(PropertyDTO propertyDTO, ScheduleVsActualQuery query, int operatorId) {
            ScheduleVsActualDTO scheduleVsActual = new ScheduleVsActualDTO();

            long fromDate = query.getFromDate();
            long toDate = query.getToDate();

            ZonedDateTime fromZonedDate = DateTimeUtils.toZonedDateTime(fromDate, propertyDTO.getTimezone());
            ZonedDateTime toZonedDate = DateTimeUtils.toZonedDateTime(toDate, propertyDTO.getTimezone());

            // 获取计数保留规则
            RoundingMode roundingMode = InternationalizationUtils.getRoundingMode(propertyDTO.getInternationalization().getNumber().getCalculationRule());

            // 查询指定日期范围内 Sale forecast
            Map<Long, SaleForecast> theDate2SaleForecast = saleForecastService.listByDateRange(propertyDTO.getId(), fromDate, toDate).stream()
                    .collect(Collectors.toMap(SaleForecast::getTheDate, Function.identity()));

            // 获取异常阈值
            SvaAbnormalThresholdDTO svaAbnormalThresholdDTO = abnormalThresholdService.getByPropertyId(propertyDTO.getId());
            Integer salesDiffAbnormalThreshold = svaAbnormalThresholdDTO.getSalesDiffAbnormalThreshold();
            BigDecimal workHourDiffAbnormalThreshold = svaAbnormalThresholdDTO.getWorkHourDiffAbnormalThreshold();

            // Forecasted Room Sale
            List<Integer> forecastedRoomSaleList = generateForecastedRoomSaleList(fromZonedDate, toZonedDate, theDate2SaleForecast);
            scheduleVsActual.setForecastedRoomSaleList(forecastedRoomSaleList);

            // Occupancy %
            List<BigDecimal> occupancyList = generateOccupancyList(fromZonedDate, toZonedDate, theDate2SaleForecast, roundingMode);
            scheduleVsActual.setOccPercentList(occupancyList);

            // Arrivals
            List<Integer> arrivalList = generateArrivalList(fromZonedDate, toZonedDate, theDate2SaleForecast);
            scheduleVsActual.setArrivalList(arrivalList);

            // Dirty
            List<Integer> dirtyList = generateDirtyList(fromZonedDate, toZonedDate, theDate2SaleForecast);
            scheduleVsActual.setDirtyList(dirtyList);

            // Departures
            List<Integer> departureList = generateDepartureList(fromZonedDate, toZonedDate, theDate2SaleForecast);
            scheduleVsActual.setDepartureList(departureList);

            // 查询指定日期范围内 Operating statistics
            Map<Long, OperatingStatistics> theDate2OperatingStatistics = operatingStatisticsService.list(propertyDTO.getId(), fromDate, toDate).stream()
                    .collect(Collectors.toMap(OperatingStatistics::getTheDate, Function.identity()));

            // Estimated/Actual F&B Rev集合，包含 total
            List<SvaFbRvDTO> fbRvList = generateFbRvList(fromZonedDate, toZonedDate, theDate2SaleForecast, theDate2OperatingStatistics);
            scheduleVsActual.setFbRvList(fbRvList);

            // Actual Rooms Sold/Var.
            List<SvaRoomSoldDTO> roomSoldList = generateRoomSoldList(fromZonedDate, toZonedDate, theDate2SaleForecast, theDate2OperatingStatistics, salesDiffAbnormalThreshold);
            scheduleVsActual.setRoomSoldList(roomSoldList);

            // Estimated ADR vs. Actual
            List<SvaAdrDTO> adrList = generateAdrList(fromZonedDate, toZonedDate, theDate2SaleForecast, theDate2OperatingStatistics, roundingMode);
            scheduleVsActual.setAdrList(adrList);

            // Estimated/Actual Room Revenue集合，包含 total
            List<SvaRoomRvDTO> roomRvList = generateRoomRvList(fromZonedDate, toZonedDate, theDate2SaleForecast, theDate2OperatingStatistics, roundingMode);
            scheduleVsActual.setRoomRvList(roomRvList);

            // Estimated Rev vs. Actual
            List<SvaRevDTO> revList = generateRevList(fromZonedDate, toZonedDate, theDate2SaleForecast, theDate2OperatingStatistics);
            scheduleVsActual.setRevList(revList);

            List<SalaryPayConfig> salaryPayConfigs = salaryPayConfigMapper.listByDate(propertyDTO.getId(), fromDate, toDate);
            // Salary payroll expenses
            List<BigDecimal> salaryPayrollList = generateSalaryPayrollList(fromZonedDate, toZonedDate, salaryPayConfigs);
            scheduleVsActual.setSalaryPayrollList(salaryPayrollList);

            // 获取需要排除统计的部门代码
            List<String> excludeDepartmentCodes = ignoreConfigService.listConfig(propertyDTO.getId()).stream()
                    .map(SvaDepartmentIgnoreConfig::getCode).toList();

            // 根据资产 id 获取指定日期范围内的考勤数据
            long startDate = Long.parseLong(DateTimeUtils.localDateToStr(DateTimeUtils.longToLocalDate(fromDate,
                    propertyDTO.getTimezone()), BaseConstant.DATE_FORMAT_YYYYMMDD));
            long endDate = Long.parseLong(DateTimeUtils.localDateToStr(DateTimeUtils.longToLocalDate(toDate,
                    propertyDTO.getTimezone()), BaseConstant.DATE_FORMAT_YYYYMMDD));
            // 获取 Actual Hour
            List<SvaScheduleHour> scheduleHourList = scheduleHourService.listEffective(propertyDTO.getId(),
                    startDate, endDate, null, excludeDepartmentCodes,
                    null, null, null);
            // 获取 Schedule Hour
            List<SvaActualHour> actualHourList = actualHourService.listEffective(propertyDTO.getId(),
                    startDate, endDate, null, excludeDepartmentCodes,
                    null, null, null);

            Map<Long, List<SvaScheduleHour>> payDate2ScheduleHourList = scheduleHourList.stream()
                    .collect(Collectors.groupingBy(SvaScheduleHour::getPayDate));
            Map<Long, List<SvaActualHour>> payDate2ActualHourList = actualHourList.stream()
                    .collect(Collectors.groupingBy(SvaActualHour::getPayDate));

            // Daily Schedule vs. Actual payroll expenses
            List<SvaPayrollDTO> dailyPayrollList = generateDailyPayrollList(fromZonedDate, toZonedDate, payDate2ScheduleHourList,
                    payDate2ActualHourList, salaryPayConfigs, Collections.emptyList());
            scheduleVsActual.setDailyPayrollList(dailyPayrollList);

            // Daily Schedule vs. Actual hours
            List<SvaHoursDTO> hoursList = generateDailyHoursList(fromZonedDate, toZonedDate, payDate2ScheduleHourList,
                    payDate2ActualHourList, Collections.emptyList());
            scheduleVsActual.setHoursList(hoursList);

            // 获取排除 Pay Code 配置
            List<String> excludePayCodeList = payCodeExcludeConfigService.listExcludePayCode(propertyDTO.getId());

            // Daily Schedule vs. Actual payroll expenses (exclude PayCode)
            List<SvaPayrollDTO> dailyPayrollExcludePayCodeList = generateDailyPayrollList(fromZonedDate, toZonedDate, payDate2ScheduleHourList,
                    payDate2ActualHourList, salaryPayConfigs, excludePayCodeList);
            scheduleVsActual.setDailyPayrollExcludePayCodeList(dailyPayrollExcludePayCodeList);

            // Daily Schedule vs. Actual hours (exclude PayCode)
            List<SvaHoursDTO> hoursExcludePayCodeList = generateDailyHoursList(fromZonedDate, toZonedDate, payDate2ScheduleHourList,
                    payDate2ActualHourList, excludePayCodeList);
            scheduleVsActual.setHoursExcludePayCodeList(hoursExcludePayCodeList);

            // 校验当前登录用户是否拥有数据权限
            boolean hasDataPermissionAccess = svaDataPermissionConfigService.verifyDataPermissionAccess(propertyDTO.getTenantId(),
                    propertyDTO.getId(), operatorId);
            scheduleVsActual.setDataPermissionValid(hasDataPermissionAccess);

            // Employee summary
            List<SvaEmployeeSummaryDTO> employeeSummaryList = generateEmployeeSummaryList(propertyDTO.getId(), fromZonedDate, toZonedDate,
                    scheduleHourList, actualHourList, excludeDepartmentCodes, hasDataPermissionAccess, workHourDiffAbnormalThreshold);
            scheduleVsActual.setEmployeeSummaryList(employeeSummaryList);

            // 选择日期范围内的考勤是否在多个版本下
            scheduleVsActual.setMultipleVersions(checkHasMultipleVersions(propertyDTO, fromDate, toDate));

            return scheduleVsActual;
        }

        /**
         * 查看选择日期内是否出现多个版本
         *
         * @param propertyDTO 资产
         * @param fromDate    开始日期
         * @param toDate      结束日期
         * @return true/false
         * @author yyt
         * @date 2025/3/27
         **/
        private Boolean checkHasMultipleVersions(PropertyDTO propertyDTO, long fromDate, long toDate) {
            Integer propertyId = propertyDTO.getId();
            String timezone = propertyDTO.getTimezone();
            LocalDate localDateFromDate = DateTimeUtils.longToLocalDate(fromDate, timezone);
            int fromDateInt = Integer.parseInt(DateTimeUtils.localDateToStr(localDateFromDate, BaseConstant.DATE_FORMAT_YYYYMMDD));
            LocalDate localDateToDate = DateTimeUtils.longToLocalDate(toDate, timezone);
            int toDateInt = Integer.parseInt(DateTimeUtils.localDateToStr(localDateToDate, BaseConstant.DATE_FORMAT_YYYYMMDD));
            List<Integer> versions = departmentCategoryService.listByDateRangeWithNewOne(propertyId, fromDateInt, toDateInt);
            if (versions.size() <= 1) {
                return false;
            }

            // 选择日期内的 推荐工时
            List<DepCategoryRecommendHour> depCategoryRecommendHours = depCategoryRecommendHourService.listByDateRangeWithNewOne(propertyId, fromDate, toDate);

            List<DateVersionBO> dateVersionBOList = new ArrayList<>();
            ZonedDateTime fromZonedDate = localDateFromDate.atStartOfDay(ZoneId.of(timezone));
            ZonedDateTime toZonedDate = localDateToDate.atStartOfDay(ZoneId.of(timezone));
            for (ZonedDateTime date = fromZonedDate; !date.isAfter(toZonedDate); date = date.plusDays(1)) {
                // 获取每天 对应的版本
                dateVersionBOList.add(new DateVersionBO(date.toEpochSecond(),
                        depCodeToCategoryService.getVersionByDate(propertyDTO, date.toEpochSecond(), depCategoryRecommendHours, versions)));
            }

            return dateVersionBOList.stream().map(DateVersionBO::getVersion).distinct().count() > 1;
        }

        /**
         * 生成 Forecasted Room Sale
         *
         * @param fromZonedDate        开始日期
         * @param toZonedDate          结束日期
         * @param theDate2SaleForecast theDate 2 SaleForecast
         * @return Forecasted Room Sale 集合
         * @author zc
         * @date 2023/6/23
         */
        private List<Integer> generateForecastedRoomSaleList(ZonedDateTime fromZonedDate, ZonedDateTime toZonedDate,
                                                             Map<Long, SaleForecast> theDate2SaleForecast) {
            List<Integer> forecastedRoomSaleList = Lists.newArrayListWithExpectedSize(8);
            Integer estimatedRoomSalesTotal = null;
            for (ZonedDateTime date = fromZonedDate; !date.isAfter(toZonedDate); date = date.plusDays(1)) {
                SaleForecast saleForecast = theDate2SaleForecast.get(date.toEpochSecond());
                Integer estimatedRoomSales = null;
                if (Objects.nonNull(saleForecast)) {
                    estimatedRoomSales = saleForecast.getEstiRoomSales();
                }
                forecastedRoomSaleList.add(estimatedRoomSales);

                // estimatedRoomSalesTotal 统计
                if (Objects.nonNull(estimatedRoomSales)) {
                    if (Objects.isNull(estimatedRoomSalesTotal)) {
                        estimatedRoomSalesTotal = estimatedRoomSales;
                    } else {
                        estimatedRoomSalesTotal += estimatedRoomSales;
                    }
                }
            }

            forecastedRoomSaleList.add(estimatedRoomSalesTotal);
            return forecastedRoomSaleList;
        }

        /**
         * 生成 Occupancy %
         *
         * @param fromZonedDate        开始日期
         * @param toZonedDate          结束日期
         * @param theDate2SaleForecast theDate 2 SaleForecast
         * @param roundingMode         计数保留规则
         * @return Occupancy % 集合
         * @author zc
         * @date 2023/6/23
         */
        private List<BigDecimal> generateOccupancyList(ZonedDateTime fromZonedDate, ZonedDateTime toZonedDate,
                                                       Map<Long, SaleForecast> theDate2SaleForecast, RoundingMode roundingMode) {
            long betweenDays = ChronoUnit.DAYS.between(fromZonedDate.toLocalDate(), toZonedDate.toLocalDate()) + 1;
            List<BigDecimal> occupancyList = Lists.newArrayListWithExpectedSize(8);
            BigDecimal occPercentTotal = null;
            for (ZonedDateTime date = fromZonedDate; !date.isAfter(toZonedDate); date = date.plusDays(1)) {
                SaleForecast saleForecast = theDate2SaleForecast.get(date.toEpochSecond());
                BigDecimal occPercent = null;
                if (Objects.nonNull(saleForecast)) {
                    occPercent = saleForecast.getOccPercent();
                }
                occupancyList.add(occPercent);

                // occPercentTotal 统计
                if (Objects.nonNull(occPercent)) {
                    if (Objects.isNull(occPercentTotal)) {
                        occPercentTotal = occPercent;
                    } else {
                        occPercentTotal = occPercentTotal.add(occPercent);
                    }
                }
            }

            occupancyList.add(Objects.isNull(occPercentTotal) ? null :
                    occPercentTotal.divide(BigDecimal.valueOf(betweenDays), BaseConstant.DATA_PRECISION, roundingMode));
            return occupancyList;
        }

        /**
         * 生成 Arrival
         *
         * @param fromZonedDate        开始日期
         * @param toZonedDate          结束日期
         * @param theDate2SaleForecast theDate 2 SaleForecast
         * @return Arrival 集合
         * @author zc
         * @date 2023/6/23
         */
        private List<Integer> generateArrivalList(ZonedDateTime fromZonedDate, ZonedDateTime toZonedDate,
                                                  Map<Long, SaleForecast> theDate2SaleForecast) {
            List<Integer> arrivalList = Lists.newArrayListWithExpectedSize(8);
            Integer arrivalsTotal = null;
            for (ZonedDateTime date = fromZonedDate; !date.isAfter(toZonedDate); date = date.plusDays(1)) {
                SaleForecast saleForecast = theDate2SaleForecast.get(date.toEpochSecond());
                Integer arrivals = null;
                if (Objects.nonNull(saleForecast)) {
                    arrivals = saleForecast.getArrivals();
                }
                arrivalList.add(arrivals);

                // arrivalsTotal 统计
                if (Objects.nonNull(arrivals)) {
                    if (Objects.isNull(arrivalsTotal)) {
                        arrivalsTotal = arrivals;
                    } else {
                        arrivalsTotal += arrivals;
                    }
                }
            }

            arrivalList.add(arrivalsTotal);
            return arrivalList;
        }

        /**
         * 生成 Dirty
         *
         * @param fromZonedDate        开始日期
         * @param toZonedDate          结束日期
         * @param theDate2SaleForecast theDate 2 SaleForecast
         * @return Dirty 集合
         * @author zc
         * @date 2023/6/23
         */
        private List<Integer> generateDirtyList(ZonedDateTime fromZonedDate, ZonedDateTime toZonedDate,
                                                Map<Long, SaleForecast> theDate2SaleForecast) {
            List<Integer> dirtyList = Lists.newArrayListWithExpectedSize(8);
            Integer dirtyTotal = null;
            for (ZonedDateTime date = fromZonedDate; !date.isAfter(toZonedDate); date = date.plusDays(1)) {
                SaleForecast saleForecast = theDate2SaleForecast.get(date.toEpochSecond());
                Integer dirty = null;
                if (Objects.nonNull(saleForecast)) {
                    dirty = saleForecast.getDirty();
                }
                dirtyList.add(dirty);

                // dirtyTotal 统计
                if (Objects.nonNull(dirty)) {
                    if (Objects.isNull(dirtyTotal)) {
                        dirtyTotal = dirty;
                    } else {
                        dirtyTotal += dirty;
                    }
                }
            }

            dirtyList.add(dirtyTotal);
            return dirtyList;
        }

        /**
         * 生成 Departure
         *
         * @param fromZonedDate        开始日期
         * @param toZonedDate          结束日期
         * @param theDate2SaleForecast theDate 2 SaleForecast
         * @return Departure 集合
         * @author zc
         * @date 2023/6/23
         */
        private List<Integer> generateDepartureList(ZonedDateTime fromZonedDate, ZonedDateTime toZonedDate,
                                                    Map<Long, SaleForecast> theDate2SaleForecast) {
            List<Integer> departureList = Lists.newArrayListWithExpectedSize(8);
            Integer departureTotal = null;
            for (ZonedDateTime date = fromZonedDate; !date.isAfter(toZonedDate); date = date.plusDays(1)) {
                SaleForecast saleForecast = theDate2SaleForecast.get(date.toEpochSecond());
                Integer departure = null;
                if (Objects.nonNull(saleForecast)) {
                    departure = saleForecast.getDepartures();
                }
                departureList.add(departure);

                // departureTotal 统计
                if (Objects.nonNull(departure)) {
                    if (Objects.isNull(departureTotal)) {
                        departureTotal = departure;
                    } else {
                        departureTotal += departure;
                    }
                }
            }

            departureList.add(departureTotal);
            return departureList;
        }

        /**
         * 生成 Estimated/Actual F&B Revenue
         *
         * @param fromZonedDate               开始日期
         * @param toZonedDate                 结束日期
         * @param theDate2SaleForecast        theDate 2 SaleForecast
         * @param theDate2OperatingStatistics theDate 2 Operating Statistics
         * @return java.util.List<cloud.wconnect.workperformance.pojo.dto.sva.schedulevsactual.SvaFbRvDTO>
         * @author lichen
         * @date 2026/4/7
         */
        private List<SvaFbRvDTO> generateFbRvList(ZonedDateTime fromZonedDate, ZonedDateTime toZonedDate,
                                                  Map<Long, SaleForecast> theDate2SaleForecast, Map<Long, OperatingStatistics> theDate2OperatingStatistics) {
            List<SvaFbRvDTO> fbRvDTOList = Lists.newArrayListWithExpectedSize(8);
            BigDecimal actualFbRvTotal = BigDecimal.ZERO;
            BigDecimal estimatedFbRvTotal = BigDecimal.ZERO;
            for (ZonedDateTime date = fromZonedDate; !date.isAfter(toZonedDate); date = date.plusDays(1)) {
                SvaFbRvDTO svaFbRvDTO = new SvaFbRvDTO();

                // 计算实际的F&B Revenue
                OperatingStatistics operatingStatistics = theDate2OperatingStatistics.get(date.toEpochSecond());
                if (Objects.nonNull(operatingStatistics)) {
                    BigDecimal foodRev = operatingStatistics.getFoodRev();
                    BigDecimal bevRev = operatingStatistics.getBevRev();
                    BigDecimal fb2Rev = operatingStatistics.getFb2Rev();
                    if (Objects.nonNull(foodRev) || Objects.nonNull(bevRev) || Objects.nonNull(fb2Rev)) {
                        BigDecimal foodRevDefault = Objects.nonNull(foodRev) ? foodRev : BigDecimal.ZERO;
                        BigDecimal bevRevDefault = Objects.nonNull(bevRev) ? bevRev : BigDecimal.ZERO;
                        BigDecimal fb2RevDefault = Objects.nonNull(fb2Rev) ? fb2Rev : BigDecimal.ZERO;
                        // 实际F&B Revenue
                        BigDecimal actualFbRv = foodRevDefault.add(bevRevDefault).add(fb2RevDefault);
                        svaFbRvDTO.setActualFbRv(actualFbRv);

                        // 计算Total
                        actualFbRvTotal = actualFbRvTotal.add(actualFbRv);
                    }
                }

                // 计算预估的F&B Revenue
                SaleForecast saleForecast = theDate2SaleForecast.get(date.toEpochSecond());
                if (Objects.nonNull(saleForecast) && Objects.nonNull(saleForecast.getEstiFbRev())) {
                    svaFbRvDTO.setEstimatedFbRv(saleForecast.getEstiFbRev());

                    // 计算Total
                    estimatedFbRvTotal = estimatedFbRvTotal.add(saleForecast.getEstiFbRev());
                }

                fbRvDTOList.add(svaFbRvDTO);
            }

            // 添加 Total
            fbRvDTOList.add(SvaFbRvDTO.builder()
                    .actualFbRv(actualFbRvTotal)
                    .estimatedFbRv(estimatedFbRvTotal)
                    .build());

            // 添加 Variances
            fbRvDTOList.add(SvaFbRvDTO.builder()
                    .variances(actualFbRvTotal.subtract(estimatedFbRvTotal))
                    .build());
            return fbRvDTOList;
        }

        /**
         * 生成 Estimated/Actual Room Revenue
         *
         * @param fromZonedDate               开始日期
         * @param toZonedDate                 结束日期
         * @param theDate2SaleForecast        theDate 2 SaleForecast
         * @param theDate2OperatingStatistics theDate 2 Operating Statistics
         * @param roundingMode                四舍五入规则
         * @return java.util.List<cloud.wconnect.workperformance.pojo.dto.sva.schedulevsactual.SvaFbRvDTO>
         * @author lichen
         * @date 2026/4/7
         */
        private List<SvaRoomRvDTO> generateRoomRvList(ZonedDateTime fromZonedDate, ZonedDateTime toZonedDate,
                                                      Map<Long, SaleForecast> theDate2SaleForecast,
                                                      Map<Long, OperatingStatistics> theDate2OperatingStatistics,
                                                      RoundingMode roundingMode) {
            List<SvaRoomRvDTO> roomRvList = Lists.newArrayListWithExpectedSize(8);
            BigDecimal actualRoomRvTotal = BigDecimal.ZERO;
            BigDecimal estimatedRoomRvTotal = BigDecimal.ZERO;
            for (ZonedDateTime date = fromZonedDate; !date.isAfter(toZonedDate); date = date.plusDays(1)) {
                SvaRoomRvDTO svaRoomRvDTO = new SvaRoomRvDTO();

                // 计算实际的Room Revenue
                OperatingStatistics operatingStatistics = theDate2OperatingStatistics.get(date.toEpochSecond());
                if (Objects.nonNull(operatingStatistics) && Objects.nonNull(operatingStatistics.getRoomRev())) {
                    BigDecimal roomRev = operatingStatistics.getRoomRev();
                    svaRoomRvDTO.setActualRoomRv(roomRev);
                    // 计算Total
                    actualRoomRvTotal = actualRoomRvTotal.add(roomRev);
                }

                // 计算预估的Room Revenue
                SaleForecast saleForecast = theDate2SaleForecast.get(date.toEpochSecond());
                if (Objects.nonNull(saleForecast) && Objects.nonNull(saleForecast.getEstiRoomSales())
                        && Objects.nonNull(saleForecast.getEstiAdr())) {
                    Integer estiRoomSales = saleForecast.getEstiRoomSales();
                    BigDecimal estiAdr = saleForecast.getEstiAdr();
                    // 计算预估的Room Revenue
                    BigDecimal estimatedRoomRv = estiAdr.multiply(BigDecimal.valueOf(estiRoomSales)).setScale(6, roundingMode);
                    svaRoomRvDTO.setEstimatedRoomRv(estimatedRoomRv);
                    // 计算Total
                    estimatedRoomRvTotal = estimatedRoomRvTotal.add(estimatedRoomRv);
                }

                roomRvList.add(svaRoomRvDTO);
            }

            // 添加 Total
            roomRvList.add(SvaRoomRvDTO.builder()
                    .actualRoomRv(actualRoomRvTotal)
                    .estimatedRoomRv(estimatedRoomRvTotal)
                    .build());

            // 添加 Variances
            roomRvList.add(SvaRoomRvDTO.builder()
                    .variances(actualRoomRvTotal.subtract(estimatedRoomRvTotal))
                    .build());
            return roomRvList;
        }

        /**
         * 生成 Forecasted Room Sale
         *
         * @param fromZonedDate               开始日期
         * @param toZonedDate                 结束日期
         * @param theDate2SaleForecast        theDate 2 SaleForecast
         * @param theDate2OperatingStatistics theDate 2 OperatingStatistics
         * @param salesDiffAbnormalThreshold  销售差值异常阈值
         * @return Forecasted Room Sale 集合
         * @author zc
         * @date 2023/6/23
         */
        private List<SvaRoomSoldDTO> generateRoomSoldList(ZonedDateTime fromZonedDate, ZonedDateTime toZonedDate,
                                                          Map<Long, SaleForecast> theDate2SaleForecast,
                                                          Map<Long, OperatingStatistics> theDate2OperatingStatistics,
                                                          Integer salesDiffAbnormalThreshold) {
            List<SvaRoomSoldDTO> roomSoldList = Lists.newArrayListWithExpectedSize(8);
            int actualRoomSoldTotal = BaseConstant.ZERO;
            int varianceRoomSoldTotal = BaseConstant.ZERO;
            for (ZonedDateTime date = fromZonedDate; !date.isAfter(toZonedDate); date = date.plusDays(1)) {
                long theDate = date.toEpochSecond();

                SvaRoomSoldDTO roomSoldDTO = new SvaRoomSoldDTO();

                OperatingStatistics operatingStatistics = theDate2OperatingStatistics.get(theDate);
                // 实际售出房间数
                Integer roomsRented = Objects.isNull(operatingStatistics) ? null : operatingStatistics.getRoomsRented();
                roomSoldDTO.setActualRoomSold(roomsRented);

                // 预计售出房间数
                SaleForecast saleForecast = theDate2SaleForecast.get(theDate);
                Integer estimatedRoomSales = Objects.isNull(saleForecast) ? null : saleForecast.getEstiRoomSales();

                // 售出房间差值 = 实际售出房间数 - 预计售出房间数
                roomsRented = Objects.isNull(roomsRented) ? BaseConstant.ZERO : roomsRented;
                estimatedRoomSales = Objects.isNull(estimatedRoomSales) ? BaseConstant.ZERO : estimatedRoomSales;
                int roomSoldVariances = roomsRented - estimatedRoomSales;
                roomSoldDTO.setVarianceRoomSold(roomSoldVariances);

                // 判断是否满足异常阈值
                if (Objects.nonNull(salesDiffAbnormalThreshold)) {
                    roomSoldDTO.setAbnormal(Math.abs(roomSoldVariances) > salesDiffAbnormalThreshold);
                } else {
                    roomSoldDTO.setAbnormal(false);
                }

                roomSoldList.add(roomSoldDTO);

                // actualRoomSoldTotal 统计
                actualRoomSoldTotal = actualRoomSoldTotal + roomsRented;

                // varianceRoomSoldTotal 统计
                varianceRoomSoldTotal += roomSoldVariances;
            }

            // 添加 Total
            roomSoldList.add(SvaRoomSoldDTO.builder()
                    .actualRoomSold(actualRoomSoldTotal)
                    .varianceRoomSold(varianceRoomSoldTotal)
                    .abnormal(false)
                    .build());

            return roomSoldList;
        }

        /**
         * 生成 Estimated ADR vs. Actual
         *
         * @param fromZonedDate               开始日期
         * @param toZonedDate                 结束日期
         * @param theDate2SaleForecast        theDate 2 SaleForecast
         * @param theDate2OperatingStatistics theDate 2 OperatingStatistics
         * @param roundingMode                计数保留规则
         * @return Estimated ADR vs. Actual 集合
         * @author zc
         * @date 2023/6/23
         */
        private List<SvaAdrDTO> generateAdrList(ZonedDateTime fromZonedDate, ZonedDateTime toZonedDate, Map<Long, SaleForecast> theDate2SaleForecast,
                                                Map<Long, OperatingStatistics> theDate2OperatingStatistics, RoundingMode roundingMode) {
            long betweenDays = ChronoUnit.DAYS.between(fromZonedDate.toLocalDate(), toZonedDate.toLocalDate()) + 1;
            List<SvaAdrDTO> adrList = Lists.newArrayListWithExpectedSize(9);
            BigDecimal estimatedAdrTotal = BigDecimal.ZERO;
            BigDecimal actualAdrTotal = BigDecimal.ZERO;
            for (ZonedDateTime date = fromZonedDate; !date.isAfter(toZonedDate); date = date.plusDays(1)) {
                long theDate = date.toEpochSecond();

                SvaAdrDTO adrDTO = new SvaAdrDTO();

                SaleForecast saleForecast = theDate2SaleForecast.get(theDate);
                if (Objects.nonNull(saleForecast)) {
                    BigDecimal estimatedAdr = saleForecast.getEstiAdr();
                    adrDTO.setEstimatedAdr(estimatedAdr);

                    // estimatedAdrTotal 统计
                    if (Objects.nonNull(estimatedAdr)) {
                        estimatedAdrTotal = estimatedAdrTotal.add(estimatedAdr);
                    }
                }

                OperatingStatistics operatingStatistics = theDate2OperatingStatistics.get(theDate);
                if (Objects.nonNull(operatingStatistics)) {
                    BigDecimal actualAdr = operatingStatistics.getAdr();
                    adrDTO.setActualAdr(actualAdr);

                    // actualAdrTotal 统计
                    if (Objects.nonNull(actualAdr)) {
                        actualAdrTotal = actualAdrTotal.add(actualAdr);
                    }
                }
                adrList.add(adrDTO);
            }

            // 计算平均值
            BigDecimal estimatedTotal = estimatedAdrTotal.divide(BigDecimal.valueOf(betweenDays),
                    BaseConstant.DATA_PRECISION, roundingMode);
            BigDecimal actualTotal = actualAdrTotal.divide(BigDecimal.valueOf(betweenDays),
                    BaseConstant.DATA_PRECISION, roundingMode);

            // 添加 Total
            adrList.add(SvaAdrDTO.builder()
                    .estimatedAdr(estimatedTotal)
                    .actualAdr(actualTotal)
                    .build());

            // 添加 Variances
            adrList.add(SvaAdrDTO.builder()
                    .variances(actualTotal.subtract(estimatedTotal))
                    .build());
            return adrList;
        }

        /**
         * 生成 Estimated Rev vs. Actual
         *
         * @param fromZonedDate               开始日期
         * @param toZonedDate                 结束日期
         * @param theDate2SaleForecast        theDate 2 SaleForecast
         * @param theDate2OperatingStatistics theDate 2 OperatingStatistics
         * @return Estimated Rev vs. Actual 集合
         * @author zc
         * @date 2023/6/23
         */
        private List<SvaRevDTO> generateRevList(ZonedDateTime fromZonedDate, ZonedDateTime toZonedDate,
                                                Map<Long, SaleForecast> theDate2SaleForecast,
                                                Map<Long, OperatingStatistics> theDate2OperatingStatistics) {
            List<SvaRevDTO> revList = Lists.newArrayListWithExpectedSize(9);
            BigDecimal actualRevTotal = BigDecimal.ZERO;
            BigDecimal estimatedRevTotal = BigDecimal.ZERO;
            for (ZonedDateTime date = fromZonedDate; !date.isAfter(toZonedDate); date = date.plusDays(1)) {
                long theDate = date.toEpochSecond();

                SvaRevDTO revDTO = new SvaRevDTO();

                SaleForecast saleForecast = theDate2SaleForecast.get(theDate);
                if (Objects.nonNull(saleForecast)) {
                    BigDecimal estimatedAdr = saleForecast.getEstiAdr();
                    Integer estimatedRoomSales = saleForecast.getEstiRoomSales();

                    BigDecimal estimatedRevSold = null;
                    if (estimatedAdr != null && estimatedRoomSales != null) {
                        // 预计售价 * 预计房间售量 = 预计房间总收入
                        estimatedRevSold = estimatedAdr.multiply(new BigDecimal(estimatedRoomSales));
                    }

                    BigDecimal estimatedRev = saleForecast.getEstiFbRev();
                    if (estimatedRevSold != null) {
                        estimatedRev = (estimatedRev == null) ? estimatedRevSold : estimatedRev.add(estimatedRevSold);
                    }

                    revDTO.setEstimatedRev(estimatedRev);

                    // estimatedRevTotal 统计
                    if (Objects.nonNull(estimatedRev)) {
                        estimatedRevTotal = estimatedRevTotal.add(estimatedRev);
                    }
                }

                OperatingStatistics operatingStatistics = theDate2OperatingStatistics.get(theDate);
                if (Objects.nonNull(operatingStatistics)) {
                    BigDecimal totalRev = operatingStatistics.getTotalRev();
                    revDTO.setActualRev(totalRev);

                    // actualRevTotal 统计
                    if (Objects.nonNull(totalRev)) {
                        actualRevTotal = actualRevTotal.add(totalRev);
                    }
                }
                revList.add(revDTO);
            }

            // 添加 Total
            revList.add(SvaRevDTO.builder()
                    .actualRev(actualRevTotal)
                    .estimatedRev(estimatedRevTotal)
                    .build());

            // 添加 Variances
            revList.add(SvaRevDTO.builder()
                    .variances(actualRevTotal.subtract(estimatedRevTotal))
                    .build());
            return revList;
        }

        /**
         * 生成 Salary payroll expenses
         *
         * @param fromZonedDate    开始日期
         * @param toZonedDate      结束日期
         * @param salaryPayConfigs SalaryPay 集合
         * @return Salary payroll expenses 集合
         * @author zc
         * @date 2023/6/24
         */
        private List<BigDecimal> generateSalaryPayrollList(ZonedDateTime fromZonedDate, ZonedDateTime toZonedDate,
                                                           List<SalaryPayConfig> salaryPayConfigs) {
            List<BigDecimal> salaryPayrollList = Lists.newArrayListWithExpectedSize(8);
            BigDecimal salaryPayrollTotal = null;
            for (ZonedDateTime date = fromZonedDate; !date.isAfter(toZonedDate); date = date.plusDays(1)) {
                BigDecimal salaryPayroll = null;
                if (CollectionUtils.isNotEmpty(salaryPayConfigs)) {
                    // 获取适用当前日期的 salary pay config 设置的工资
                    salaryPayroll = getAppropriateSalaryPayMoney(salaryPayConfigs, date);
                }
                salaryPayrollList.add(salaryPayroll);

                // salaryPayrollTotal 统计
                if (Objects.nonNull(salaryPayroll)) {
                    if (Objects.isNull(salaryPayrollTotal)) {
                        salaryPayrollTotal = salaryPayroll;
                    } else {
                        salaryPayrollTotal = salaryPayrollTotal.add(salaryPayroll);
                    }
                }
            }

            // 添加 Total
            salaryPayrollList.add(salaryPayrollTotal);

            return salaryPayrollList;
        }

        /**
         * 功能描述：获取适用指定日期的 salary pay config 设置的工资
         * 业务描述：不能使用比指定日期大的 salary pay config
         * 例：指定日期1号，则只能使用 1号或1号之前设置的 salary pay config 信息
         *
         * @param salaryPayConfigs salary pay config
         * @param date             指定日期
         * @return 指定日期设置的 工资
         * @author zc
         * @date 2023/6/24
         */
        @Override
        public BigDecimal getAppropriateSalaryPayMoney(@Nullable List<SalaryPayConfig> salaryPayConfigs, ZonedDateTime date) {
            if (CollectionUtils.isEmpty(salaryPayConfigs)) {
                return null;
            }

            long startingDate = date.toEpochSecond();
            Optional<SalaryPayConfig> salaryPayConfigOptional = salaryPayConfigs.stream()
                    .filter(item -> item.getStartingDate() <= startingDate)
                    .max(Comparator.comparing(SalaryPayConfig::getStartingDate));
            if (salaryPayConfigOptional.isEmpty()) {
                return null;
            }

            SalaryPayConfig salaryPayConfig = salaryPayConfigOptional.get();
            Map<String, BigDecimal> payRate = JsonUtils.convertStringByTypeReference(objectMapper, salaryPayConfig.getPayRates(),
                    new TypeReference<>() {
                    });

            // 根据当前日期获取对应设置的周几的工资
            return payRate.get(date.getDayOfWeek().getValue() + StringUtils.EMPTY);
        }

        /**
         * 生成 Daily Schedule vs. Actual payroll expenses
         *
         * @param fromZonedDate            开始日期
         * @param toZonedDate              结束日期
         * @param payDate2ScheduleHourList payDate 2 ScheduleHourList
         * @param payDate2ActualHourList   payDate 2 ActualHourList
         * @param salaryPayConfigs         salary payroll config
         * @param excludePayCodeList       排除的 payCode 集合
         * @return Daily Schedule vs. Actual payroll expenses 集合
         * @author zc
         * @date 2023/6/24
         */
        private List<SvaPayrollDTO> generateDailyPayrollList(ZonedDateTime fromZonedDate, ZonedDateTime toZonedDate,
                                                             Map<Long, List<SvaScheduleHour>> payDate2ScheduleHourList,
                                                             Map<Long, List<SvaActualHour>> payDate2ActualHourList,
                                                             List<SalaryPayConfig> salaryPayConfigs, List<String> excludePayCodeList) {
            List<SvaPayrollDTO> payrollList = Lists.newArrayListWithExpectedSize(9);
            BigDecimal schedulePayrollTotal = null;
            BigDecimal actualPayrollTotal = null;
            for (ZonedDateTime date = fromZonedDate; !date.isAfter(toZonedDate); date = date.plusDays(1)) {
                long payDate = DateTimeUtils.parseToLong(date, BaseConstant.DATE_FORMAT_YYYYMMDD);

                BigDecimal schedulePayroll = payDate2ScheduleHourList.getOrDefault(payDate, Collections.emptyList()).stream()
                        .filter(item -> !excludePayCodeList.contains(item.getPayCode()))
                        .map(SvaScheduleHour::getPayroll).filter(Objects::nonNull).reduce(BigDecimal::add).orElse(null);

                BigDecimal actualPayroll = payDate2ActualHourList.getOrDefault(payDate, Collections.emptyList()).stream()
                        .filter(item -> !excludePayCodeList.contains(item.getPayCode()))
                        .map(SvaActualHour::getPayroll).filter(Objects::nonNull).reduce(BigDecimal::add).orElse(null);

                BigDecimal salaryPayroll = null;
                if (CollectionUtils.isNotEmpty(salaryPayConfigs)) {
                    // 获取适用当前日期的 salary pay config 设置的工资
                    salaryPayroll = getAppropriateSalaryPayMoney(salaryPayConfigs, date);
                }
                // 累加 salary pay
                if (Objects.nonNull(salaryPayroll)) {
                    if (Objects.isNull(schedulePayroll)) {
                        schedulePayroll = salaryPayroll;
                    } else {
                        schedulePayroll = schedulePayroll.add(salaryPayroll);
                    }
                    if (Objects.isNull(actualPayroll)) {
                        actualPayroll = salaryPayroll;
                    } else {
                        actualPayroll = actualPayroll.add(salaryPayroll);
                    }
                }

                SvaPayrollDTO payrollDTO = new SvaPayrollDTO();
                payrollDTO.setSchedule(schedulePayroll);
                payrollDTO.setActual(actualPayroll);

                payrollList.add(payrollDTO);

                // schedulePayrollTotal 统计
                if (Objects.nonNull(schedulePayroll)) {
                    if (Objects.isNull(schedulePayrollTotal)) {
                        schedulePayrollTotal = schedulePayroll;
                    } else {
                        schedulePayrollTotal = schedulePayrollTotal.add(schedulePayroll);
                    }
                }

                // actualPayrollTotal 统计
                if (Objects.nonNull(actualPayroll)) {
                    if (Objects.isNull(actualPayrollTotal)) {
                        actualPayrollTotal = actualPayroll;
                    } else {
                        actualPayrollTotal = actualPayrollTotal.add(actualPayroll);
                    }
                }
            }

            // 添加 Total
            payrollList.add(SvaPayrollDTO.builder()
                    .schedule(schedulePayrollTotal)
                    .actual(actualPayrollTotal)
                    .build());

            // 添加 Variances
            if (Objects.nonNull(schedulePayrollTotal) && Objects.nonNull(actualPayrollTotal)) {
                payrollList.add(SvaPayrollDTO.builder()
                        .variances(actualPayrollTotal.subtract(schedulePayrollTotal))
                        .build());
            } else {
                payrollList.add(new SvaPayrollDTO());
            }
            return payrollList;
        }

        /**
         * 生成 Daily Schedule vs. Actual hours
         *
         * @param fromZonedDate            开始日期
         * @param toZonedDate              结束日期
         * @param payDate2ScheduleHourList payDate 2 ScheduleHourList
         * @param payDate2ActualHourList   payDate 2 ActualHourList
         * @param excludePayCodeList       排除的 payCode 集合
         * @return Daily Schedule vs. Actual hours 集合
         * @author zc
         * @date 2023/6/24
         */
        private List<SvaHoursDTO> generateDailyHoursList(ZonedDateTime fromZonedDate, ZonedDateTime toZonedDate,
                                                         Map<Long, List<SvaScheduleHour>> payDate2ScheduleHourList,
                                                         Map<Long, List<SvaActualHour>> payDate2ActualHourList,
                                                         List<String> excludePayCodeList) {
            List<SvaHoursDTO> hoursList = Lists.newArrayListWithExpectedSize(9);
            BigDecimal scheduleHoursTotal = null;
            BigDecimal actualHoursTotal = null;
            for (ZonedDateTime date = fromZonedDate; !date.isAfter(toZonedDate); date = date.plusDays(1)) {
                long payDate = DateTimeUtils.parseToLong(date, BaseConstant.DATE_FORMAT_YYYYMMDD);

                BigDecimal currentDayScheduledHours = payDate2ScheduleHourList.getOrDefault(payDate, Collections.emptyList()).stream()
                        .filter(item -> !excludePayCodeList.contains(item.getPayCode()))
                        .map(SvaScheduleHour::getHours).filter(Objects::nonNull).reduce(BigDecimal::add).orElse(null);

                BigDecimal currentDayActualHours = payDate2ActualHourList.getOrDefault(payDate, Collections.emptyList()).stream()
                        .filter(item -> !excludePayCodeList.contains(item.getPayCode()))
                        .map(SvaActualHour::getHours).filter(Objects::nonNull).reduce(BigDecimal::add).orElse(null);

                SvaHoursDTO hoursDTO = new SvaHoursDTO();
                hoursDTO.setSchedule(currentDayScheduledHours);
                hoursDTO.setActual(currentDayActualHours);

                hoursList.add(hoursDTO);

                // scheduleHoursTotal 统计
                if (Objects.nonNull(currentDayScheduledHours)) {
                    if (Objects.isNull(scheduleHoursTotal)) {
                        scheduleHoursTotal = currentDayScheduledHours;
                    } else {
                        scheduleHoursTotal = scheduleHoursTotal.add(currentDayScheduledHours);
                    }
                }

                // actualHoursTotal 统计
                if (Objects.nonNull(currentDayActualHours)) {
                    if (Objects.isNull(actualHoursTotal)) {
                        actualHoursTotal = currentDayActualHours;
                    } else {
                        actualHoursTotal = actualHoursTotal.add(currentDayActualHours);
                    }
                }
            }

            // 添加 Total
            hoursList.add(SvaHoursDTO.builder()
                    .schedule(scheduleHoursTotal)
                    .actual(actualHoursTotal)
                    .build());

            // 添加 Variances
            if (Objects.nonNull(scheduleHoursTotal) && Objects.nonNull(actualHoursTotal)) {
                hoursList.add(SvaHoursDTO.builder()
                        .variances(actualHoursTotal.subtract(scheduleHoursTotal))
                        .build());
            } else {
                hoursList.add(new SvaHoursDTO());
            }
            return hoursList;
        }

        /**
         * 获取 Actual Hour、Schedule Hour 的 PositionId 集合
         *
         * @param scheduleHourList Schedule Hour 数据
         * @param actualHourList   Actual Hour 数据
         * @return PositionId 集合
         * @author zc
         * @date 2025/4/23
         */
        private Set<String> getActualSchedulePositionIds(List<SvaScheduleHour> scheduleHourList, List<SvaActualHour> actualHourList) {
            Set<String> positionIds = Sets.newHashSet();
            positionIds.addAll(scheduleHourList.stream().map(SvaScheduleHour::getPositionId).toList());
            positionIds.addAll(actualHourList.stream().map(SvaActualHour::getPositionId).toList());
            return positionIds;
        }

        /**
         * 获取 Actual Hour、Schedule Hour 的 DepartmentCategoryId 集合
         *
         * @param scheduleHourList Schedule Hour 数据
         * @param actualHourList   Actual Hour 数据
         * @return DepartmentCategoryId 集合
         * @author zc
         * @date 2025/4/23
         */
        private Set<Integer> getActualScheduleDepCategoryIds(List<SvaScheduleHour> scheduleHourList, List<SvaActualHour> actualHourList) {
            Set<Integer> depCategoryIds = Sets.newHashSet();
            depCategoryIds.addAll(scheduleHourList.stream().map(SvaScheduleHour::getDepCategoryId).toList());
            depCategoryIds.addAll(actualHourList.stream().map(SvaActualHour::getDepCategoryId).toList());
            return depCategoryIds;
        }

        /**
         * 生成 Employee summary
         *
         * @param propertyId                    资产 id
         * @param fromZonedDate                 开始日期
         * @param toZonedDate                   结束日期
         * @param scheduleHourList              Schedule Hour 集合
         * @param actualHourList                Actual Hour 集合
         * @param hasDataPermissionAccess       是否拥有数据权限
         * @param workHourDiffAbnormalThreshold 工时差值异常阈值
         * @return Employee summary 集合
         * @author zc
         * @date 2023/6/24
         */
        private List<SvaEmployeeSummaryDTO> generateEmployeeSummaryList(int propertyId, ZonedDateTime fromZonedDate, ZonedDateTime toZonedDate,
                                                                        List<SvaScheduleHour> scheduleHourList, List<SvaActualHour> actualHourList,
                                                                        List<String> excludeDepartmentCodes, boolean hasDataPermissionAccess,
                                                                        BigDecimal workHourDiffAbnormalThreshold) {
            // 获取 Actual Hour、Schedule Hour 的 PositionId 集合
            List<String> positionIds = Lists.newArrayList(getActualSchedulePositionIds(scheduleHourList, actualHourList));

            // 获取指定日期范围内 没有考勤、未隐藏、未离职或在选择日期后离职的员工数据（排除部门代码）
            List<AttendanceEmployee> noAttendanceEmployeeList = employeeService.listForNoAttendance(propertyId, positionIds,
                    fromZonedDate.toEpochSecond(), excludeDepartmentCodes);
            if (CollectionUtils.isEmpty(scheduleHourList) && CollectionUtils.isEmpty(actualHourList)
                    && CollectionUtils.isEmpty(noAttendanceEmployeeList)) {
                return Collections.emptyList();
            }

            // 获取 Actual Hour、Schedule Hour 的 DepartmentCategoryId 集合
            Set<Integer> departmentCategoryIdSet = getActualScheduleDepCategoryIds(scheduleHourList, actualHourList);
            departmentCategoryIdSet.addAll(noAttendanceEmployeeList.stream().map(AttendanceEmployee::getDepCategoryId).toList());
            List<Integer> departmentCategoryIdList = Lists.newArrayList(departmentCategoryIdSet);

            // 根据 depCategoryIds 获取分类信息
            Map<Integer, String> depId2DepName = departmentCategoryService.listByIds(departmentCategoryIdList).stream()
                    .collect(Collectors.toMap(DepartmentCategory::getId, DepartmentCategory::getName));

            // 获取评论数据
            Map<Long, Map<String, Map<Integer, List<SvaComment>>>> theDate2PositionId2CategoryId2Comments =
                    svaCommentService.listByDateRange(propertyId, fromZonedDate.toEpochSecond(), toZonedDate.toEpochSecond()).stream()
                            .collect(Collectors.groupingBy(SvaComment::getTheDate, Collectors.groupingBy(SvaComment::getPositionId,
                                    Collectors.groupingBy(SvaComment::getDeptCategoryId))));

            List<SvaEmployeeSummaryDTO> employeeSummaryList = Lists.newArrayList();

            // 根据 positionId 获取员工数据
            Map<String, AttendanceEmployee> positionId2Employee = employeeService.listByPositionIds(propertyId, positionIds, null).stream()
                    .collect(Collectors.toMap(AttendanceEmployee::getPositionId, Function.identity()));
            // 生成有考勤数据的 Employee summary
            employeeSummaryList.addAll(generateAttendanceEmployeeSummaryList(fromZonedDate, toZonedDate, scheduleHourList, actualHourList,
                    positionId2Employee, depId2DepName, theDate2PositionId2CategoryId2Comments, hasDataPermissionAccess, workHourDiffAbnormalThreshold));

            // 生成没有考勤数据的 Employee summary
            employeeSummaryList.addAll(generateNoAttendanceEmployeeSummaryList(fromZonedDate, toZonedDate, noAttendanceEmployeeList, depId2DepName,
                    theDate2PositionId2CategoryId2Comments, hasDataPermissionAccess));

            return employeeSummaryList;
        }

        /**
         * 合并 Schedule Hour、Actual Hour
         *
         * @param scheduleHourList Schedule Hour 数据
         * @param actualHourList   Actual Hour 数据
         * @return SvaHoursDetailMergeDTO 集合
         * @author zc
         * @date 2025/4/23
         */
        private List<SvaHoursDetailMergeDTO> mergeHoursList(List<SvaScheduleHour> scheduleHourList, List<SvaActualHour> actualHourList) {
            List<SvaHoursDetailMergeDTO> mergedList = new ArrayList<>();
            mergedList.addAll(scheduleHourList.stream().map(hour -> SvaHoursDetailMergeDTO.builder()
                    .positionId(hour.getPositionId()).depCategoryId(hour.getDepCategoryId())
                    .hours(hour.getHours()).type(SvaReportTypeCons.SCHEDULE_HOUR).payroll(hour.getPayroll())
                    .payDate(hour.getPayDate()).payCode(hour.getPayCode()).build()).toList());
            mergedList.addAll(actualHourList.stream().map(hour -> SvaHoursDetailMergeDTO.builder()
                    .positionId(hour.getPositionId()).depCategoryId(hour.getDepCategoryId())
                    .hours(hour.getHours()).type(SvaReportTypeCons.ACTUAL_HOUR).payroll(hour.getPayroll())
                    .payDate(hour.getPayDate()).payCode(hour.getPayCode()).build()).toList());
            return mergedList;
        }

        /**
         * 生成有考勤数据的 Employee summary
         *
         * @param fromZonedDate                          开始日期
         * @param toZonedDate                            结束日期
         * @param scheduleHourList                       Schedule Hour 集合
         * @param actualHourList                         Actual Hour 集合
         * @param positionId2Employee                    positionId 2 AttendanceEmployeeList
         * @param depId2DepName                          department id 2 department name
         * @param theDate2PositionId2CategoryId2Comments 评论数据
         * @param hasDataPermissionAccess                是否有数据权限 true：有数据权限；false：没有数据权限
         * @param workHourDiffAbnormalThreshold          工时差值异常阈值
         * @return Employee summary 集合
         * @author zc
         * @date 2023/6/26
         */
        private List<SvaEmployeeSummaryDTO> generateAttendanceEmployeeSummaryList(ZonedDateTime fromZonedDate, ZonedDateTime toZonedDate,
                                                                                  List<SvaScheduleHour> scheduleHourList, List<SvaActualHour> actualHourList,
                                                                                  Map<String, AttendanceEmployee> positionId2Employee, Map<Integer, String> depId2DepName,
                                                                                  Map<Long, Map<String, Map<Integer, List<SvaComment>>>> theDate2PositionId2CategoryId2Comments,
                                                                                  boolean hasDataPermissionAccess, BigDecimal workHourDiffAbnormalThreshold) {
            if (CollectionUtils.isEmpty(scheduleHourList) && CollectionUtils.isEmpty(actualHourList)) {
                return Collections.emptyList();
            }
            // 合并 Schedule Hour、Actual Hour
            List<SvaHoursDetailMergeDTO> hoursDetailMergeDTOList = mergeHoursList(scheduleHourList, actualHourList);
            Map<String, Map<Integer, List<SvaHoursDetailMergeDTO>>> positionId2DepId2Hours = hoursDetailMergeDTOList.stream()
                    .collect(Collectors.groupingBy(SvaHoursDetailMergeDTO::getPositionId,
                            Collectors.groupingBy(SvaHoursDetailMergeDTO::getDepCategoryId)));

            List<SvaEmployeeSummaryDTO> employeeSummaryList = Lists.newArrayList();
            for (Map.Entry<String, Map<Integer, List<SvaHoursDetailMergeDTO>>> entry : positionId2DepId2Hours.entrySet()) {
                String positionId = entry.getKey();
                // 员工时薪
                BigDecimal hourlyWage = null;
                // 员工名称
                String employeeName = null;
                // 离职日期
                Long terminationDate = null;
                AttendanceEmployee employee = positionId2Employee.get(positionId);
                if (Objects.nonNull(employee)) {
                    // 员工信息为空，表示导入的是异常考勤信息，需显示出来考勤信息
                    if (!employee.getShown()) {
                        // 当前员工设置了隐藏，不应该显示在 统计报表中
                        continue;
                    }
                    employeeName = employee.getEmpName();
                    terminationDate = employee.getTerminationDate();

                    // 若当前登录用户没有数据权限，则 hourly wage 设置为 null
                    hourlyWage = hasDataPermissionAccess ? employee.getHourlyRate() : null;
                }

                // 循环当前员工在不同分类下的考勤信息
                Map<Integer, List<SvaHoursDetailMergeDTO>> entryValue = entry.getValue();
                for (Map.Entry<Integer, List<SvaHoursDetailMergeDTO>> hoursEntry : entryValue.entrySet()) {
                    SvaEmployeeSummaryDTO summaryDTO = new SvaEmployeeSummaryDTO();
                    summaryDTO.setPositionId(positionId);
                    summaryDTO.setEmployeeName(employeeName);
                    summaryDTO.setTerminationDate(terminationDate);
                    summaryDTO.setHourlyWage(hourlyWage);

                    int depCategoryId = hoursEntry.getKey();
                    summaryDTO.setDepCategoryId(depCategoryId);
                    summaryDTO.setDepCategoryName(depId2DepName.getOrDefault(depCategoryId, DepartmentCategory.DEFAULT_CATEGORY_NAME));

                    // 生成员工 Daily hour
                    List<SvaDailyHourDTO> dailyHourList = generateAttendanceEmployeeDailyHourList(fromZonedDate, toZonedDate, positionId,
                            depCategoryId, hoursEntry.getValue(), theDate2PositionId2CategoryId2Comments, workHourDiffAbnormalThreshold);
                    summaryDTO.setDailyHourList(dailyHourList);

                    if (hasDataPermissionAccess) {
                        Map<Integer, List<SvaHoursDetailMergeDTO>> type2HoursDetailMergeList = hoursEntry.getValue().stream()
                                .collect(Collectors.groupingBy(SvaHoursDetailMergeDTO::getType));

                        // 计算 Scheduled Payroll、Actual Payroll
                        BigDecimal scheduledPayroll = type2HoursDetailMergeList.getOrDefault(SvaReportTypeCons.SCHEDULE_HOUR, Collections.emptyList()).stream()
                                .map(SvaHoursDetailMergeDTO::getPayroll).filter(Objects::nonNull).reduce(BigDecimal::add).orElse(null);
                        summaryDTO.setScheduledPayroll(scheduledPayroll);
                        BigDecimal actualPayroll = type2HoursDetailMergeList.getOrDefault(SvaReportTypeCons.ACTUAL_HOUR, Collections.emptyList()).stream()
                                .map(SvaHoursDetailMergeDTO::getPayroll).filter(Objects::nonNull).reduce(BigDecimal::add).orElse(null);
                        summaryDTO.setActualPayroll(actualPayroll);
                    }

                    employeeSummaryList.add(summaryDTO);
                }
            }
            return employeeSummaryList;
        }

        /**
         * 生成有考勤的员工 Daily hour
         *
         * @param fromZonedDate                          开始日期
         * @param toZonedDate                            结束日期
         * @param positionId                             position id
         * @param categoryId                             部门分类 id
         * @param hoursDetailMergeDTOList                Actual Hour、Schedule Hour 集合
         * @param theDate2PositionId2CategoryId2Comments 评论数据
         * @param workHourDiffAbnormalThreshold          工时差值异常阈值
         * @return 员工 Daily hour 集合
         * @author zc
         * @date 2023/6/26
         */
        private List<SvaDailyHourDTO> generateAttendanceEmployeeDailyHourList(ZonedDateTime fromZonedDate, ZonedDateTime toZonedDate,
                                                                              String positionId, int categoryId, List<SvaHoursDetailMergeDTO> hoursDetailMergeDTOList,
                                                                              Map<Long, Map<String, Map<Integer, List<SvaComment>>>> theDate2PositionId2CategoryId2Comments,
                                                                              BigDecimal workHourDiffAbnormalThreshold) {
            List<SvaDailyHourDTO> dailyHourList = Lists.newArrayListWithExpectedSize(8);
            Map<Long, Map<Integer, List<SvaHoursDetailMergeDTO>>> type2HoursByPayDate = hoursDetailMergeDTOList.stream()
                    .collect(Collectors.groupingBy(SvaHoursDetailMergeDTO::getPayDate,
                            Collectors.groupingBy(SvaHoursDetailMergeDTO::getType)));
            for (ZonedDateTime date = fromZonedDate; !date.isAfter(toZonedDate); date = date.plusDays(1)) {
                long payDate = DateTimeUtils.parseToLong(date, BaseConstant.DATE_FORMAT_YYYYMMDD);

                SvaDailyHourDTO dailyHourDTO = new SvaDailyHourDTO();
                Map<Integer, List<SvaHoursDetailMergeDTO>> type2Hours = type2HoursByPayDate.getOrDefault(payDate, Collections.emptyMap());
                List<SvaHoursDetailMergeDTO> scheduleHourMergeList = type2Hours.getOrDefault(SvaReportTypeCons.SCHEDULE_HOUR, Collections.emptyList());

                // 计划工时
                BigDecimal scheduleHours = scheduleHourMergeList.stream().map(SvaHoursDetailMergeDTO::getHours)
                        .filter(Objects::nonNull).reduce(BigDecimal::add).orElse(null);
                dailyHourDTO.setScheduleHours(scheduleHours);

                dailyHourDTO.setScheduleHoursList(scheduleHourMergeList.stream().map(item ->
                        new SvaHoursDetailDTO().setHours(item.getHours()).setPayCode(item.getPayCode())).toList());

                List<SvaHoursDetailMergeDTO> actualHourMergeList = type2Hours.getOrDefault(SvaReportTypeCons.ACTUAL_HOUR, Collections.emptyList());

                // 实际工时
                BigDecimal actualHours = actualHourMergeList.stream().map(SvaHoursDetailMergeDTO::getHours)
                        .filter(Objects::nonNull).reduce(BigDecimal::add).orElse(null);
                dailyHourDTO.setActualHours(actualHours);

                // 判断是否异常
                if (Objects.nonNull(scheduleHours) && Objects.nonNull(actualHours) && Objects.nonNull(workHourDiffAbnormalThreshold)) {
                    dailyHourDTO.setAbnormal(actualHours.subtract(scheduleHours).abs().compareTo(workHourDiffAbnormalThreshold) > 0);
                } else {
                    dailyHourDTO.setAbnormal(false);
                }

                dailyHourDTO.setActualHoursList(actualHourMergeList.stream().map(item ->
                        new SvaHoursDetailDTO().setHours(item.getHours()).setPayCode(item.getPayCode())).toList());

                // 是否有评论
                boolean hasComment = false;
                Map<String, Map<Integer, List<SvaComment>>> positionId2CategoryId2Comments = theDate2PositionId2CategoryId2Comments.get(date.toEpochSecond());
                if (Objects.nonNull(positionId2CategoryId2Comments)) {
                    Map<Integer, List<SvaComment>> commentsByCategoryId = positionId2CategoryId2Comments.get(positionId);
                    if (Objects.nonNull(commentsByCategoryId)) {
                        hasComment = CollectionUtils.isNotEmpty(commentsByCategoryId.get(categoryId));
                    }
                }
                dailyHourDTO.setHasComment(hasComment);

                dailyHourList.add(dailyHourDTO);
            }
            Map<Integer, List<SvaHoursDetailMergeDTO>> type2HoursDetailMergeList = hoursDetailMergeDTOList.stream()
                    .collect(Collectors.groupingBy(SvaHoursDetailMergeDTO::getType));
            // 计算 Scheduled Payroll、Actual Hours Total
            BigDecimal scheduleHoursTotal = type2HoursDetailMergeList.getOrDefault(SvaReportTypeCons.SCHEDULE_HOUR, Collections.emptyList()).stream()
                    .map(SvaHoursDetailMergeDTO::getHours).filter(Objects::nonNull).reduce(BigDecimal::add).orElse(null);

            BigDecimal actualHoursTotal = type2HoursDetailMergeList.getOrDefault(SvaReportTypeCons.ACTUAL_HOUR, Collections.emptyList()).stream()
                    .map(SvaHoursDetailMergeDTO::getHours).filter(Objects::nonNull).reduce(BigDecimal::add).orElse(null);

            // 添加 Total
            SvaDailyHourDTO dailyHourDTO = new SvaDailyHourDTO();
            dailyHourDTO.setScheduleHours(scheduleHoursTotal);
            dailyHourDTO.setActualHours(actualHoursTotal);
            dailyHourDTO.setAbnormal(false);
            dailyHourList.add(dailyHourDTO);

            return dailyHourList;
        }

        /**
         * 生成没有考勤数据的 Employee summary
         *
         * @param fromZonedDate                          开始日期
         * @param toZonedDate                            结束日期
         * @param depId2DepName                          department id 2 department name
         * @param theDate2PositionId2CategoryId2Comments 评论数据
         * @param hasDataPermissionAccess                是否有数据权限 true：有数据权限； false：没有数据权限
         * @return Employee summary 集合
         * @author zc
         * @date 2023/6/26
         */
        private List<SvaEmployeeSummaryDTO> generateNoAttendanceEmployeeSummaryList(ZonedDateTime fromZonedDate, ZonedDateTime toZonedDate,
                                                                                    List<AttendanceEmployee> noAttendanceEmployeeList,
                                                                                    Map<Integer, String> depId2DepName,
                                                                                    Map<Long, Map<String, Map<Integer, List<SvaComment>>>> theDate2PositionId2CategoryId2Comments,
                                                                                    boolean hasDataPermissionAccess) {
            if (CollectionUtils.isEmpty(noAttendanceEmployeeList)) {
                return Collections.emptyList();
            }

            List<SvaEmployeeSummaryDTO> employeeSummaryList = Lists.newArrayList();

            for (AttendanceEmployee employee : noAttendanceEmployeeList) {
                SvaEmployeeSummaryDTO summaryDTO = new SvaEmployeeSummaryDTO();
                summaryDTO.setPositionId(employee.getPositionId());
                summaryDTO.setEmployeeName(employee.getEmpName());
                summaryDTO.setTerminationDate(employee.getTerminationDate());
                summaryDTO.setHourlyWage(hasDataPermissionAccess ? employee.getHourlyRate() : null);
                summaryDTO.setDepCategoryId(employee.getDepCategoryId());
                summaryDTO.setDepCategoryName(depId2DepName.getOrDefault(employee.getDepCategoryId(), DepartmentCategory.DEFAULT_CATEGORY_NAME));

                // 生成没有考勤的员工 Daily hour
                List<SvaDailyHourDTO> dailyHourList = generateNoAttendanceEmployeeDailyHourList(fromZonedDate, toZonedDate, employee, theDate2PositionId2CategoryId2Comments);
                summaryDTO.setDailyHourList(dailyHourList);

                employeeSummaryList.add(summaryDTO);
            }
            return employeeSummaryList;
        }

        /**
         * 生成没有考勤的员工 Daily hour
         *
         * @param fromZonedDate                          开始日期
         * @param toZonedDate                            结束日期
         * @param employee                               员工信息
         * @param theDate2PositionId2CategoryId2Comments 评论数据
         * @return 员工 Daily hour 集合
         * @author zc
         * @date 2023/6/26
         */
        private List<SvaDailyHourDTO> generateNoAttendanceEmployeeDailyHourList(ZonedDateTime fromZonedDate, ZonedDateTime toZonedDate, AttendanceEmployee employee,
                                                                                Map<Long, Map<String, Map<Integer, List<SvaComment>>>> theDate2PositionId2CategoryId2Comments) {
            List<SvaDailyHourDTO> dailyHourList = Lists.newArrayListWithExpectedSize(8);
            for (ZonedDateTime date = fromZonedDate; !date.isAfter(toZonedDate); date = date.plusDays(1)) {
                SvaDailyHourDTO dailyHourDTO = new SvaDailyHourDTO();

                // 是否有评论
                boolean hasComment = false;
                Map<String, Map<Integer, List<SvaComment>>> positionId2CategoryId2Comments = theDate2PositionId2CategoryId2Comments.get(date.toEpochSecond());
                if (Objects.nonNull(positionId2CategoryId2Comments)) {
                    Map<Integer, List<SvaComment>> commentsByCategoryId = positionId2CategoryId2Comments.get(employee.getPositionId());
                    if (Objects.nonNull(commentsByCategoryId)) {
                        hasComment = CollectionUtils.isNotEmpty(commentsByCategoryId.get(employee.getDepCategoryId()));
                    }
                }
                dailyHourDTO.setHasComment(hasComment);
                dailyHourList.add(dailyHourDTO);
            }

            dailyHourList.add(new SvaDailyHourDTO());

            return dailyHourList;
        }

        /**
         * 生成导出数据内容
         *
         * @param propertyDTO 资产
         * @param query       查询条件
         * @param operatorId  操作人 id
         * @return org.jxls.common.Context
         * @author zc
         * @date 2025/2/7
         */
        @Override
        public HashMap<String, Object> generateContext(PropertyDTO propertyDTO, ScheduleVsActualQuery query, int operatorId) {
            // 国际化内容
            InternationalizationNumberDTO number = propertyDTO.getInternationalization().getNumber();
            RoundingMode roundingMode = InternationalizationUtils.getRoundingMode(number.getCalculationRule());

            // 获取分类统计工时信息
            List<SvaDepWeeklyStatisticsVO> hoursStatisticVOList = generateHoursStatisticsVOList(propertyDTO, query);
            // 获取一周展示日期
            List<SvaDateDTO> dateList = getDateList(propertyDTO, query);
            // 获取统计数据
            ScheduleVsActualVO scheduleVsActualVO = generateScheduleVsActualVO(propertyDTO, query, operatorId);
            // 获取VO日期范围
            String weeklyDateRange = generateVODateRange(propertyDTO, query);
            // 获取异常阈值
            SvaAbnormalThresholdDTO svaAbnormalThresholdDTO = abnormalThresholdService.getByPropertyId(propertyDTO.getId());
            // 销售差值异常阈值
            Integer salesDiffAbnormalThreshold = svaAbnormalThresholdDTO.getSalesDiffAbnormalThreshold();
            // 工时差值异常阈值
            BigDecimal workHourDiffAbnormalThreshold = svaAbnormalThresholdDTO.getWorkHourDiffAbnormalThreshold();
            BigDecimal formatWorkHourDiffAbnormalThreshold = null;
            if (Objects.nonNull(workHourDiffAbnormalThreshold)) {
                formatWorkHourDiffAbnormalThreshold = workHourDiffAbnormalThreshold.setScale(PrecisionCons.SCHEDULE_VS_ACTUAL_PRECISION, roundingMode);
            }

            HashMap<String, Object> data = new HashMap<>();
            data.put("scheduleVsActual", scheduleVsActualVO);
            data.put("hoursStatistics", hoursStatisticVOList);
            data.put("dateList", dateList);
            data.put("weeklyDateRange", weeklyDateRange);
            data.put("propertyName", propertyDTO.getName());
            data.put("propertyCode", attendancePropertyCodeInternalService.getCode(propertyDTO.getId()));
            data.put("salesDiffAbnormalThreshold", salesDiffAbnormalThreshold);
            data.put("workHourDiffAbnormalThreshold", formatWorkHourDiffAbnormalThreshold);
            return data;
        }

        /**
         * 获取VO日期范围
         *
         * @param propertyDTO 资产
         * @param query       查询条件
         * @return 日期范围
         * @author zc
         * @date 2025/2/8
         */
        private String generateVODateRange(PropertyDTO propertyDTO, ScheduleVsActualQuery query) {
            String timezone = propertyDTO.getTimezone();
            String language = propertyDTO.getLanguage();
            String pattern = propertyDTO.getInternationalization().getDateTime().getLongDate();

            String startDateStr = InternationalizationUtils.formatDateTime(query.getFromDate(),
                    pattern, StringUtils.EMPTY, timezone, language);
            String endDateStr = InternationalizationUtils.formatDateTime(query.getToDate(),
                    pattern, StringUtils.EMPTY, timezone, language);
            return startDateStr + " ~ " + endDateStr;
        }

        /**
         * 生成工时统计 VO 集合
         *
         * @param propertyDTO 资产
         * @param query       查询条件
         * @return SvaDepCategoryStatisticsVO 集合
         * @author zc
         * @date 2025/2/7
         */
        private List<SvaDepWeeklyStatisticsVO> generateHoursStatisticsVOList(PropertyDTO propertyDTO, ScheduleVsActualQuery query) {
            // 获取国际化数字配置
            String calculationRule = propertyDTO.getInternationalization().getNumber().getCalculationRule();
            String fromDate = DateTimeUtils.timestampToStr(query.getFromDate(), BaseConstant.DATE_FORMAT_YYYYMMDD, propertyDTO.getTimezone());
            String toDate = DateTimeUtils.timestampToStr(query.getToDate(), BaseConstant.DATE_FORMAT_YYYYMMDD, propertyDTO.getTimezone());
            return hoursStatisticsService.weeklyStatistics(propertyDTO, fromDate, toDate).getList().stream()
                    .map(item -> SvaDepWeeklyStatisticsVO.builder()
                            .departmentDesc(item.getDepartmentDesc())
                            .targetHours(formatValueStr(item.getTargetHours(), calculationRule, StringUtils.EMPTY))
                            .schedule(new SvaDepCategoryStatisticsValueVO(
                                    formatValueStr(item.getSchedule().getHours(), calculationRule, StringUtils.EMPTY),
                                    formatValueStr(item.getSchedule().getExcludePayCodeHours(), calculationRule, StringUtils.EMPTY)))
                            .actual(new SvaDepCategoryStatisticsValueVO(
                                    formatValueStr(item.getActual().getHours(), calculationRule, StringUtils.EMPTY),
                                    formatValueStr(item.getActual().getExcludePayCodeHours(), calculationRule, StringUtils.EMPTY)))
                            .saVariances(new SvaDepCategoryStatisticsValueVO(
                                    formatValueStr(item.getSaVariances().getVariances(), calculationRule, StringUtils.EMPTY),
                                    formatValueStr(item.getSaVariances().getExcludePayCodeVariances(), calculationRule, StringUtils.EMPTY)))
                            .arVariances(new SvaDepCategoryStatisticsValueVO(
                                    formatValueStr(item.getArVariances().getVariances(), calculationRule, StringUtils.EMPTY),
                                    formatValueStr(item.getArVariances().getExcludePayCodeVariances(), calculationRule, StringUtils.EMPTY)))
                            .srVariances(new SvaDepCategoryStatisticsValueVO(
                                    formatValueStr(item.getSrVariances().getVariances(), calculationRule, StringUtils.EMPTY),
                                    formatValueStr(item.getSrVariances().getExcludePayCodeVariances(), calculationRule, StringUtils.EMPTY)))
                            .build()
                    ).toList();
        }

        /**
         * 格式化金额为字符串
         *
         * @param value           数值
         * @param calculationRule 计数保留规则
         * @param currency        币种
         * @return java.lang.String
         * @author zc
         * @date 2025/5/29
         */
        private String formatAmountValueStr(Object value, String calculationRule, String currency) {
            String valueStr = InternationalizationUtils.formatAmountViewValue(value,
                    calculationRule, currency);
            return StringUtils.isNotBlank(valueStr) ? valueStr : "--";
        }

        /**
         * 格式化数字为字符串
         *
         * @param value           数值
         * @param calculationRule 计数保留规则
         * @param unit            单位符号
         * @return java.lang.String
         * @author zc
         * @date 2025/5/29
         */
        private String formatValueStr(Object value, String calculationRule, String unit) {
            String valueStr = InternationalizationUtils.formatViewValue(value,
                    PrecisionCons.SCHEDULE_VS_ACTUAL_PRECISION, calculationRule, unit);
            return StringUtils.isNotBlank(valueStr) ? valueStr : "--";
        }

        /**
         * 生成 SVA 统计数据 VO
         *
         * @param propertyDTO 资产
         * @param query       查询条件
         * @param operatorId  操作人 id
         * @return ScheduleVsActualVO
         * @author zc
         * @date 2025/2/7
         */
        private ScheduleVsActualVO generateScheduleVsActualVO(PropertyDTO propertyDTO, ScheduleVsActualQuery query, int operatorId) {
            // 获取国际化数字配置
            InternationalizationNumberDTO numberDTO = propertyDTO.getInternationalization().getNumber();
            String calculationRule = numberDTO.getCalculationRule();
            String currency = numberDTO.getCurrency();

            // 获取统计报表信息
            ScheduleVsActualDTO scheduleVsActualDTO = getScheduleVsActual(propertyDTO, query, operatorId);

            ScheduleVsActualVO scheduleVsActualVO = new ScheduleVsActualVO();
            // Forecasted Room Sale
            scheduleVsActualVO.setForecastedRoomSaleList(scheduleVsActualDTO.getForecastedRoomSaleList().stream().map(item ->
                    formatValueStr(item, calculationRule, StringUtils.EMPTY)).toList());
            // Occupancy
            scheduleVsActualVO.setOccPercentList(scheduleVsActualDTO.getOccPercentList().stream().map(item -> (Objects.isNull(item)) ?
                    StringUtils.EMPTY : formatValueStr(item, calculationRule, StringUtils.EMPTY) + "%").toList());
            // Arrival
            scheduleVsActualVO.setArrivalList(scheduleVsActualDTO.getArrivalList().stream().map(item ->
                    formatValueStr(item, calculationRule, StringUtils.EMPTY)).toList());
            // Dirty
            scheduleVsActualVO.setDirtyList(scheduleVsActualDTO.getDirtyList().stream().map(item ->
                    formatValueStr(item, calculationRule, StringUtils.EMPTY)).toList());
            // Departure
            scheduleVsActualVO.setDepartureList(scheduleVsActualDTO.getDepartureList().stream().map(item ->
                    formatValueStr(item, calculationRule, StringUtils.EMPTY)).toList());

            // Est/Actual F&B Revenue
            List<SvaFbRvDTO> fbRvList = scheduleVsActualDTO.getFbRvList();
            scheduleVsActualVO.setFbRvVariances(formatAmountValueStr(fbRvList.get(8).getVariances(), calculationRule, currency));
            fbRvList.remove(8);
            // F&B Revenue
            scheduleVsActualVO.setFbRvList(fbRvList.stream().map(item -> SvaScheduleActualValueVO.builder()
                    .actualValue(formatAmountValueStr(item.getActualFbRv(), calculationRule, currency))
                    .scheduleValue(formatAmountValueStr(item.getEstimatedFbRv(), calculationRule, currency))
                    .build()).toList());

            // Room sold total
            List<SvaRoomSoldDTO> roomSoldList = scheduleVsActualDTO.getRoomSoldList();
            scheduleVsActualVO.setRoomSoldTotal(SvaScheduleActualValueVO.builder()
                    .actualValue(formatValueStr(roomSoldList.get(7).getActualRoomSold(), calculationRule, StringUtils.EMPTY))
                    .scheduleValue(formatValueStr(roomSoldList.get(7).getVarianceRoomSold(), calculationRule, StringUtils.EMPTY))
                    .build());
            roomSoldList.remove(7);
            // Room sold
            scheduleVsActualVO.setRoomSoldList(roomSoldList.stream().map(item -> SvaScheduleActualValueVO.builder()
                    .actualValue(formatValueStr(item.getActualRoomSold(), calculationRule, StringUtils.EMPTY))
                    .scheduleValue(formatValueStr(item.getVarianceRoomSold(), calculationRule, StringUtils.EMPTY))
                    .build()).toList());
            // Adr variances
            List<SvaAdrDTO> adrList = scheduleVsActualDTO.getAdrList();
            scheduleVsActualVO.setAdrVariances(formatAmountValueStr(adrList.get(8).getVariances(), calculationRule, currency));
            adrList.remove(8);
            // Adr
            scheduleVsActualVO.setAdrList(adrList.stream().map(item -> SvaScheduleActualValueVO.builder()
                    .actualValue(formatAmountValueStr(item.getActualAdr(), calculationRule, currency))
                    .scheduleValue(formatAmountValueStr(item.getEstimatedAdr(), calculationRule, currency))
                    .build()).toList());

            // Est/Actual Room Rev variances
            List<SvaRoomRvDTO> roomRvList = scheduleVsActualDTO.getRoomRvList();
            scheduleVsActualVO.setRoomRvVariances(formatAmountValueStr(roomRvList.get(8).getVariances(), calculationRule, currency));
            roomRvList.remove(8);
            // Room Rev
            scheduleVsActualVO.setRoomRvList(roomRvList.stream().map(item -> SvaScheduleActualValueVO.builder()
                    .actualValue(formatAmountValueStr(item.getActualRoomRv(), calculationRule, currency))
                    .scheduleValue(formatAmountValueStr(item.getEstimatedRoomRv(), calculationRule, currency))
                    .build()).toList());

            // rev variances
            List<SvaRevDTO> revList = scheduleVsActualDTO.getRevList();
            scheduleVsActualVO.setRevVariances(formatAmountValueStr(revList.get(8).getVariances(), calculationRule, currency));
            revList.remove(8);
            // Rev
            scheduleVsActualVO.setRevList(revList.stream().map(item -> SvaScheduleActualValueVO.builder()
                    .actualValue(formatAmountValueStr(item.getActualRev(), calculationRule, currency))
                    .scheduleValue(formatAmountValueStr(item.getEstimatedRev(), calculationRule, currency))
                    .build()).toList());
            // Salary payroll
            scheduleVsActualVO.setSalaryPayrollList(scheduleVsActualDTO.getSalaryPayrollList().stream().map(item ->
                    formatAmountValueStr(item, calculationRule, currency)).toList());
            // Daily payroll variances
            List<SvaPayrollDTO> dailyPayrollList = scheduleVsActualDTO.getDailyPayrollList();
            scheduleVsActualVO.setDailyPayrollVariances(formatAmountValueStr(dailyPayrollList.get(8).getVariances(), calculationRule, currency));
            dailyPayrollList.remove(8);
            // Daily payroll
            scheduleVsActualVO.setDailyPayrollList(dailyPayrollList.stream().map(item -> SvaScheduleActualValueVO.builder()
                    .actualValue(formatAmountValueStr(item.getActual(), calculationRule, currency))
                    .scheduleValue(formatAmountValueStr(item.getSchedule(), calculationRule, currency))
                    .build()).toList());
            // Daily payroll (exclude pay code) variances
            List<SvaPayrollDTO> dailyPayrollExcludePayCodeList = scheduleVsActualDTO.getDailyPayrollExcludePayCodeList();
            scheduleVsActualVO.setDailyPayrollExcludePayCodeVariances(formatAmountValueStr(dailyPayrollExcludePayCodeList.get(8).getVariances(),
                    calculationRule, currency));
            dailyPayrollExcludePayCodeList.remove(8);
            // Daily payroll (exclude pay code)
            scheduleVsActualVO.setDailyPayrollExcludePayCodeList(dailyPayrollExcludePayCodeList.stream().map(item -> SvaScheduleActualValueVO.builder()
                    .actualValue(formatAmountValueStr(item.getActual(), calculationRule, currency))
                    .scheduleValue(formatAmountValueStr(item.getSchedule(), calculationRule, currency))
                    .build()).toList());
            // Hours list variances
            List<SvaHoursDTO> hoursList = scheduleVsActualDTO.getHoursList();
            scheduleVsActualVO.setHoursListVariances(formatValueStr(hoursList.get(8).getVariances(),
                    calculationRule, StringUtils.EMPTY));
            hoursList.remove(8);
            // Hours list
            scheduleVsActualVO.setHoursList(hoursList.stream().map(item -> SvaScheduleActualValueVO.builder()
                    .actualValue(formatValueStr(item.getActual(), calculationRule, StringUtils.EMPTY))
                    .scheduleValue(formatValueStr(item.getSchedule(), calculationRule, StringUtils.EMPTY))
                    .build()).toList());
            // Hours list (exclude pay code) variances
            List<SvaHoursDTO> hoursExcludePayCodeList = scheduleVsActualDTO.getHoursExcludePayCodeList();
            scheduleVsActualVO.setHoursListExcludePayCodeVariances(formatValueStr(hoursExcludePayCodeList.get(8).getVariances(),
                    calculationRule, StringUtils.EMPTY));
            hoursExcludePayCodeList.remove(8);
            // Hours list (exclude pay code)
            scheduleVsActualVO.setHoursExcludePayCodeList(hoursExcludePayCodeList.stream().map(item -> SvaScheduleActualValueVO.builder()
                    .actualValue(formatValueStr(item.getActual(), calculationRule, StringUtils.EMPTY))
                    .scheduleValue(formatValueStr(item.getSchedule(), calculationRule, StringUtils.EMPTY))
                    .build()).toList());

            List<SvaEmployeeSummaryDTO> employeeSummaryList = scheduleVsActualDTO.getEmployeeSummaryList();
            List<SvaEmployeeSummaryVO> employeeSummaryVOList = Lists.newArrayListWithExpectedSize(employeeSummaryList.size());
            // employee summary daily hour total
            List<SvaScheduleActualValueVO> dailyHourTotalList = Lists.newArrayListWithExpectedSize(employeeSummaryList.size());
            for (SvaEmployeeSummaryDTO summaryDTO : employeeSummaryList) {
                SvaDailyHourDTO dailyHourDTO = summaryDTO.getDailyHourList().get(7);
                dailyHourTotalList.add(SvaScheduleActualValueVO.builder()
                        .actualValue(formatValueStr(dailyHourDTO.getActualHours(), calculationRule, StringUtils.EMPTY))
                        .scheduleValue(formatValueStr(dailyHourDTO.getScheduleHours(), calculationRule, StringUtils.EMPTY))
                        .build());
                summaryDTO.getDailyHourList().remove(7);

                employeeSummaryVOList.add(SvaEmployeeSummaryVO.builder()
                        .positionId(summaryDTO.getPositionId())
                        .depCategoryName(summaryDTO.getDepCategoryName())
                        .employeeName(summaryDTO.getEmployeeName())
                        .dailyHourList(summaryDTO.getDailyHourList().stream().map(item ->
                                SvaScheduleActualValueVO.builder()
                                        .actualValue(formatValueStr(item.getActualHours(), calculationRule, StringUtils.EMPTY))
                                        .scheduleValue(formatValueStr(item.getScheduleHours(), calculationRule, StringUtils.EMPTY))
                                        .build()).toList())
                        .hourlyWage(formatAmountValueStr(summaryDTO.getHourlyWage(), calculationRule, currency))
                        .actualPayroll(formatAmountValueStr(summaryDTO.getActualPayroll(), calculationRule, currency))
                        .scheduledPayroll(formatAmountValueStr(summaryDTO.getScheduledPayroll(), calculationRule, currency))
                        .build());
            }
            scheduleVsActualVO.setEmployeeSummaryList(employeeSummaryVOList);
            scheduleVsActualVO.setDailyHourTotalList(dailyHourTotalList);

            return scheduleVsActualVO;
        }

        /**
         * 获取一周展示日期
         *
         * @param propertyDTO 资产
         * @param query       查询条件
         * @return DateDTO 集合
         * @author zc
         * @date 2023/6/29
         */
        private List<SvaDateDTO> getDateList(PropertyDTO propertyDTO, ScheduleVsActualQuery query) {
            InternationalizationDateTimeDTO dateTimeDTO = propertyDTO.getInternationalization().getDateTime();

            List<SvaDateDTO> dateList = Lists.newArrayListWithExpectedSize(7);
            DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern(dateTimeDTO.getLongDate(),
                    LocaleContextHolder.getLocale());

            ZonedDateTime startDate = DateTimeUtils.toZonedDateTime(query.getFromDate(), propertyDTO.getTimezone());
            ZonedDateTime endDate = DateTimeUtils.toZonedDateTime(query.getToDate(), propertyDTO.getTimezone());
            for (ZonedDateTime date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) {
                SvaDateDTO dateDTO = new SvaDateDTO();
                dateDTO.setDate(dateTimeFormatter.format(date));
                dateDTO.setWeek(date.getDayOfWeek().getDisplayName(TextStyle.FULL, LocaleContextHolder.getLocale()));

                dateList.add(dateDTO);
            }

            return dateList;
        }
    }