using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using NCC.Common.Filter; using NCC.Common.Helper; using NCC.Dependency; using NCC.DynamicApiController; using NCC.Extend.Entitys.Dto.LqAssistantSalary; using NCC.Extend.Entitys.lq_assistant_salary_statistics; using NCC.Extend.Entitys.lq_attendance_summary; using NCC.Extend.Entitys.lq_hytk_hytk; using NCC.Extend.Entitys.lq_kd_kdjlb; using NCC.Extend.Entitys.lq_md_target; using NCC.Extend.Entitys.lq_md_xdbhsj; using NCC.Extend.Entitys.lq_mdxx; using NCC.Extend.Entitys.lq_xh_hyhk; using NCC.Extend.Entitys.lq_xh_jksyj; using NCC.System.Entitys.Permission; using SqlSugar; using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Yitter.IdGenerator; namespace NCC.Extend { /// /// 店助薪酬服务 /// [ApiDescriptionSettings(Tag = "店助、店助主任薪酬服务", Name = "LqAssistantSalary", Order = 301)] [Route("api/Extend/[controller]")] public class LqAssistantSalaryService : IDynamicApiController, ITransient { private readonly ISqlSugarClient _db; /// /// 初始化一个类型的新实例 /// public LqAssistantSalaryService(ISqlSugarClient db) { _db = db; } /// /// 获取店助工资列表 /// /// 查询参数 /// 店助工资分页列表 [HttpGet("assistant")] public async Task GetAssistantSalaryList([FromQuery] AssistantSalaryInput input) { var monthStr = $"{input.Year}{input.Month:D2}"; // 1. 检查当月是否已生成工资数据 var exists = await _db.Queryable() .AnyAsync(x => x.StatisticsMonth == monthStr); // 2. 如果没有数据,则进行计算 if (!exists) { await CalculateAssistantSalary(input.Year, input.Month); } // 3. 查询数据 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 AssistantSalaryOutput { Id = x.Id, StoreName = x.StoreName, EmployeeName = x.EmployeeName, Position = x.Position, StoreTotalPerformance = x.StoreTotalPerformance, StoreBillingPerformance = x.StoreBillingPerformance, StoreRefundPerformance = x.StoreRefundPerformance, StoreLifeline = x.StoreLifeline, PerformanceCompletionRate = x.PerformanceCompletionRate, CommissionRate = x.CommissionRate, CommissionAmount = x.CommissionAmount, HeadCount = x.HeadCount, Stage1TargetHeadCount = x.Stage1TargetHeadCount, Stage2TargetHeadCount = x.Stage2TargetHeadCount, ReachedStage1 = x.ReachedStage1, ReachedStage2 = x.ReachedStage2, StageRewardAmount = x.StageRewardAmount, Stage1Reward = x.Stage1Reward, Stage2Reward = x.Stage2Reward, BaseSalary = x.BaseSalary, PhoneManagementFee = x.PhoneManagementFee, WorkingDays = x.WorkingDays, LeaveDays = x.LeaveDays, GrossSalary = x.GrossSalary, ActualSalary = x.ActualSalary, TotalDeduction = x.TotalDeduction, TotalSubsidy = x.TotalSubsidy, Bonus = x.Bonus, ReturnPhoneDeposit = x.ReturnPhoneDeposit, ReturnAccommodationDeposit = x.ReturnAccommodationDeposit, MonthlyPaymentStatus = x.MonthlyPaymentStatus, PaidAmount = x.PaidAmount, PendingAmount = x.PendingAmount, LastMonthSupplement = x.LastMonthSupplement, MonthlyTotalPayment = x.MonthlyTotalPayment, IsLocked = x.IsLocked, UpdateTime = x.UpdateTime, StoreType = x.StoreType, StoreCategory = x.StoreCategory, IsNewStore = x.IsNewStore, NewStoreProtectionStage = x.NewStoreProtectionStage }) .ToPagedListAsync(input.currentPage, input.pageSize); return PageResult.SqlSugarPageResult(list); } /// /// 计算店助工资 /// /// 年份 /// 月份 /// [HttpPost("calculate/assistant")] public async Task CalculateAssistantSalary(int year, int month) { var startDate = new DateTime(year, month, 1); var endDate = startDate.AddMonths(1).AddDays(-1); var monthStr = $"{year}{month:D2}"; var daysInMonth = DateTime.DaysInMonth(year, month); // 当月天数 // 1. 获取基础数据 // 1.1 获取店助员工列表(从BASE_USER表,岗位为"店助"或"店助主任") var assistantUserList = await _db.Queryable() .Where(x => (x.Gw == "店助" || x.Gw == "店助主任") && x.DeleteMark == null && x.EnabledMark == 1) .Select(x => new { x.Id, x.RealName, x.Mdid, x.Gw }) .ToListAsync(); if (!assistantUserList.Any()) { // 如果没有店助员工,直接返回 return; } // 1.2 门店信息 (lq_mdxx) var storeList = await _db.Queryable().ToListAsync(); var storeDict = storeList.Where(x => !string.IsNullOrEmpty(x.Id)).ToDictionary(x => x.Id, x => x); // 1.4 门店目标信息 (lq_md_target) - 包含门店生命线和阶段目标 var storeTargets = await _db.Queryable() .Where(x => x.Month == monthStr) .ToListAsync(); var storeTargetDict = storeTargets.Where(x => !string.IsNullOrEmpty(x.StoreId)) .ToDictionary(x => x.StoreId, x => x); // 1.5 门店新店保护信息 (lq_md_xdbhsj) var newStoreProtectionList = await _db.Queryable() .Where(x => x.Sfqy == 1) .ToListAsync(); var newStoreProtectionDict = newStoreProtectionList .Where(x => x.Bhkssj <= startDate && x.Bhjssj >= startDate) .GroupBy(x => x.Mdid) .ToDictionary(g => g.Key, g => g.First()); // 1.6 门店总业绩计算 (开单实付 - 退卡金额) // 开单实付 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)); // 退卡金额(使用F_ActualRefundAmount,如果没有则使用tkje) var storeRefundList = await _db.Queryable() .Where(x => x.Tksj >= startDate && x.Tksj <= endDate.AddDays(1) && x.IsEffective == 1) .Select(x => new { x.Md, x.ActualRefundAmount, x.Tkje }) .ToListAsync(); var storeRefundDict = storeRefundList .Where(x => !string.IsNullOrEmpty(x.Md)) .GroupBy(x => x.Md) .ToDictionary(g => g.Key, g => g.Sum(x => x.ActualRefundAmount ?? x.Tkje ?? 0)); // 1.7 进店消耗人数统计(有消费金额的,按门店按月去重客户数) // 使用SQL查询优化性能 var headcountSql = $@" SELECT hyhk.md as StoreId, COUNT(DISTINCT hyhk.hy) as HeadCount FROM lq_xh_hyhk hyhk WHERE hyhk.F_IsEffective = 1 AND DATE_FORMAT(hyhk.hksj, '%Y%m') = @monthStr AND EXISTS ( SELECT 1 FROM lq_xh_jksyj jksyj WHERE jksyj.glkdbh = hyhk.F_Id AND jksyj.F_IsEffective = 1 AND jksyj.jksyj > 0 ) GROUP BY hyhk.md"; var headcountData = await _db.Ado.SqlQueryAsync(headcountSql, new { monthStr }); var headcountDict = headcountData .Where(x => x.StoreId != null) .ToDictionary(x => x.StoreId.ToString(), x => Convert.ToInt32(x.HeadCount)); // 1.8 考勤数据 (lq_attendance_summary) var attendanceList = await _db.Queryable() .Where(x => x.Year == year && x.Month == month && x.IsEffective == 1) .ToListAsync(); var attendanceDict = attendanceList.ToDictionary(x => x.UserId, x => x); // 2. 计算每个店助的工资 var assistantSalaryList = new List(); foreach (var assistantUser in assistantUserList) { var salary = new LqAssistantSalaryStatisticsEntity { Id = YitIdHelper.NextId().ToString(), EmployeeId = assistantUser.Id, EmployeeName = assistantUser.RealName, StatisticsMonth = monthStr, Position = assistantUser.Gw ?? "店助", // 使用Gw字段,如果为空则默认为"店助" CreateTime = DateTime.Now, UpdateTime = DateTime.Now, IsLocked = 0, MonthlyPaymentStatus = "未发放" }; // 2.1 填充门店信息 string storeId = assistantUser.Mdid; if (string.IsNullOrEmpty(storeId)) { // 如果用户没有门店ID,跳过 continue; } salary.StoreId = storeId; if (storeDict.ContainsKey(storeId)) { var store = storeDict[storeId]; salary.StoreName = store.Dm; salary.StoreType = store.StoreType; salary.StoreCategory = store.StoreCategory; // 数据校验:门店分类必须设置 if (!salary.StoreCategory.HasValue) { throw new Exception($"门店【{store.Dm}】的门店分类未设置,无法计算店助工资"); } } // 2.2 填充新店保护信息 if (newStoreProtectionDict.ContainsKey(storeId)) { var protection = newStoreProtectionDict[storeId]; salary.IsNewStore = "是"; salary.NewStoreProtectionStage = protection.Stage; } else { salary.IsNewStore = "否"; salary.NewStoreProtectionStage = 0; } // 2.3 获取门店目标信息(门店生命线和阶段目标) // 如果没有设置门店目标,则相关字段设为0(适用于新店未开张等情况) if (!storeTargetDict.ContainsKey(storeId)) { // 门店目标未设置时,所有目标相关字段设为0 salary.StoreLifeline = 0; salary.Stage1TargetHeadCount = 0; salary.Stage2TargetHeadCount = 0; } else { var storeTarget = storeTargetDict[storeId]; salary.StoreLifeline = storeTarget.StoreLifeline; // 阶段目标设置(如果未设置,则奖励金额为0) salary.Stage1TargetHeadCount = storeTarget.AssistantHeadcountTargetStage1 > 0 ? (int)storeTarget.AssistantHeadcountTargetStage1 : 0; salary.Stage2TargetHeadCount = storeTarget.AssistantHeadcountTargetStage2 > 0 ? (int)storeTarget.AssistantHeadcountTargetStage2 : 0; } // 2.4 计算门店业绩 decimal billing = storeBillingDict.ContainsKey(storeId) ? storeBillingDict[storeId] : 0; decimal refund = storeRefundDict.ContainsKey(storeId) ? storeRefundDict[storeId] : 0; salary.StoreBillingPerformance = billing; salary.StoreRefundPerformance = refund; salary.StoreTotalPerformance = billing - refund; // 计算业绩完成率 if (salary.StoreLifeline > 0) { salary.PerformanceCompletionRate = salary.StoreTotalPerformance / salary.StoreLifeline; } else { salary.PerformanceCompletionRate = 0; } // 2.5 计算提成比例(固定比例,不随在店天数变化) // 判断岗位类型:店助 或 店助主任 bool isDirector = salary.Position == "店助主任"; decimal commissionRate = 0; // 如果门店生命线未设置(<=0),则没有提成 if (salary.StoreLifeline <= 0) { commissionRate = 0; } else { // 计算业绩完成率 decimal performanceRatio = salary.StoreTotalPerformance / salary.StoreLifeline; // 根据岗位类型确定提成比例 // 店助和店助主任使用相同的分段提成规则,但100%以上部分比例不同 decimal totalCommission; if (isDirector) { // 店助主任提成规则(分段式): // 1. 前提条件:门店业绩必须达到门店生命线的70%,否则无提成 // 2. 70% ≤ 业绩 < 100%:整个业绩按0.4% // 3. 业绩 ≥ 100%:分段式 // - 0-100%部分(整个生命线):0.4% // - 100%以上部分:1.6%(与店助的0.6%不同) totalCommission = CalculateDirectorCommission(salary.StoreTotalPerformance, salary.StoreLifeline); } else { // 店助提成规则(分段式): // 1. 前提条件:门店业绩必须达到门店生命线的70%,否则无提成 // 2. 70% ≤ 业绩 < 100%:整个业绩按0.4% // 3. 业绩 ≥ 100%:分段式 // - 0-100%部分(整个生命线):0.4% // - 100%以上部分:0.6% totalCommission = CalculateAssistantCommission(salary.StoreTotalPerformance, salary.StoreLifeline); } // 计算平均提成比例(用于显示) if (salary.StoreTotalPerformance > 0) { commissionRate = totalCommission / salary.StoreTotalPerformance; } else { commissionRate = 0; } } salary.CommissionRate = commissionRate; // 2.6 统计进店消耗人数 salary.HeadCount = headcountDict.ContainsKey(storeId) ? headcountDict[storeId] : 0; // 2.7 计算门店总阶段奖励(先计算门店级别的奖励) decimal storeTotalStageReward = 0; bool reachedStage1 = false; bool reachedStage2 = false; // 如果阶段目标未设置(为0),则没有奖励考核,奖励金额为0 if (salary.Stage1TargetHeadCount <= 0 && salary.Stage2TargetHeadCount <= 0) { // 阶段目标未设置,没有奖励考核 salary.ReachedStage1 = "否"; salary.ReachedStage2 = "否"; storeTotalStageReward = 0m; } else { // 阶段目标已设置,进行奖励考核 reachedStage1 = salary.Stage1TargetHeadCount > 0 && salary.HeadCount >= salary.Stage1TargetHeadCount; reachedStage2 = salary.Stage2TargetHeadCount > 0 && salary.HeadCount >= salary.Stage2TargetHeadCount; salary.ReachedStage1 = reachedStage1 ? "是" : "否"; salary.ReachedStage2 = reachedStage2 ? "是" : "否"; // 阶段奖励计算规则: // - 如果达到第二阶段,获得400元(第一阶段200 + 第二阶段200) // - 如果只达到第一阶段,获得200元 // - 如果都没达到,获得0元 if (reachedStage2) { storeTotalStageReward = 400m; } else if (reachedStage1) { storeTotalStageReward = 200m; } else { storeTotalStageReward = 0m; } } // 2.8 考勤数据 int workingDays = 0; if (attendanceDict.ContainsKey(assistantUser.Id)) { var attendance = attendanceDict[assistantUser.Id]; workingDays = (int)attendance.WorkDays; salary.WorkingDays = workingDays; salary.LeaveDays = (int)attendance.LeaveDays; } else { salary.WorkingDays = 0; salary.LeaveDays = 0; } // 2.9 计算底薪(店助和店助主任底薪规则相同,都根据门店分类确定,按在店天数比例计算) decimal baseSalaryFull = CalculateBaseSalary(salary.StoreCategory.Value); if (daysInMonth > 0 && workingDays > 0) { salary.BaseSalary = baseSalaryFull / daysInMonth * workingDays; } else { salary.BaseSalary = 0; } // 2.10 计算手机管理费(按在店天数比例计算) decimal phoneManagementFeeFull = 150m; if (daysInMonth > 0 && workingDays > 0) { salary.PhoneManagementFee = phoneManagementFeeFull / daysInMonth * workingDays; } else { salary.PhoneManagementFee = 0; } // 2.11 按在店天数比例计算店助的提成和奖励 // 逻辑:提成金额 = 门店总提成(阶梯计算) / 当月天数 × 在店天数 // 阶段奖励 = 门店总奖励 / 当月天数 × 在店天数 if (daysInMonth > 0 && workingDays > 0) { // 先计算门店总提成(阶梯计算)- 根据岗位类型使用不同规则 decimal storeTotalCommission; if (isDirector) { storeTotalCommission = CalculateDirectorCommission(salary.StoreTotalPerformance, salary.StoreLifeline); } else { storeTotalCommission = CalculateAssistantCommission(salary.StoreTotalPerformance, salary.StoreLifeline); } // 按比例计算提成:门店总提成 / 当月天数 × 在店天数 salary.CommissionAmount = storeTotalCommission / daysInMonth * workingDays; // 按比例计算奖励 salary.StageRewardAmount = storeTotalStageReward / daysInMonth * workingDays; // 计算阶段奖励的明细(按比例分配) if (reachedStage2) { // 达到第二阶段:第一阶段200 + 第二阶段200 salary.Stage1Reward = 200m / daysInMonth * workingDays; salary.Stage2Reward = 200m / daysInMonth * workingDays; } else if (reachedStage1) { // 只达到第一阶段:第一阶段200 salary.Stage1Reward = 200m / daysInMonth * workingDays; salary.Stage2Reward = 0m; } else { // 都没达到 salary.Stage1Reward = 0m; salary.Stage2Reward = 0m; } } else { // 如果当月天数为0或在店天数为0,则提成和奖励为0 salary.CommissionAmount = 0; salary.StageRewardAmount = 0; salary.Stage1Reward = 0; salary.Stage2Reward = 0; } // 2.12 计算应发工资 salary.GrossSalary = salary.BaseSalary + salary.CommissionAmount + salary.StageRewardAmount + salary.PhoneManagementFee; // 2.13 初始化扣款、补贴、奖金字段(默认值为0) salary.MissingCard = 0; salary.LateArrival = 0; salary.LeaveDeduction = 0; salary.SocialInsuranceDeduction = 0; salary.RewardDeduction = 0; salary.AccommodationDeduction = 0; salary.StudyPeriodDeduction = 0; salary.WorkClothesDeduction = 0; salary.TotalDeduction = 0; salary.MonthlyTrainingSubsidy = 0; salary.MonthlyTransportSubsidy = 0; salary.LastMonthTrainingSubsidy = 0; salary.LastMonthTransportSubsidy = 0; salary.TotalSubsidy = 0; salary.Bonus = 0; salary.ReturnPhoneDeposit = 0; salary.ReturnAccommodationDeposit = 0; // 2.14 计算实发工资 salary.ActualSalary = salary.GrossSalary - salary.TotalDeduction + salary.TotalSubsidy + salary.Bonus; // 2.15 初始化支付相关字段 salary.PaidAmount = 0; salary.PendingAmount = salary.ActualSalary; salary.LastMonthSupplement = 0; salary.MonthlyTotalPayment = 0; assistantSalaryList.Add(salary); } // 3. 保存数据 if (assistantSalaryList.Any()) { // 先删除当月旧数据 (防止重复) await _db.Deleteable() .Where(x => x.StatisticsMonth == monthStr) .ExecuteCommandAsync(); await _db.Insertable(assistantSalaryList).ExecuteCommandAsync(); } } /// /// 计算店助提成(分段阶梯提成模式) /// /// 门店业绩 /// 门店生命线 /// 提成金额 /// /// 提成规则: /// 1. 前提条件:门店业绩必须达到门店生命线的70%,否则无提成 /// 2. 0-100%部分:整个0-100%部分(整个生命线)按0.4%计算 /// 3. 100%以上部分:按0.6%计算 /// 4. 分段计算:不同区间按不同比例分别计算后累加 /// /// 计算公式: /// - 如果业绩 < 70%:提成 = 0 /// - 如果 70% ≤ 业绩 < 100%:提成 = 业绩 × 0.4% /// - 如果业绩 ≥ 100%:提成 = 生命线 × 0.4% + (业绩 - 生命线) × 0.6% /// private decimal CalculateAssistantCommission(decimal storePerformance, decimal storeLifeline) { if (storeLifeline <= 0) { return 0; } decimal ratio = storePerformance / storeLifeline; // 前提条件:必须达到70%才有提成 if (ratio < 0.7m) { // 门店业绩 < 门店生命线 × 70% → 0%(无提成) return 0; } else if (ratio < 1.0m) { // 门店生命线 × 70% ≤ 门店业绩 < 门店生命线 × 100% → 整个业绩按0.4%计算 return storePerformance * 0.004m; } else { // 门店业绩 ≥ 门店生命线 × 100% → 分段计算 // 0-100%部分(整个生命线):按0.4%计算 // 100%以上部分:按0.6%计算 decimal commissionBelow100 = storeLifeline * 0.004m; // 0-100%部分(整个生命线)按0.4% decimal commissionAbove100 = (storePerformance - storeLifeline) * 0.006m; // 100%以上部分按0.6% return commissionBelow100 + commissionAbove100; } } /// /// 计算店助主任提成(分段提成模式) /// /// 门店业绩 /// 门店生命线 /// 提成金额 /// /// 店助主任提成规则(分段式,与店助相同,但100%以上部分比例不同): /// 1. 前提条件:门店业绩必须达到门店生命线的70%,否则无提成 /// 2. 70% ≤ 业绩 < 100%:整个业绩按0.4%计算 /// 3. 业绩 ≥ 100%:分段式提成 /// - 0-100%部分(整个生命线):按0.4%计算 /// - 100%以上部分:按1.6%计算(与店助的0.6%不同) /// /// 计算公式: /// - 如果业绩 < 70%:提成 = 0 /// - 如果 70% ≤ 业绩 < 100%:提成 = 业绩 × 0.4% /// - 如果业绩 ≥ 100%:提成 = 生命线 × 0.4% + (业绩 - 生命线) × 1.6% /// private decimal CalculateDirectorCommission(decimal storePerformance, decimal storeLifeline) { if (storeLifeline <= 0) { return 0; } decimal ratio = storePerformance / storeLifeline; // 前提条件:必须达到70%才有提成 if (ratio < 0.7m) { // 门店业绩 < 门店生命线 × 70% → 0%(无提成) return 0; } else if (ratio < 1.0m) { // 门店生命线 × 70% ≤ 门店业绩 < 门店生命线 × 100% → 整个业绩按0.4%计算 return storePerformance * 0.004m; } else { // 门店业绩 ≥ 门店生命线 × 100% → 分段式提成 // 0-100%部分(整个生命线):按0.4%计算 // 100%以上部分:按1.6%计算(店助主任与店助的区别) decimal commissionBelow100 = storeLifeline * 0.004m; // 0-100%部分(整个生命线)按0.4% decimal commissionAbove100 = (storePerformance - storeLifeline) * 0.016m; // 100%以上部分按1.6% return commissionBelow100 + commissionAbove100; } } /// /// 计算底薪(店助) /// /// 门店分类(1=A类,2=B类,3=C类) /// 底薪金额 private decimal CalculateBaseSalary(int storeCategory) { return storeCategory switch { 1 => 3000m, // A类门店 2 => 3100m, // B类门店 3 => 3200m, // C类门店 _ => throw new Exception($"门店分类值无效:{storeCategory},有效值为1(A类)、2(B类)、3(C类)") }; } } }