using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using NCC.Common.Enum; using NCC.Common.Filter; using NCC.Common.Helper; using NCC.Dependency; using NCC.DynamicApiController; using NCC.Extend.Entitys.Dto.LqSalary; using NCC.FriendlyException; using System.IO; using Yitter.IdGenerator; using NCC.Extend.Entitys.lq_attendance_summary; using NCC.Extend.Entitys.lq_jinsanjiao_user; using NCC.Extend.Entitys.lq_kd_jksyj; using NCC.Extend.Entitys.lq_kd_kdjlb; using NCC.Extend.Entitys.lq_md_target; using NCC.Extend.Entitys.lq_person_times_record; using NCC.Extend.Entitys.lq_salary_statistics; using NCC.Extend.Entitys.lq_xh_jksyj; using NCC.Extend.Entitys.lq_ycsd_jsj; using NCC.Extend.Entitys.lq_mdxx; using NCC.Extend.Entitys.lq_hytk_hytk; using NCC.Extend.Entitys.lq_hytk_jksyj; using NCC.Extend.Entitys.lq_md_xdbhsj; using NCC.Extend.Entitys.lq_salary_extra_calculation; using NCC.System.Entitys.Permission; using SqlSugar; using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; namespace NCC.Extend { /// /// 健康师薪酬服务 /// [ApiDescriptionSettings(Tag = "健康师薪酬服务", Name = "LqSalary", Order = 300)] [Route("api/Extend/[controller]")] public class LqSalaryService : IDynamicApiController, ITransient { private readonly ISqlSugarClient _db; private readonly ILogger _logger; /// /// 初始化一个类型的新实例 /// public LqSalaryService(ISqlSugarClient db, ILogger logger) { _db = db; _logger = logger; } /// /// 获取健康师工资列表 /// /// 查询参数 /// 健康师工资分页列表 [HttpGet("health-coach")] public async Task GetHealthCoachSalaryList([FromQuery] HealthCoachSalaryInput input) { var monthStr = $"{input.Year}{input.Month:D2}"; // 查询数据 var query = _db.Queryable() .Where(x => x.StatisticsMonth == monthStr); if (!string.IsNullOrEmpty(input.StoreId)) { query = query.Where(x => x.StoreId == input.StoreId); } if (!string.IsNullOrEmpty(input.Keyword)) { query = query.Where(x => x.EmployeeName.Contains(input.Keyword) || x.EmployeeId.Contains(input.Keyword)); } var list = await query.Select(x => new HealthCoachSalaryOutput { Id = x.Id, StoreId = x.StoreId, StoreName = x.StoreName, EmployeeId = x.EmployeeId, EmployeeName = x.EmployeeName, Position = x.Position, GoldTriangleId = x.GoldTriangleId, GoldTriangleTeam = x.GoldTriangleTeam, TotalPerformance = x.TotalPerformance, BasePerformance = x.BasePerformance, CooperationPerformance = x.CooperationPerformance, BaseRewardPerformance = x.BaseRewardPerformance, CooperationRewardPerformance = x.CooperationRewardPerformance, ActualBasePerformance = x.ActualBasePerformance, ActualCooperationPerformance = x.ActualCooperationPerformance, RewardPerformance = x.RewardPerformance, StoreTotalPerformance = x.StoreTotalPerformance, TeamPerformance = x.TeamPerformance, Percentage = x.Percentage, NewCustomerPerformance = x.NewCustomerPerformance, NewCustomerConversionRate = x.NewCustomerConversionRate, NewCustomerPoint = x.NewCustomerPoint, UpgradeCustomerCount = x.UpgradeCustomerCount, UpgradePerformance = x.UpgradePerformance, UpgradePoint = x.UpgradePoint, NewCustomerCommission = x.NewCustomerPerformanceCommission, UpgradeCommission = x.UpgradePerformanceCommission, OtherPerformanceAdd = x.OtherPerformanceAdd, OtherPerformanceSubtract = x.OtherPerformanceSubtract, Consumption = x.Consumption, ProjectCount = x.ProjectCount, CustomerCount = x.CustomerCount, WorkingDays = x.WorkingDays, LeaveDays = x.LeaveDays, CommissionPoint = x.CommissionPoint, BasePerformanceCommission = x.BasePerformanceCommission, CooperationPerformanceCommission = x.CooperationPerformanceCommission, ConsultantCommission = x.ConsultantCommission, StoreTZoneCommission = x.StoreTZoneCommission, TotalCommission = x.TotalCommission, HealthCoachBaseSalary = x.HealthCoachBaseSalary, HandworkFee = x.HandworkFee, OutherHandworkFee = x.OutherHandworkFee, TransportationAllowance = x.TransportationAllowance, LessRest = x.LessRest, FullAttendance = x.FullAttendance, CalculatedGrossSalary = x.CalculatedGrossSalary, GuaranteedSalary = x.GuaranteedSalary, GuaranteedLeaveDeduction = x.GuaranteedLeaveDeduction, GuaranteedBaseSalary = x.GuaranteedBaseSalary, GuaranteedSupplement = x.GuaranteedSupplement, FinalGrossSalary = x.FinalGrossSalary, MonthlyTrainingSubsidy = x.MonthlyTrainingSubsidy, MonthlyTransportSubsidy = x.MonthlyTransportSubsidy, LastMonthTrainingSubsidy = x.LastMonthTrainingSubsidy, LastMonthTransportSubsidy = x.LastMonthTransportSubsidy, TotalSubsidy = x.TotalSubsidy, MissingCard = x.MissingCard, LateArrival = x.LateArrival, LeaveDeduction = x.LeaveDeduction, SocialInsuranceDeduction = x.SocialInsuranceDeduction, RewardDeduction = x.RewardDeduction, AccommodationDeduction = x.AccommodationDeduction, StudyPeriodDeduction = x.StudyPeriodDeduction, WorkClothesDeduction = x.WorkClothesDeduction, TotalDeduction = x.TotalDeduction, Bonus = x.Bonus, ReturnPhoneDeposit = x.ReturnPhoneDeposit, ReturnAccommodationDeposit = x.ReturnAccommodationDeposit, ActualSalary = x.ActualSalary, MonthlyPaymentStatus = x.MonthlyPaymentStatus, PaidAmount = x.PaidAmount, PendingAmount = x.PendingAmount, LastMonthSupplement = x.LastMonthSupplement, MonthlyTotalPayment = x.MonthlyTotalPayment, StatisticsMonth = x.StatisticsMonth, IsLocked = x.IsLocked, EmployeeConfirmStatus = x.EmployeeConfirmStatus, CreateTime = x.CreateTime, CreateUser = x.CreateUser, UpdateTime = x.UpdateTime, UpdateUser = x.UpdateUser, IsNewStore = x.IsNewStore, NewStoreProtectionStage = x.NewStoreProtectionStage, StoreType = x.StoreType, StoreCategory = x.StoreCategory, DailyAverageConsumption = x.DailyAverageConsumption, DailyAverageProjectCount = x.DailyAverageProjectCount, TeamTotalConsumption = x.TeamTotalConsumption }) .ToPagedListAsync(input.currentPage, input.pageSize); return PageResult.SqlSugarPageResult(list); } /// /// 通过月份和员工ID查询工资(仅查询已锁定且未确认的工资) /// /// /// 根据年份、月份和员工ID查询对应员工的工资记录 /// /// **重要**:此接口只能查询已锁定(IsLocked=1)且未确认(EmployeeConfirmStatus=0或null)的工资记录 /// **注意**:已确认的工资记录无法通过此接口查询 /// /// 示例请求: /// ``` /// GET /api/Extend/lqsalary/query-by-employee?Year=2026&Month=1&EmployeeId=员工ID /// ``` /// /// 参数说明: /// - Year: 年份(必填) /// - Month: 月份(必填) /// - EmployeeId: 员工ID(必填) /// /// 查询参数 /// 工资记录详情 /// 查询成功,返回工资记录 /// 未找到对应的工资记录 [HttpGet("query-by-employee")] public async Task GetSalaryByEmployee([FromQuery] SalaryQueryByEmployeeInput input) { if (input.Year <= 0 || input.Month <= 0 || input.Month > 12) { throw NCCException.Oh("年份和月份参数不正确"); } if (string.IsNullOrWhiteSpace(input.EmployeeId)) { throw NCCException.Oh("员工ID不能为空"); } var monthStr = $"{input.Year}{input.Month:D2}"; var salary = await _db.Queryable() .Where(x => x.StatisticsMonth == monthStr && x.EmployeeId == input.EmployeeId && x.IsLocked == 1 && x.EmployeeConfirmStatus != 1) .Select(x => new HealthCoachSalaryOutput { Id = x.Id, StoreId = x.StoreId, StoreName = x.StoreName, EmployeeId = x.EmployeeId, EmployeeName = x.EmployeeName, Position = x.Position, GoldTriangleId = x.GoldTriangleId, GoldTriangleTeam = x.GoldTriangleTeam, TotalPerformance = x.TotalPerformance, BasePerformance = x.BasePerformance, CooperationPerformance = x.CooperationPerformance, BaseRewardPerformance = x.BaseRewardPerformance, CooperationRewardPerformance = x.CooperationRewardPerformance, ActualBasePerformance = x.ActualBasePerformance, ActualCooperationPerformance = x.ActualCooperationPerformance, RewardPerformance = x.RewardPerformance, StoreTotalPerformance = x.StoreTotalPerformance, TeamPerformance = x.TeamPerformance, Percentage = x.Percentage, NewCustomerPerformance = x.NewCustomerPerformance, NewCustomerConversionRate = x.NewCustomerConversionRate, NewCustomerPoint = x.NewCustomerPoint, UpgradeCustomerCount = x.UpgradeCustomerCount, UpgradePerformance = x.UpgradePerformance, UpgradePoint = x.UpgradePoint, NewCustomerCommission = x.NewCustomerPerformanceCommission, UpgradeCommission = x.UpgradePerformanceCommission, OtherPerformanceAdd = x.OtherPerformanceAdd, OtherPerformanceSubtract = x.OtherPerformanceSubtract, Consumption = x.Consumption, ProjectCount = x.ProjectCount, CustomerCount = x.CustomerCount, WorkingDays = x.WorkingDays, LeaveDays = x.LeaveDays, CommissionPoint = x.CommissionPoint, BasePerformanceCommission = x.BasePerformanceCommission, CooperationPerformanceCommission = x.CooperationPerformanceCommission, ConsultantCommission = x.ConsultantCommission, StoreTZoneCommission = x.StoreTZoneCommission, TotalCommission = x.TotalCommission, HealthCoachBaseSalary = x.HealthCoachBaseSalary, HandworkFee = x.HandworkFee, OutherHandworkFee = x.OutherHandworkFee, TransportationAllowance = x.TransportationAllowance, LessRest = x.LessRest, FullAttendance = x.FullAttendance, CalculatedGrossSalary = x.CalculatedGrossSalary, GuaranteedSalary = x.GuaranteedSalary, GuaranteedLeaveDeduction = x.GuaranteedLeaveDeduction, GuaranteedBaseSalary = x.GuaranteedBaseSalary, GuaranteedSupplement = x.GuaranteedSupplement, FinalGrossSalary = x.FinalGrossSalary, MonthlyTrainingSubsidy = x.MonthlyTrainingSubsidy, MonthlyTransportSubsidy = x.MonthlyTransportSubsidy, LastMonthTrainingSubsidy = x.LastMonthTrainingSubsidy, LastMonthTransportSubsidy = x.LastMonthTransportSubsidy, TotalSubsidy = x.TotalSubsidy, MissingCard = x.MissingCard, LateArrival = x.LateArrival, LeaveDeduction = x.LeaveDeduction, SocialInsuranceDeduction = x.SocialInsuranceDeduction, RewardDeduction = x.RewardDeduction, AccommodationDeduction = x.AccommodationDeduction, StudyPeriodDeduction = x.StudyPeriodDeduction, WorkClothesDeduction = x.WorkClothesDeduction, TotalDeduction = x.TotalDeduction, Bonus = x.Bonus, ReturnPhoneDeposit = x.ReturnPhoneDeposit, ReturnAccommodationDeposit = x.ReturnAccommodationDeposit, ActualSalary = x.ActualSalary, MonthlyPaymentStatus = x.MonthlyPaymentStatus, PaidAmount = x.PaidAmount, PendingAmount = x.PendingAmount, LastMonthSupplement = x.LastMonthSupplement, MonthlyTotalPayment = x.MonthlyTotalPayment, StatisticsMonth = x.StatisticsMonth, IsLocked = x.IsLocked, EmployeeConfirmStatus = x.EmployeeConfirmStatus, CreateTime = x.CreateTime, CreateUser = x.CreateUser, UpdateTime = x.UpdateTime, UpdateUser = x.UpdateUser, IsNewStore = x.IsNewStore, NewStoreProtectionStage = x.NewStoreProtectionStage, StoreType = x.StoreType, StoreCategory = x.StoreCategory, DailyAverageConsumption = x.DailyAverageConsumption, DailyAverageProjectCount = x.DailyAverageProjectCount, TeamTotalConsumption = x.TeamTotalConsumption }) .FirstAsync(); if (salary == null) { throw NCCException.Oh($"未找到员工{input.EmployeeId}在{input.Year}年{input.Month}月的工资记录"); } return salary; } #region 计算工资 /// /// 计算健康师工资 /// /// 年份 /// 月份 /// [HttpPost("calculate/health-coach")] public async Task CalculateHealthCoachSalary(int year, int month) { var startDate = new DateTime(year, month, 1); var endDate = startDate.AddMonths(1).AddDays(-1); var monthStr = $"{year}{month:D2}"; // 1. 获取基础数据 // 1.1 业绩数据 (lq_kd_jksyj) var performanceList = await _db.Queryable() .Where(x => x.Yjsj >= startDate && x.Yjsj <= endDate.AddDays(1) && x.IsEffective == 1) .ToListAsync(); // 1.1.1 获取关联的开单记录(用于获取 sfskdd) var billingIds = performanceList.Select(x => x.Glkdbh).Distinct().ToList(); var billingDict = await _db.Queryable() .Where(x => billingIds.Contains(x.Id) && x.Id != null) .ToDictionaryAsync(x => x.Id, x => x.Sfskdd); // 1.1.2 组合数据 var performanceData = performanceList.Select(p => new { Jks = p.Jkszh, // 使用 Jkszh (账号/ID) 而不是 Jks (姓名) p.Jksxm, p.StoreId, p.Jksyj, p.ItemCategory, p.PerformanceType, // 新增业绩类型字段 Sfskdd = billingDict.ContainsKey(p.Glkdbh) ? billingDict[p.Glkdbh] : null }).ToList(); // 1.2 消耗数据 (lq_xh_jksyj) var consumptionList = await _db.Queryable() .Where(x => x.Yjsj >= startDate && x.Yjsj <= endDate.AddDays(1) && x.IsEffective == 1) .ToListAsync(); // 1.3 考勤数据 (lq_attendance_summary) var attendanceList = await _db.Queryable() .Where(x => x.Year == year && x.Month == month && x.IsEffective == 1) .ToListAsync(); // 1.3.1 退款数据 (lq_hytk_jksyj) var refundList = await _db.Queryable() .Where(x => x.Tksj >= startDate && x.Tksj <= endDate.AddDays(1) && x.IsEffective == 1) .ToListAsync(); // 1.4 战队成员及顾问信息 (lq_jinsanjiao_user + lq_ycsd_jsj) var teamUserList = await _db.Queryable() .Where(x => x.Month == monthStr && x.DeleteMark == 0) .ToListAsync(); // 1.4.1 获取战队信息 var teamIds = teamUserList.Select(x => x.JsjId).Distinct().ToList(); var teamList = await _db.Queryable() .Where(x => teamIds.Contains(x.Id)) .ToListAsync(); var teamDict = teamList.Where(x => !string.IsNullOrEmpty(x.Id)).ToDictionary(x => x.Id, x => x.Jsj); // 1.4.2 组合数据 var teamMembers = teamUserList.Select(user => new { user.UserId, user.IsLeader, TeamId = user.JsjId, TeamName = teamDict.ContainsKey(user.JsjId) ? teamDict[user.JsjId] : (string)null }).ToList(); // 1.5 到店人头 (lq_person_times_record) // 统计每个健康师的去重会员数 var headcountList = await _db.Queryable() .Where(x => x.WorkMonth == monthStr && x.IsEffective == 1) .GroupBy(x => x.PersonId) .Select(x => new { PersonId = x.PersonId, Count = SqlFunc.AggregateDistinctCount(x.MemberId) }) .ToListAsync(); // 1.6 门店生命线 (lq_md_target) var storeTargets = await _db.Queryable() .Where(x => x.Month == monthStr) .ToListAsync(); // 1.6.1 门店总业绩计算 (开单实付 - 退款金额) // 开单实付 var storeBillingList = await _db.Queryable() .Where(x => x.Kdrq >= startDate && x.Kdrq <= endDate.AddDays(1) && x.IsEffective == 1) .Select(x => new { x.Djmd, x.Sfyj }) .ToListAsync(); var storeBillingDict = storeBillingList .Where(x => !string.IsNullOrEmpty(x.Djmd)) .GroupBy(x => x.Djmd) .ToDictionary(g => g.Key, g => g.Sum(x => x.Sfyj)); // 退款金额 var storeRefundList = await _db.Queryable() .Where(x => x.Tksj >= startDate && x.Tksj <= endDate.AddDays(1) && x.IsEffective == 1) .Select(x => new { x.Mdbh, x.Tkje, x.ActualRefundAmount }) .ToListAsync(); var storeRefundDict = storeRefundList .Where(x => !string.IsNullOrEmpty(x.Mdbh)) .GroupBy(x => x.Mdbh) .ToDictionary(g => g.Key, g => g.Sum(x => x.ActualRefundAmount ?? 0)); // 1.7 门店信息 (lq_mdxx) var storeList = await _db.Queryable().ToListAsync(); var storeDict = storeList.Where(x => !string.IsNullOrEmpty(x.Id)).ToDictionary(x => x.Id, x => x); // 1.7.1 门店新店保护信息 (lq_md_xdbhsj) var newStoreProtectionList = await _db.Queryable() .Where(x => x.Sfqy == 1) .ToListAsync(); // 构造新店保护查找字典: StoreId -> List // 因为一个门店可能有多个阶段配置,虽然通常是一个时间段,但为了严谨取符合当前月份的配置 // 逻辑: 统计月份 (startDate ~ endDate) 是否在 bhkssj ~ bhjssj 范围内 // 只要统计月份与保护期有交集,就算保护期。或者严格一点,统计月份的第一天在保护期内。 // 这里取: 统计月份的第一天 (startDate) 在保护期内 var newStoreProtectionDict = newStoreProtectionList .Where(x => x.Bhkssj <= startDate && x.Bhjssj >= startDate) .GroupBy(x => x.Mdid) .ToDictionary(g => g.Key, g => g.First()); // 取第一个匹配的配置 // 1.8 健康师工资额外计算数据 (lq_salary_extra_calculation) var extraCalculationList = await _db.Queryable() .Where(x => x.Year == year && x.Month == month) .ToListAsync(); var extraCalculationDict = extraCalculationList .Where(x => !string.IsNullOrEmpty(x.EmployeeId)) .ToDictionary(x => x.EmployeeId, x => x); // 2. 聚合每个健康师的数据对象 var employeeStats = new Dictionary(); // 获取所有涉及的健康师ID var allEmployeeIds = performanceData.Select(x => x.Jks) .Union(consumptionList.Select(x => x.Jks)) .Union(attendanceList.Select(x => x.UserId)) .Union(teamMembers.Select(x => x.UserId)) .Where(x => !string.IsNullOrEmpty(x)) .Distinct() .ToList(); // 1.8 批量获取员工信息 (BASE_USER + BASE_POSITION) // 使用 allEmployeeIds 作为驱动,查询 BASE_USER // 重要:只统计岗位为"健康师"的员工 var userList = await _db.Queryable() .Where(x => allEmployeeIds.Contains(x.Id) && x.Gw == "健康师" // 只统计岗位为"健康师"的员工 && x.DeleteMark == null && x.EnabledMark == 1) .Select(x => new { x.Id, x.RealName, x.PositionId, x.Mdid }) .ToListAsync(); // 过滤出健康师ID列表 var healthCoachIds = userList.Select(x => x.Id).ToList(); var userDict = userList.ToDictionary(x => x.Id, x => x); var positionIds = userList.Select(x => x.PositionId).Distinct().ToList(); var positionList = await _db.Queryable().Where(x => positionIds.Contains(x.Id)).ToListAsync(); var positionLookup = positionList.Where(x => !string.IsNullOrEmpty(x.Id)).ToDictionary(x => x.Id, x => x.FullName); // 只处理健康师员工 foreach (var empId in allEmployeeIds.Where(x => healthCoachIds.Contains(x))) { var salary = new LqSalaryStatisticsEntity { Id = YitIdHelper.NextId().ToString(), EmployeeId = empId, StatisticsMonth = monthStr, CreateTime = DateTime.Now, UpdateTime = DateTime.Now, IsLocked = 0 }; // 填充基础信息 (优先从 BASE_USER 获取) string userMdid = null; if (userDict.ContainsKey(empId)) { var user = userDict[empId]; salary.EmployeeName = user.RealName; userMdid = user.Mdid; // 岗位 if (user.PositionId != null && positionLookup.ContainsKey(user.PositionId)) { salary.Position = positionLookup[user.PositionId]; } } // 如果 BASE_USER 没名字,尝试从业务表获取 if (string.IsNullOrEmpty(salary.EmployeeName)) { var perfRecord = performanceData.FirstOrDefault(x => x.Jks == empId); var consRecord = consumptionList.FirstOrDefault(x => x.Jks == empId); if (perfRecord != null) salary.EmployeeName = perfRecord.Jksxm; else if (consRecord != null) salary.EmployeeName = consRecord.Jksxm; } // 填充门店ID (从业务数据获取,因为 User 表的 OrganizeId 未必是门店) var perfStore = performanceData.FirstOrDefault(x => x.Jks == empId && !string.IsNullOrEmpty(x.StoreId)); var consStore = consumptionList.FirstOrDefault(x => x.Jks == empId && !string.IsNullOrEmpty(x.StoreId)); if (perfStore != null) salary.StoreId = perfStore.StoreId; else if (consStore != null) salary.StoreId = consStore.StoreId; // 如果业务数据没门店,尝试使用 User.Mdid if (string.IsNullOrEmpty(salary.StoreId) && !string.IsNullOrEmpty(userMdid)) { if (storeDict.ContainsKey(userMdid)) { salary.StoreId = userMdid; } } // 填充门店名称及分类信息 if (!string.IsNullOrEmpty(salary.StoreId) && storeDict.ContainsKey(salary.StoreId)) { var store = storeDict[salary.StoreId]; salary.StoreName = store.Dm; salary.StoreType = store.StoreType; salary.StoreCategory = store.StoreCategory; } // 填充新店保护信息 if (!string.IsNullOrEmpty(salary.StoreId) && newStoreProtectionDict.ContainsKey(salary.StoreId)) { var protection = newStoreProtectionDict[salary.StoreId]; salary.IsNewStore = "是"; salary.NewStoreProtectionStage = protection.Stage; } else { salary.IsNewStore = "否"; salary.NewStoreProtectionStage = 0; } // 2.1 计算个人业绩 var myPerf = performanceData.Where(x => x.Jks == empId).ToList(); salary.BasePerformance = myPerf.Where(x => (x.PerformanceType ?? "").Trim() == "基础业绩").Sum(x => decimal.Parse(x.Jksyj ?? "0")); salary.CooperationPerformance = myPerf.Where(x => (x.PerformanceType ?? "").Trim() == "合作业绩").Sum(x => decimal.Parse(x.Jksyj ?? "0")); salary.TotalPerformance = myPerf.Sum(x => decimal.Parse(x.Jksyj ?? "0")); // 扣除退款 var myRefunds = refundList.Where(x => x.Jks == empId).ToList(); if (myRefunds.Any()) { decimal totalRefund = myRefunds.Sum(x => x.Jksyj ?? 0); decimal baseRefund = myRefunds.Where(x => (x.PerformanceType ?? "").Trim() == "基础业绩").Sum(x => x.Jksyj ?? 0); decimal cooperationRefund = myRefunds.Where(x => (x.PerformanceType ?? "").Trim() == "合作业绩").Sum(x => x.Jksyj ?? 0); // 如果退款记录未标记类型,且总退款大于0,则可能需要处理(当前暂不处理未分类退款的明细扣除,仅扣除总额) // 修正:如果PerformanceType为空,应当从总业绩扣除。若要从Base或Coop扣除,需确认业务规则。 // 假设:如果未分类,暂时不从Base/Coop扣(或者按比例扣?)。 // 根据现有数据,有部分是null。 // 安全起见,TotalPerformance 减去 totalRefund。 // Base 和 Coop 减去各自明确标识的部分。 salary.TotalPerformance -= totalRefund; salary.BasePerformance -= baseRefund; salary.CooperationPerformance -= cooperationRefund; } // 新客与升单业绩 salary.NewCustomerPerformance = myPerf.Where(x => string.Equals(x.Sfskdd, "是")).Sum(x => decimal.Parse(x.Jksyj ?? "0")); salary.UpgradePerformance = myPerf.Where(x => string.Equals(x.Sfskdd, "否")).Sum(x => decimal.Parse(x.Jksyj ?? "0")); // 2.1.1 填充额外计算数据 if (extraCalculationDict.ContainsKey(empId)) { var extraData = extraCalculationDict[empId]; salary.BaseRewardPerformance = extraData.BaseRewardPerformance; salary.CooperationRewardPerformance = extraData.CooperationRewardPerformance; salary.OtherPerformanceAdd = extraData.OtherPerformanceAdd; salary.OtherPerformanceSubtract = extraData.OtherPerformanceSubtract; salary.UpgradeCustomerCount = extraData.UpgradeCustomerCount; salary.NewCustomerConversionRate = extraData.NewCustomerConversionRate; salary.NewCustomerPerformance = extraData.NewCustomerPerformance; salary.UpgradePerformance = extraData.UpgradePerformance; // 调整总业绩:总业绩 = 基础业绩 + 合作业绩 + 基础奖励业绩 - 合作奖励业绩 + 其他业绩加 - 其他业绩减 // 注意:合作奖励业绩是"负奖励"概念,正数表示减少,负数表示增加 // 确保后续计算(包括金三角战队业绩)使用的是调整后的总业绩 salary.TotalPerformance += salary.BaseRewardPerformance; salary.TotalPerformance -= salary.CooperationRewardPerformance; // 合作奖励业绩是负奖励,需要减去 salary.TotalPerformance += salary.OtherPerformanceAdd; salary.TotalPerformance -= salary.OtherPerformanceSubtract; } // 2.1.2 计算实际基础业绩和实际合作业绩 // 定义新店相关变量,供后续多处使用 bool isNewStore = salary.IsNewStore == "是"; int newStoreStage = salary.NewStoreProtectionStage; // 实际基础业绩 = 基础业绩 - 基础奖励业绩 + 其他业绩加 - 其他业绩减 decimal actualBasePerformance = salary.BasePerformance - salary.BaseRewardPerformance + salary.OtherPerformanceAdd - salary.OtherPerformanceSubtract; // 新店额外调整:根据阶段扣除新客业绩或升单业绩 if (isNewStore) { if (newStoreStage == 1) { // 第一阶段:扣除新客业绩 actualBasePerformance -= salary.NewCustomerPerformance; } else if (newStoreStage == 2) { // 第二阶段:扣除升单业绩 actualBasePerformance -= salary.UpgradePerformance; } } salary.ActualBasePerformance = actualBasePerformance; // 实际合作业绩 = 合作业绩 - 合作奖励业绩 // 注意:合作奖励业绩是"负奖励"概念,正数表示减少,负数表示增加 // 例如:40000 表示减少合作业绩 40000,-20000 表示增加合作业绩 20000 salary.ActualCooperationPerformance = salary.CooperationPerformance - salary.CooperationRewardPerformance; // 2.2 计算消耗和项目数 var myCons = consumptionList.Where(x => x.Jks == empId).ToList(); salary.Consumption = myCons.Sum(x => x.Jksyj ?? 0); salary.ProjectCount = myCons.Sum(x => x.KdpxNumber ?? 0); salary.HandworkFee = myCons.Sum(x => x.LaborCost ?? 0); // 使用 F_LaborCost // 2.3 考勤数据 var myAtt = attendanceList.FirstOrDefault(x => x.UserId == empId); salary.WorkingDays = myAtt?.WorkDays ?? 0; salary.LeaveDays = myAtt?.LeaveDays ?? 0; // 计算日均消耗和日均项目数 (用于底薪计算) // 逻辑: 总消耗/在店天数, 总项目数/在店天数 if (salary.WorkingDays > 0) { salary.DailyAverageConsumption = salary.Consumption / salary.WorkingDays; salary.DailyAverageProjectCount = salary.ProjectCount / salary.WorkingDays; } else { salary.DailyAverageConsumption = 0; salary.DailyAverageProjectCount = 0; } // 2.4 到店人头 var myHeadcount = headcountList.FirstOrDefault(x => x.PersonId == empId); salary.CustomerCount = myHeadcount?.Count ?? 0; // 2.5 战队信息 (初始) var myTeam = teamMembers.FirstOrDefault(x => x.UserId == empId); // 初始判断岗位:如果是战队队长(IsLeader=1)则是顾问,否则是健康师 // 注意:这里先根据战队设置判断,后续考勤不足21天会降级 if (myTeam != null && myTeam.IsLeader == 1) { salary.Position = "顾问"; } else if (string.IsNullOrEmpty(salary.Position)) // 如果BASE_USER没岗位,且不是队长,默认为健康师 { salary.Position = "健康师"; } if (myTeam != null) { salary.GoldTriangleId = myTeam.TeamId; salary.GoldTriangleTeam = myTeam.TeamName ?? "个人"; } else { salary.GoldTriangleTeam = "个人"; } // 2.6 门店总业绩 if (!string.IsNullOrEmpty(salary.StoreId)) { decimal billing = storeBillingDict.ContainsKey(salary.StoreId) ? storeBillingDict[salary.StoreId] : 0; decimal refund = storeRefundDict.ContainsKey(salary.StoreId) ? storeRefundDict[salary.StoreId] : 0; salary.StoreTotalPerformance = billing - refund; } employeeStats[empId] = salary; } // 3. 处理战队逻辑 (考勤规则) // 规则:若出勤天数 < 20天,则该健康师不计入战队,按单人计算。 // 按战队分组 var teamGroups = employeeStats.Values .Where(x => !string.IsNullOrEmpty(x.GoldTriangleId)) .GroupBy(x => x.GoldTriangleId) .ToList(); foreach (var group in teamGroups) { var validMembers = new List(); var invalidMembers = new List(); foreach (var member in group) { if (member.WorkingDays >= 20) { validMembers.Add(member); } else { invalidMembers.Add(member); } } // 对于无效成员,移除战队标识,视为单人,并重置岗位为健康师 foreach (var member in invalidMembers) { member.GoldTriangleId = null; member.GoldTriangleTeam = "个人"; member.Position = "健康师"; // 降级为健康师 } // 计算有效战队的总业绩和总消耗 var teamTotalPerformance = validMembers.Sum(x => x.TotalPerformance); var teamTotalConsumption = validMembers.Sum(x => x.Consumption); // 更新有效成员的战队业绩和战队总消耗 foreach (var member in validMembers) { member.TeamPerformance = teamTotalPerformance; member.TeamTotalConsumption = teamTotalConsumption; } } // 补充处理:对于没有战队ID的(个人,或被剔除的),战队业绩等于个人总业绩 foreach (var salary in employeeStats.Values) { if (string.IsNullOrEmpty(salary.GoldTriangleId)) { salary.TeamPerformance = salary.TotalPerformance; } } // 4. 计算薪资 (底薪 & 提成) foreach (var salary in employeeStats.Values) { // 定义新店相关变量,供底薪和提成计算使用 bool isNewStore = salary.IsNewStore == "是"; int newStoreStage = salary.NewStoreProtectionStage; // 4.1 底薪计算 // 4.1 底薪计算 // 传入当月天数用于计算标准日均 int daysInMonth = DateTime.DaysInMonth(year, month); salary.HealthCoachBaseSalary = CalculateBaseSalary( salary.DailyAverageConsumption, salary.DailyAverageProjectCount, daysInMonth, salary.WorkingDays, isNewStore); // 4.2 提成计算 // 业绩门槛: 战队成员个人总业绩 <= 6000 无提成 (需按日均计算) // 规则:战队成员日均业绩 <= 6000 / 当月天数 -> 无提成 decimal memberThreshold = 6000m; if (daysInMonth > 0 && salary.WorkingDays > 0) { memberThreshold = (6000m / daysInMonth) * salary.WorkingDays; } if (!string.IsNullOrEmpty(salary.GoldTriangleId) && salary.TotalPerformance < memberThreshold) // 修正为小于校验 { salary.TotalCommission = 0; salary.BasePerformanceCommission = 0; salary.CooperationPerformanceCommission = 0; salary.ConsultantCommission = 0; salary.NewCustomerPerformanceCommission = 0; salary.UpgradePerformanceCommission = 0; } else { // 确定提成点 decimal commissionPoint = 0; if (!string.IsNullOrEmpty(salary.GoldTriangleId)) { // 是战队成员 // 获取战队人数 (注意:这里应该是有效战队人数) var teamMemberCount = employeeStats.Values.Count(x => x.GoldTriangleId == salary.GoldTriangleId); // 注意:提成点按原始基础业绩计算,不是实际基础业绩 // 战队成员不按日均考核提成点,只考核个人门槛 commissionPoint = GetTeamCommissionPoint(teamMemberCount, salary.TeamPerformance, daysInMonth, salary.WorkingDays); } else { // 单人 (或被剔除出战队) // 注意:提成点按原始总业绩计算 // 单人按日均考核提成点 commissionPoint = GetTeamCommissionPoint(1, salary.TotalPerformance, daysInMonth, salary.WorkingDays); } salary.CommissionPoint = commissionPoint; // 计算基础/合作提成(使用实际业绩) salary.BasePerformanceCommission = salary.ActualBasePerformance * 0.95m * commissionPoint; salary.CooperationPerformanceCommission = salary.ActualCooperationPerformance * 0.95m * 0.65m * commissionPoint; // 计算新客转化率提成和升单人头提成(根据新店阶段) // isNewStore 和 newStoreStage 已在上面定义 // 升单提点总是赋值(根据升单人头数) decimal upgradeCommissionRate = GetUpgradeCommissionRate(salary.UpgradeCustomerCount); salary.UpgradePoint = upgradeCommissionRate; if (isNewStore) { if (newStoreStage == 1) { // 第一阶段:计算新客转化率提成 (需乘以0.95) salary.NewCustomerPerformanceCommission = CalculateNewCustomerConversionCommission( salary.NewCustomerPerformance, salary.NewCustomerConversionRate) * 0.95m; } else if (newStoreStage == 2) { // 第二阶段:计算升单人头提成 (需乘以0.95) salary.UpgradePerformanceCommission = CalculateUpgradeCustomerCommission( salary.UpgradePerformance, salary.UpgradeCustomerCount) * 0.95m; } // 第三阶段:不计算新客/升单提成 } // 计算顾问提成 // 检查是否是顾问 // 注意:这里需要重新判断是否是顾问,因为可能被降级了 if (salary.Position == "顾问" && !string.IsNullOrEmpty(salary.GoldTriangleId)) { // 获取战队人数 var teamMemberList = employeeStats.Values.Where(x => x.GoldTriangleId == salary.GoldTriangleId).ToList(); var teamMemberCount = teamMemberList.Count; // 单人或者一个人的战队就没有顾问 if (teamMemberCount > 1) { salary.ConsultantCommission = CalculateConsultantCommission( salary.TeamPerformance, teamMemberList, teamMemberCount, isNewStore); } else { salary.ConsultantCommission = 0; } } // 计算门店T区提成 // 规则:姓名包含"T区" -> 门店总业绩 * 0.05 * 0.05 if (!string.IsNullOrEmpty(salary.EmployeeName) && salary.EmployeeName.Contains("T区")) { salary.StoreTZoneCommission = salary.StoreTotalPerformance * 0.05m * 0.05m; // T区人员仅核算提成,其他项(底薪、手工、社保等)归零 salary.HealthCoachBaseSalary = 0; salary.HandworkFee = 0; salary.BasePerformanceCommission = 0; salary.CooperationPerformanceCommission = 0; salary.ConsultantCommission = 0; salary.NewCustomerPerformanceCommission = 0; salary.UpgradePerformanceCommission = 0; salary.TotalSubsidy = 0; salary.TotalDeduction = 0; } salary.TotalCommission = salary.BasePerformanceCommission + salary.CooperationPerformanceCommission + salary.ConsultantCommission + salary.NewCustomerPerformanceCommission + salary.UpgradePerformanceCommission + salary.StoreTZoneCommission; } // 计算占比 if (salary.TeamPerformance > 0) { salary.Percentage = salary.TotalPerformance / salary.TeamPerformance; } else if (salary.TotalPerformance > 0 && string.IsNullOrEmpty(salary.GoldTriangleId)) { salary.Percentage = 1; // 单人占比100% } // 4.3 最终工资 salary.CalculatedGrossSalary = salary.HealthCoachBaseSalary + salary.TotalCommission + salary.HandworkFee + salary.TotalSubsidy - salary.TotalDeduction; salary.ActualSalary = salary.HealthCoachBaseSalary + salary.TotalCommission + salary.HandworkFee + salary.TotalSubsidy - salary.TotalDeduction; } // 5. 保存数据 if (employeeStats.Any()) { // 5.1 先删除计算月的未锁定且未确认的工资记录 var deletedCount = await _db.Deleteable() .Where(x => x.StatisticsMonth == monthStr && x.IsLocked == 0 && x.EmployeeConfirmStatus == 0) .ExecuteCommandAsync(); if (deletedCount > 0) { _logger.LogInformation($"计算工资前删除了 {deletedCount} 条未锁定且未确认的记录(月份:{monthStr})"); } // 5.2 查询已存在的记录(只查询已锁定或已确认的记录) var existingRecords = await _db.Queryable() .Where(x => x.StatisticsMonth == monthStr && (x.IsLocked == 1 || x.EmployeeConfirmStatus == 1)) .ToListAsync(); var existingDict = existingRecords .Where(x => !string.IsNullOrEmpty(x.EmployeeId)) .GroupBy(x => x.EmployeeId) .ToDictionary(g => g.Key, g => g.First()); // 分离需要插入的新记录和需要更新的记录 var recordsToInsert = new List(); var recordsToUpdate = new List(); var updatedCount = 0; var skippedCount = 0; foreach (var salary in employeeStats.Values) { if (existingDict.ContainsKey(salary.EmployeeId)) { // 检查记录是否已锁定或已确认 var existing = existingDict[salary.EmployeeId]; // 如果已锁定或已确认,跳过不更新(保留所有原有数据,包括扣款项目) if (existing.IsLocked == 1 || existing.EmployeeConfirmStatus == 1) { skippedCount++; continue; // 跳过,不进行任何更新 } // 未锁定且未确认的记录,可以做更新操作 salary.Id = existing.Id; salary.EmployeeConfirmStatus = existing.EmployeeConfirmStatus; // 应该是0 salary.EmployeeConfirmTime = existing.EmployeeConfirmTime; salary.EmployeeConfirmRemark = existing.EmployeeConfirmRemark; salary.IsLocked = existing.IsLocked; // 保留锁定状态(应该是0) salary.CreateTime = existing.CreateTime; salary.CreateUser = existing.CreateUser; recordsToUpdate.Add(salary); updatedCount++; } else { // 不存在的记录,做插入操作 salary.Id = YitIdHelper.NextId().ToString(); salary.EmployeeConfirmStatus = 0; salary.IsLocked = 0; salary.CreateTime = DateTime.Now; salary.CreateUser = ""; // 新记录,创建人为空或系统 recordsToInsert.Add(salary); } } // 批量插入新记录 if (recordsToInsert.Any()) { await _db.Insertable(recordsToInsert).ExecuteCommandAsync(); _logger.LogInformation($"插入了 {recordsToInsert.Count} 条新的工资记录(月份:{monthStr})"); } // 批量更新现有记录 if (recordsToUpdate.Any()) { await _db.Updateable(recordsToUpdate).ExecuteCommandAsync(); _logger.LogInformation($"更新了 {recordsToUpdate.Count} 条未锁定且未确认的工资记录(月份:{monthStr})"); } if (skippedCount > 0) { _logger.LogInformation($"跳过了 {skippedCount} 条已锁定或已确认的工资记录,保留原有数据(月份:{monthStr})"); } } } #endregion #region 员工工资确认 /// /// 员工确认工资条 /// /// /// 员工确认自己的工资条,确认后工资数据不可再修改 /// /// 示例请求: /// /// { /// "id": "工资记录ID", /// "employeeId": "员工ID", /// "remark": "确认备注(可选)" /// } /// /// /// 参数说明: /// - id: 工资记录ID(必填) /// - employeeId: 员工ID(必填) /// - remark: 确认备注(可选) /// /// 注意事项: /// - 只能确认自己的工资条 /// - 只能确认已锁定的工资条(IsLocked = 1) /// - 已确认的工资条不能重复确认 /// /// 确认参数 /// 操作结果 /// 确认成功 /// 参数错误或记录不存在 [HttpPost("confirm")] public async Task ConfirmSalary([FromBody] SalaryConfirmInput input) { try { if (string.IsNullOrWhiteSpace(input.Id)) { throw NCCException.Oh("工资记录ID不能为空"); } if (string.IsNullOrWhiteSpace(input.EmployeeId)) { throw NCCException.Oh("员工ID不能为空"); } // 查询工资记录 var salary = await _db.Queryable() .Where(s => s.Id == input.Id && s.EmployeeId == input.EmployeeId) .FirstAsync(); if (salary == null) { throw NCCException.Oh("工资记录不存在或不属于该员工"); } // 检查是否已确认 if (salary.EmployeeConfirmStatus == 1) { throw NCCException.Oh("该工资条已确认,不能重复确认"); } // 检查是否已锁定(员工只能确认已锁定的工资条) if (salary.IsLocked != 1) { throw NCCException.Oh("该工资条尚未锁定,请等待管理员锁定后再确认"); } // 更新确认状态 salary.EmployeeConfirmStatus = 1; salary.EmployeeConfirmTime = DateTime.Now; salary.EmployeeConfirmRemark = input.Remark; // 注意:IsLocked 保持为 1(因为本来就是管理员锁定的) salary.UpdateTime = DateTime.Now; await _db.Updateable(salary).ExecuteCommandAsync(); return "确认成功"; } catch (Exception ex) { throw NCCException.Oh($"确认工资条失败: {ex.Message}"); } } #endregion #region 工资锁定/解锁 /// /// 批量锁定/解锁工资条 /// /// /// 管理员批量锁定或解锁工资条 /// /// 示例请求: /// ```json /// { /// "ids": ["工资记录ID1", "工资记录ID2"], /// "isLocked": true /// } /// ``` /// /// 参数说明: /// - ids: 工资记录ID列表(必填) /// - isLocked: 是否锁定(true=锁定,false=解锁) /// /// 锁定参数 /// 操作结果 /// 锁定/解锁成功 /// 参数错误或记录不存在 [HttpPost("lock")] public async Task LockSalary([FromBody] SalaryLockInput input) { try { if (input == null || input.Ids == null || !input.Ids.Any()) { throw NCCException.Oh("工资记录ID列表不能为空"); } // 查询工资记录 var salaries = await _db.Queryable() .Where(s => input.Ids.Contains(s.Id)) .ToListAsync(); if (!salaries.Any()) { throw NCCException.Oh("未找到指定的工资记录"); } var lockedCount = 0; var unlockedCount = 0; var skippedCount = 0; foreach (var salary in salaries) { // 如果已确认,不能解锁 if (salary.EmployeeConfirmStatus == 1 && !input.IsLocked) { skippedCount++; continue; } salary.IsLocked = input.IsLocked ? 1 : 0; salary.UpdateTime = DateTime.Now; if (input.IsLocked) { lockedCount++; } else { unlockedCount++; } } // 批量更新 await _db.Updateable(salaries).ExecuteCommandAsync(); var action = input.IsLocked ? "锁定" : "解锁"; var count = input.IsLocked ? lockedCount : unlockedCount; var message = $"{action}成功:{count}条"; if (skippedCount > 0) { message += $",跳过{skippedCount}条(已确认的记录不能解锁)"; } return message; } catch (Exception ex) { throw NCCException.Oh($"锁定/解锁工资条失败: {ex.Message}"); } } /// /// 批量锁定当月所有工资 /// /// /// 批量锁定指定月份的所有健康师工资记录 /// /// 示例请求: /// ```json /// { /// "year": 2025, /// "month": 12, /// "isLocked": true /// } /// ``` /// /// 批量锁定输入参数 /// 锁定结果 [HttpPost("lock-by-month")] public async Task LockSalaryByMonth([FromBody] SalaryLockByMonthInput input) { try { if (input == null) throw NCCException.Oh("参数不能为空"); if (input.Year <= 0 || input.Month <= 0 || input.Month > 12) throw NCCException.Oh("年份和月份参数不正确"); var monthStr = $"{input.Year}{input.Month:D2}"; var salaries = await _db.Queryable() .Where(s => s.StatisticsMonth == monthStr) .ToListAsync(); if (!salaries.Any()) throw NCCException.Oh($"未找到{input.Year}年{input.Month}月的工资记录"); var lockedCount = 0; var unlockedCount = 0; var skippedCount = 0; var alreadyLockedCount = 0; foreach (var salary in salaries) { if (salary.EmployeeConfirmStatus == 1 && !input.IsLocked) { skippedCount++; continue; } if (salary.IsLocked == 1 && input.IsLocked) { alreadyLockedCount++; continue; } if (salary.IsLocked == 0 && !input.IsLocked) { alreadyLockedCount++; continue; } salary.IsLocked = input.IsLocked ? 1 : 0; salary.UpdateTime = DateTime.Now; if (input.IsLocked) lockedCount++; else unlockedCount++; } if (lockedCount > 0 || unlockedCount > 0) { var salariesToUpdate = salaries.Where(s => (input.IsLocked && s.IsLocked == 0) || (!input.IsLocked && s.IsLocked == 1 && s.EmployeeConfirmStatus != 1) ).ToList(); if (salariesToUpdate.Any()) { await _db.Updateable(salariesToUpdate) .UpdateColumns(s => new { s.IsLocked, s.UpdateTime }) .ExecuteCommandAsync(); } } var action = input.IsLocked ? "锁定" : "解锁"; var count = input.IsLocked ? lockedCount : unlockedCount; var message = $"{action}成功:{count}条"; if (alreadyLockedCount > 0) message += $",跳过{alreadyLockedCount}条(已是{action}状态)"; if (skippedCount > 0) message += $",跳过{skippedCount}条(已确认的记录不能解锁)"; return new { success = true, message = message, total = salaries.Count, locked = lockedCount, unlocked = unlockedCount, skipped = skippedCount, alreadyLocked = alreadyLockedCount }; } catch (Exception ex) { _logger.LogError(ex, "批量锁定当月工资失败"); var action = input?.IsLocked == true ? "锁定" : "解锁"; throw NCCException.Oh($"批量{action}当月工资失败: {ex.Message}"); } } #endregion #region 导入工资 /// /// 从Excel导入健康师工资数据 /// /// /// 从Excel文件导入健康师工资数据,Excel第一列必须是ID(主键) /// /// 导入规则: /// 1. Excel第一列是ID(主键),如果为空则自动生成新ID /// 2. 如果ID在数据库中存在,则更新记录(需检查是否已锁定或已确认) /// 3. 如果ID在数据库中不存在,则新增记录 /// 4. 已锁定(IsLocked=1)或已确认(EmployeeConfirmStatus=1)的记录不能导入覆盖 /// /// Excel字段顺序(第一列为ID): /// ID, 门店名称, 员工姓名, 岗位, 金三角战队, ...(共77列) /// /// Excel文件 /// 导入结果 /// 导入成功 /// 文件格式错误或数据验证失败 [HttpPost("import")] public async Task ImportSalaryFromExcel(IFormFile file) { try { if (file == null || file.Length == 0) { throw NCCException.Oh("请选择要上传的Excel文件"); } // 检查文件格式 var allowedExtensions = new[] { ".xlsx", ".xls" }; var fileExtension = Path.GetExtension(file.FileName).ToLowerInvariant(); if (!allowedExtensions.Contains(fileExtension)) { throw NCCException.Oh("只支持.xlsx和.xls格式的Excel文件"); } var recordsToInsert = new List(); var recordsToUpdate = new List(); var errorMessages = new List(); var successCount = 0; var failCount = 0; var skippedCount = 0; // 保存临时文件 var tempFilePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString() + Path.GetExtension(file.FileName)); try { using (var stream = new FileStream(tempFilePath, FileMode.Create)) { await file.CopyToAsync(stream); } // 使用ExcelImportHelper读取Excel文件 // 参数说明:0表示第一个工作表,0表示第一行是标题行 var dataTable = ExcelImportHelper.ToDataTable(tempFilePath, 0, 0); if (dataTable.Rows.Count == 0) { throw NCCException.Oh("Excel文件中没有数据行"); } // ExcelImportHelper.ToDataTable(filePath, 0, 0)会将第一行作为标题行,数据从第二行开始 // 所以DataTable.Rows[0]是Excel的第一条数据行,应该从i=0开始循环 for (int i = 0; i < dataTable.Rows.Count; i++) { try { var row = dataTable.Rows[i]; // 安全获取列值 Func GetColumnValue = (colIndex) => { if (colIndex < row.ItemArray.Length) { return row[colIndex]?.ToString()?.Trim() ?? ""; } return ""; }; // 第一列是ID(Excel第一列应该是ID) var id = GetColumnValue(0); // 判断Excel格式:如果第一列是"门店名称"等中文,说明是旧格式(没有ID列) var firstColumnValue = GetColumnValue(0); bool isOldFormat = false; if (!string.IsNullOrWhiteSpace(firstColumnValue) && (firstColumnValue == "门店名称" || firstColumnValue.Contains("门店") || (!string.IsNullOrWhiteSpace(firstColumnValue) && !long.TryParse(firstColumnValue, out _) && firstColumnValue.Length > 20))) { // 旧格式:第一列是"门店名称"标题行或数据,不是ID isOldFormat = true; id = ""; // ID为空,需要根据员工姓名和门店名称匹配 } // 根据Excel格式确定字段索引 int storeNameIndex = isOldFormat ? 0 : 1; // 门店名称索引 int employeeNameIndex = isOldFormat ? 1 : 2; // 员工姓名索引 // 跳过空行(ID、员工姓名都为空) var employeeName = GetColumnValue(employeeNameIndex); var storeName = GetColumnValue(storeNameIndex); if (string.IsNullOrWhiteSpace(id) && string.IsNullOrWhiteSpace(employeeName)) { continue; } // 如果ID为空,尝试根据员工姓名和门店名称匹配现有记录的ID if (string.IsNullOrWhiteSpace(id) && !string.IsNullOrWhiteSpace(employeeName)) { var matchedRecord = await _db.Queryable() .Where(x => x.EmployeeName == employeeName) .WhereIF(!string.IsNullOrWhiteSpace(storeName), x => x.StoreName == storeName) .OrderBy(x => x.CreateTime, OrderByType.Desc) .FirstAsync(); if (matchedRecord != null) { id = matchedRecord.Id; } } // 验证必填字段 if (string.IsNullOrWhiteSpace(employeeName)) { errorMessages.Add($"第{i + 1}行:员工姓名不能为空"); failCount++; continue; } // 辅助方法:清理数值字符串 Func ParseDecimal = (str) => { if (string.IsNullOrWhiteSpace(str)) return 0; var cleaned = str.Trim() .Replace(",", "") .Replace(",", "") .Replace("¥", "") .Replace("$", "") .Replace("元", "") .Replace("%", "") .Replace(" ", ""); if (decimal.TryParse(cleaned, out decimal result)) return result; return 0; }; Func ParseInt = (str) => { if (string.IsNullOrWhiteSpace(str)) return 0; var cleaned = str.Trim().Replace(",", "").Replace(",", "").Replace(" ", ""); if (int.TryParse(cleaned, out int result)) return result; return 0; }; // 如果Excel中有ID,查找现有记录 LqSalaryStatisticsEntity existing = null; if (!string.IsNullOrWhiteSpace(id)) { existing = await _db.Queryable() .Where(x => x.Id == id) .FirstAsync(); if (existing != null) { // 检查是否已锁定或已确认 if (existing.IsLocked == 1) { errorMessages.Add($"第{i + 1}行:员工 {existing.EmployeeName} (ID: {id}) 的工资已锁定,不能导入覆盖"); skippedCount++; failCount++; continue; } if (existing.EmployeeConfirmStatus == 1) { errorMessages.Add($"第{i + 1}行:员工 {existing.EmployeeName} (ID: {id}) 的工资已确认,不能导入覆盖"); skippedCount++; failCount++; continue; } } } // 创建或更新实体 LqSalaryStatisticsEntity entity; if (existing != null) { // 更新现有记录 entity = existing; // 导入后重置确认状态(如果被覆盖) entity.EmployeeConfirmStatus = 0; entity.EmployeeConfirmTime = null; entity.EmployeeConfirmRemark = null; } else { // 新增记录 entity = new LqSalaryStatisticsEntity { Id = string.IsNullOrWhiteSpace(id) ? YitIdHelper.NextId().ToString() : id, EmployeeConfirmStatus = 0, IsLocked = 0, CreateTime = DateTime.Now, UpdateTime = DateTime.Now }; } // 映射Excel字段到实体属性(根据Excel列顺序) // 注意:Excel第一列是ID(索引0),第二列是门店名称(索引1),第三列是员工姓名(索引2)... entity.StoreName = storeName; entity.EmployeeName = employeeName; entity.Position = GetColumnValue(2); // Excel第3列(索引2) entity.GoldTriangleTeam = GetColumnValue(3); // Excel第4列(索引3) // 根据Excel格式计算字段索引偏移量 int offset = isOldFormat ? 0 : 1; // 如果是新格式(第一列是ID),所有业务字段索引+1 // 映射Excel字段到实体属性 // Excel列顺序(新格式):ID, 门店名称, 员工姓名, 岗位, 金三角战队, 总业绩(5+offset), ... entity.Position = GetColumnValue(2 + offset); // 岗位 entity.GoldTriangleTeam = GetColumnValue(3 + offset); // 金三角战队 entity.TotalPerformance = ParseDecimal(GetColumnValue(4 + offset)); // 总业绩 entity.BasePerformance = ParseDecimal(GetColumnValue(5 + offset)); entity.CooperationPerformance = ParseDecimal(GetColumnValue(6 + offset)); // 跳过索引7+offset(奖励业绩),实际基础业绩和实际合作业绩在索引8+offset和9+offset entity.ActualBasePerformance = ParseDecimal(GetColumnValue(8 + offset)); entity.ActualCooperationPerformance = ParseDecimal(GetColumnValue(9 + offset)); entity.NewCustomerPerformance = ParseDecimal(GetColumnValue(10 + offset)); entity.UpgradePerformance = ParseDecimal(GetColumnValue(11 + offset)); entity.BaseRewardPerformance = ParseDecimal(GetColumnValue(12 + offset)); entity.CooperationRewardPerformance = ParseDecimal(GetColumnValue(13 + offset)); entity.OtherPerformanceAdd = ParseDecimal(GetColumnValue(14 + offset)); entity.OtherPerformanceSubtract = ParseDecimal(GetColumnValue(15 + offset)); entity.StoreTotalPerformance = ParseDecimal(GetColumnValue(16 + offset)); entity.TeamPerformance = ParseDecimal(GetColumnValue(17 + offset)); entity.Percentage = ParseDecimal(GetColumnValue(18 + offset)); entity.Consumption = ParseDecimal(GetColumnValue(19 + offset)); entity.ProjectCount = ParseInt(GetColumnValue(20 + offset)); entity.CustomerCount = ParseInt(GetColumnValue(21 + offset)); entity.WorkingDays = ParseDecimal(GetColumnValue(22 + offset)); entity.LeaveDays = ParseDecimal(GetColumnValue(23 + offset)); entity.DailyAverageConsumption = ParseDecimal(GetColumnValue(24 + offset)); entity.DailyAverageProjectCount = ParseDecimal(GetColumnValue(25 + offset)); entity.TeamTotalConsumption = ParseDecimal(GetColumnValue(26 + offset)); entity.NewCustomerConversionRate = ParseDecimal(GetColumnValue(27 + offset)); entity.NewCustomerPoint = ParseDecimal(GetColumnValue(28 + offset)); entity.NewCustomerPerformanceCommission = ParseDecimal(GetColumnValue(29 + offset)); entity.UpgradeCustomerCount = ParseInt(GetColumnValue(30 + offset)); entity.UpgradePoint = ParseDecimal(GetColumnValue(31 + offset)); entity.UpgradePerformanceCommission = ParseDecimal(GetColumnValue(32 + offset)); entity.CommissionPoint = ParseDecimal(GetColumnValue(33 + offset)); entity.BasePerformanceCommission = ParseDecimal(GetColumnValue(34 + offset)); entity.CooperationPerformanceCommission = ParseDecimal(GetColumnValue(35 + offset)); entity.ConsultantCommission = ParseDecimal(GetColumnValue(36 + offset)); entity.StoreTZoneCommission = ParseDecimal(GetColumnValue(37 + offset)); entity.TotalCommission = ParseDecimal(GetColumnValue(38 + offset)); entity.HealthCoachBaseSalary = ParseDecimal(GetColumnValue(39 + offset)); entity.HandworkFee = ParseDecimal(GetColumnValue(40 + offset)); entity.OutherHandworkFee = ParseDecimal(GetColumnValue(41 + offset)); entity.CalculatedGrossSalary = ParseDecimal(GetColumnValue(42 + offset)); entity.GuaranteedSalary = ParseDecimal(GetColumnValue(43 + offset)); entity.GuaranteedLeaveDeduction = ParseDecimal(GetColumnValue(44 + offset)); entity.GuaranteedBaseSalary = ParseDecimal(GetColumnValue(45 + offset)); entity.GuaranteedSupplement = ParseDecimal(GetColumnValue(46 + offset)); entity.FinalGrossSalary = ParseDecimal(GetColumnValue(47 + offset)); entity.TransportationAllowance = ParseDecimal(GetColumnValue(48 + offset)); entity.LessRest = ParseDecimal(GetColumnValue(49 + offset)); entity.FullAttendance = ParseDecimal(GetColumnValue(50 + offset)); entity.MonthlyTrainingSubsidy = ParseDecimal(GetColumnValue(51 + offset)); entity.MonthlyTransportSubsidy = ParseDecimal(GetColumnValue(52 + offset)); entity.LastMonthTrainingSubsidy = ParseDecimal(GetColumnValue(53 + offset)); entity.LastMonthTransportSubsidy = ParseDecimal(GetColumnValue(54 + offset)); entity.TotalSubsidy = ParseDecimal(GetColumnValue(55 + offset)); entity.MissingCard = ParseDecimal(GetColumnValue(56 + offset)); entity.LateArrival = ParseDecimal(GetColumnValue(57 + offset)); entity.LeaveDeduction = ParseDecimal(GetColumnValue(58 + offset)); entity.SocialInsuranceDeduction = ParseDecimal(GetColumnValue(59 + offset)); entity.RewardDeduction = ParseDecimal(GetColumnValue(60 + offset)); entity.AccommodationDeduction = ParseDecimal(GetColumnValue(61 + offset)); entity.StudyPeriodDeduction = ParseDecimal(GetColumnValue(62 + offset)); entity.WorkClothesDeduction = ParseDecimal(GetColumnValue(63 + offset)); entity.TotalDeduction = ParseDecimal(GetColumnValue(64 + offset)); entity.Bonus = ParseDecimal(GetColumnValue(65 + offset)); entity.ReturnPhoneDeposit = ParseDecimal(GetColumnValue(66 + offset)); entity.ReturnAccommodationDeposit = ParseDecimal(GetColumnValue(67 + offset)); entity.LastMonthSupplement = ParseDecimal(GetColumnValue(68 + offset)); entity.ActualSalary = ParseDecimal(GetColumnValue(69 + offset)); entity.MonthlyPaymentStatus = GetColumnValue(70 + offset); entity.PaidAmount = ParseDecimal(GetColumnValue(71 + offset)); entity.PendingAmount = ParseDecimal(GetColumnValue(72 + offset)); entity.MonthlyTotalPayment = ParseDecimal(GetColumnValue(73 + offset)); // 处理"是否新店"字段 var isNewStoreStr = GetColumnValue(74 + offset); entity.IsNewStore = (isNewStoreStr == "是" || isNewStoreStr == "1" || isNewStoreStr.ToLower() == "true") ? "是" : "否"; // 处理"新店保护阶段"字段 var newStoreProtectionStageStr = GetColumnValue(75 + offset); if (int.TryParse(newStoreProtectionStageStr, out int protectionStage)) { entity.NewStoreProtectionStage = protectionStage; } // 注意:Excel中的"锁定状态"字段不应该从Excel导入,因为锁定状态应该由管理员操作 // 导入时只读取,但不更新到数据库 // 处理Excel中没有的字段:StoreId、EmployeeId、StatisticsMonth等 if (existing != null) { // 更新记录:保留原有的StoreId、EmployeeId、StatisticsMonth等 entity.StoreId = existing.StoreId; entity.EmployeeId = existing.EmployeeId; entity.StatisticsMonth = existing.StatisticsMonth; } else { // 新增记录:尝试根据员工姓名和门店名称查找EmployeeId和StoreId // 如果找不到,这些字段会保持为空,需要后续通过计算工资时填充 if (!string.IsNullOrWhiteSpace(employeeName)) { // 尝试查找用户(通过员工姓名) var user = await _db.Queryable() .Where(u => u.RealName == employeeName) .FirstAsync(); if (user != null) { entity.EmployeeId = user.Id; // 尝试从用户的门店ID获取 if (!string.IsNullOrEmpty(user.Mdid) && string.IsNullOrWhiteSpace(storeName)) { entity.StoreId = user.Mdid; } } // 如果门店名称不为空,尝试查找门店ID if (!string.IsNullOrWhiteSpace(storeName) && string.IsNullOrEmpty(entity.StoreId)) { var store = await _db.Queryable() .Where(s => s.Dm == storeName) .FirstAsync(); if (store != null) { entity.StoreId = store.Id; } } } // StatisticsMonth需要从Excel文件名或其他地方获取 // 这里暂时留空,后续可以根据业务需求补充(例如从Excel文件名中提取月份) } entity.UpdateTime = DateTime.Now; // 重新计算实际合作业绩,确保数据一致性 // 实际合作业绩 = 合作业绩 - 合作奖励业绩(合作奖励业绩是"负奖励"概念) entity.ActualCooperationPerformance = entity.CooperationPerformance - entity.CooperationRewardPerformance; if (existing != null) { recordsToUpdate.Add(entity); } else { recordsToInsert.Add(entity); } successCount++; } catch (Exception ex) { errorMessages.Add($"第{i + 1}行数据处理失败: {ex.Message}"); failCount++; } } } finally { // 清理临时文件 if (File.Exists(tempFilePath)) { File.Delete(tempFilePath); } } // 批量插入新记录 if (recordsToInsert.Any()) { await _db.Insertable(recordsToInsert).ExecuteCommandAsync(); } // 批量更新现有记录 if (recordsToUpdate.Any()) { // 使用IgnoreColumns排除CreateTime和CreateUser,确保其他所有字段都被更新 await _db.Updateable(recordsToUpdate) .IgnoreColumns(x => x.CreateTime) .IgnoreColumns(x => x.CreateUser) .ExecuteCommandAsync(); } return new { success = true, message = $"导入完成:成功 {successCount} 条,失败 {failCount} 条,跳过 {skippedCount} 条(已锁定或已确认)", successCount = successCount, failCount = failCount, skippedCount = skippedCount, errors = errorMessages }; } catch (Exception ex) { throw NCCException.Oh($"导入健康师工资数据失败: {ex.Message}"); } } #endregion /// /// 计算底薪 /// private decimal CalculateBaseSalary(decimal dailyAvgConsumption, decimal dailyAvgProjectCount, int daysInMonth, decimal workingDays, bool isNewStore) { // 规则调整:按日均计算 // 一星:月消耗 10000 / 当月天数,项目数 96 / 当月天数 // 二星:月消耗 20000 / 当月天数,项目数 126 / 当月天数 // 三星:月消耗 40000 / 当月天数,项目数 156 / 当月天数 // 计算各星级日均标准 decimal consStar1 = 10000m / daysInMonth; decimal consStar2 = 20000m / daysInMonth; decimal consStar3 = 40000m / daysInMonth; decimal projStar1 = 96m / daysInMonth; decimal projStar2 = 126m / daysInMonth; decimal projStar3 = 156m / daysInMonth; // 0星:未达标 -> 1800 // 1星:>=1w标准 且 >=96个标准 -> 2000 // 2星:>=2w标准 且 >=126个标准 -> 2200 // 3星:>=4w标准 且 >=156个标准 -> 2400 // 特殊规则:若消耗或项目数中仅一项未达标(0星),底薪按1星(2000元)计算 // 新店规则:新店底薪最低为1星(2000元),不满足1星按1星算 int starCons = 0; if (dailyAvgConsumption >= consStar3) starCons = 3; else if (dailyAvgConsumption >= consStar2) starCons = 2; else if (dailyAvgConsumption >= consStar1) starCons = 1; int starProj = 0; if (dailyAvgProjectCount >= projStar3) starProj = 3; else if (dailyAvgProjectCount >= projStar2) starProj = 2; else if (dailyAvgProjectCount >= projStar1) starProj = 1; int finalStar = Math.Min(starCons, starProj); // 特殊规则处理: 仅一项未达标(0星) -> 1星 if (finalStar == 0 && (starCons > 0 || starProj > 0)) { finalStar = 1; } decimal baseSalary = finalStar switch { 3 => 2400, 2 => 2200, 1 => 2000, _ => 1800 }; // 新店保底1星(2000元) if (isNewStore && baseSalary < 2000) { baseSalary = 2000; } // 最终计算:(底薪 / 当月天数) * 在店天数 if (daysInMonth > 0) { baseSalary = (baseSalary / daysInMonth) * workingDays; } return Math.Round(baseSalary, 2); } /// /// 获取战队提成点 /// private decimal GetTeamCommissionPoint(int memberCount, decimal teamPerformance, int daysInMonth, decimal workingDays) { if (memberCount >= 3) { if (teamPerformance >= 150000) return 0.07m; if (teamPerformance >= 120000) return 0.06m; if (teamPerformance >= 90000) return 0.05m; if (teamPerformance >= 60000) return 0.04m; if (teamPerformance >= 30000) return 0.03m; } else if (memberCount == 2) { if (teamPerformance >= 80000) return 0.06m; if (teamPerformance >= 60000) return 0.05m; if (teamPerformance >= 40000) return 0.04m; if (teamPerformance >= 20000) return 0.03m; } else // 1人 { // 单人按照日均考核 decimal p1 = 60000m; decimal p2 = 40000m; decimal p3 = 20000m; decimal p4 = 10000m; if (daysInMonth > 0 && workingDays > 0) { p1 = (p1 / daysInMonth) * workingDays; p2 = (p2 / daysInMonth) * workingDays; p3 = (p3 / daysInMonth) * workingDays; p4 = (p4 / daysInMonth) * workingDays; } if (teamPerformance >= p1) return 0.06m; if (teamPerformance >= p2) return 0.05m; if (teamPerformance >= p3) return 0.04m; if (teamPerformance >= p4) return 0.03m; } return 0; } /// /// 计算顾问提成 /// /// 战队总业绩 /// 战队成员列表 /// 战队人数 /// 是否为新店 /// 顾问提成金额 private decimal CalculateConsultantCommission(decimal teamPerformance, List teamMembers, int teamMemberCount, bool isNewStore) { // 顾问提成规则: // 战队人数3人:战队总业绩 ≥ 6万元 且 组员业绩达到40%以上 且 消耗达到6万元 → 团队总业绩0.8% // 战队人数2人:战队总业绩 ≥ 4万元 且 组员业绩达到30%以上 且 消耗达到4万元 → 团队总业绩0.3% // 如果3人以上,就按照3人的规则来 // 单人或者一个人的战队就没有顾问(已在调用处处理) // 注意: // 1. "组员业绩"指除顾问外的其他成员业绩总和 // 2. 只统计有效战队成员(考勤≥20天,未被剔除的成员) // 3. "达到X%以上"指:组员业绩总和 ≥ 团队总业绩 × X% // 4. 新店顾问不考核消耗 // 5. 消耗达标:3人战队整组消耗>=6万,2人战队整组消耗>=4万 // 使用传入的 teamMembers 计算总消耗 var teamConsumption = teamMembers.Sum(x => x.Consumption); // 计算组员(非顾问)业绩总和 var memberPerformance = teamMembers.Where(x => x.Position != "顾问").Sum(x => x.TotalPerformance); // 3人及以上战队:业绩≥6万 且 组员业绩≥40% 且 (新店 或 消耗≥6万) → 0.8% if (teamMemberCount >= 3) { if (teamPerformance >= 60000 && memberPerformance >= teamPerformance * 0.4m) { if (isNewStore || teamConsumption >= 60000) { return teamPerformance * 0.008m; } } } // 2人战队:业绩≥4万 且 组员业绩≥30% 且 (新店 或 消耗≥4万) → 0.3% else if (teamMemberCount == 2) { if (teamPerformance >= 40000 && memberPerformance >= teamPerformance * 0.3m) { if (isNewStore || teamConsumption >= 40000) { return teamPerformance * 0.003m; } } } // 1人战队:没有顾问,返回0(已在调用处处理,这里不会执行到) return 0; } /// /// 计算新客转化率提成 /// private decimal CalculateNewCustomerConversionCommission(decimal newCustomerPerformance, decimal conversionRate) { decimal commissionRate = 0; if (conversionRate >= 0.5m) commissionRate = 0.20m; else if (conversionRate >= 0.45m) commissionRate = 0.15m; else if (conversionRate >= 0.35m) commissionRate = 0.10m; else if (conversionRate >= 0) commissionRate = 0.06m; return newCustomerPerformance * commissionRate; } /// /// 获取升单提成比例 /// private decimal GetUpgradeCommissionRate(decimal upgradeCustomerCount) { if (upgradeCustomerCount >= 10) return 0.12m; // 大于等于10:12% else if (upgradeCustomerCount >= 7 && upgradeCustomerCount < 10) return 0.10m; // 大于等于7且小于10:10% else if (upgradeCustomerCount >= 4 && upgradeCustomerCount < 7) return 0.07m; // 大于等于4且小于7:7% // 小于4:0% return 0m; } /// /// 计算升单人头提成 /// private decimal CalculateUpgradeCustomerCommission(decimal upgradePerformance, decimal upgradeCustomerCount) { decimal commissionRate = GetUpgradeCommissionRate(upgradeCustomerCount); return upgradePerformance * commissionRate; } } }