using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using NCC.Common.Filter; using NCC.Common.Helper; using NCC.Dependency; using NCC.DynamicApiController; using NCC.Extend.Entitys.Dto.LqAssistantSalary; using NCC.Extend.Entitys.Dto.LqSalary; 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.FriendlyException; using NCC.System.Entitys.Permission; using SqlSugar; using System; using System.Collections.Generic; using System.Data; using System.IO; 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; private readonly ILogger _logger; /// /// 初始化一个类型的新实例 /// public LqAssistantSalaryService(ISqlSugarClient db, ILogger logger) { _db = db; _logger = logger; } /// /// 获取店助工资列表 /// /// 查询参数 /// 店助工资分页列表 [HttpGet("assistant")] public async Task GetAssistantSalaryList([FromQuery] AssistantSalaryInput 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 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, EmployeeConfirmStatus = x.EmployeeConfirmStatus, UpdateTime = x.UpdateTime, StoreType = x.StoreType, StoreCategory = x.StoreCategory, IsNewStore = x.IsNewStore, NewStoreProtectionStage = x.NewStoreProtectionStage }) .ToPagedListAsync(input.currentPage, input.pageSize); return PageResult.SqlSugarPageResult(list); } /// /// 通过月份和员工ID查询工资(仅查询已锁定且未确认的工资) /// /// /// **重要**:此接口只能查询已锁定(IsLocked=1)且未确认(EmployeeConfirmStatus=0或null)的工资记录 /// **注意**:已确认的工资记录无法通过此接口查询 /// [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 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, EmployeeConfirmStatus = x.EmployeeConfirmStatus, UpdateTime = x.UpdateTime, StoreType = x.StoreType, StoreCategory = x.StoreCategory, IsNewStore = x.IsNewStore, NewStoreProtectionStage = x.NewStoreProtectionStage }) .FirstAsync(); if (salary == null) throw NCCException.Oh($"未找到员工{input.EmployeeId}在{input.Year}年{input.Month}月的工资记录"); return salary; } /// /// 计算店助工资 /// /// 年份 /// 月份 /// [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()) { // 3.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})"); } // 3.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 assistantSalaryList) { 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})"); } } } /// /// 计算店助提成(分段阶梯提成模式) /// /// 门店业绩 /// 门店生命线 /// 提成金额 /// /// 提成规则: /// 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类)") }; } #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; salary.UpdateTime = DateTime.Now; await _db.Updateable(salary).ExecuteCommandAsync(); return "确认成功"; } catch (Exception ex) { throw NCCException.Oh($"确认工资条失败: {ex.Message}"); } } #endregion #region 工资锁定/解锁 /// /// 批量锁定/解锁工资条 /// [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}"); } } /// /// 批量锁定当月所有工资 /// /// 批量锁定输入参数 /// 锁定结果 [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文件 /// 导入结果 [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); } var dataTable = ExcelImportHelper.ToDataTable(tempFilePath, 0, 0); if (dataTable.Rows.Count == 0) throw NCCException.Oh("Excel文件中没有数据行"); Func ParseDecimal = (str) => { if (string.IsNullOrWhiteSpace(str)) return 0; var cleaned = str.Trim().Replace(",", "").Replace(",", "").Replace("¥", "").Replace("$", "").Replace("元", "").Replace("%", "").Replace(" ", ""); return decimal.TryParse(cleaned, out decimal result) ? result : 0; }; Func ParseInt = (str) => { if (string.IsNullOrWhiteSpace(str)) return 0; var cleaned = str.Trim().Replace(",", "").Replace(",", "").Replace(" ", ""); return int.TryParse(cleaned, out int result) ? result : 0; }; // 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) => colIndex < row.ItemArray.Length && row[colIndex] != null ? row[colIndex].ToString().Trim() : ""; var firstColumnValue = GetColumnValue(0); bool isOldFormat = !string.IsNullOrWhiteSpace(firstColumnValue) && (firstColumnValue == "门店名称" || (!long.TryParse(firstColumnValue, out _) && firstColumnValue.Length > 20)); int storeNameIndex = isOldFormat ? 0 : 1; int employeeNameIndex = isOldFormat ? 1 : 2; int offset = isOldFormat ? 0 : 1; var id = isOldFormat ? "" : GetColumnValue(0); var employeeName = GetColumnValue(employeeNameIndex); var storeName = GetColumnValue(storeNameIndex); if (string.IsNullOrWhiteSpace(id) && string.IsNullOrWhiteSpace(employeeName)) continue; 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; } LqAssistantSalaryStatisticsEntity existing = null; if (!string.IsNullOrWhiteSpace(id)) { existing = await _db.Queryable() .Where(x => x.Id == id).FirstAsync(); if (existing != null && (existing.IsLocked == 1 || existing.EmployeeConfirmStatus == 1)) { skippedCount++; failCount++; continue; } } var entity = existing ?? new LqAssistantSalaryStatisticsEntity { Id = string.IsNullOrWhiteSpace(id) ? YitIdHelper.NextId().ToString() : id, EmployeeConfirmStatus = 0, IsLocked = 0, CreateTime = DateTime.Now, CreateUser = "" }; entity.StoreName = storeName; entity.EmployeeName = employeeName; entity.Position = GetColumnValue(2 + offset); entity.StoreTotalPerformance = ParseDecimal(GetColumnValue(3 + offset)); entity.StoreBillingPerformance = ParseDecimal(GetColumnValue(4 + offset)); entity.StoreRefundPerformance = ParseDecimal(GetColumnValue(5 + offset)); entity.StoreLifeline = ParseDecimal(GetColumnValue(6 + offset)); entity.PerformanceCompletionRate = ParseDecimal(GetColumnValue(7 + offset)); entity.CommissionRate = ParseDecimal(GetColumnValue(8 + offset)); entity.CommissionAmount = ParseDecimal(GetColumnValue(9 + offset)); entity.HeadCount = ParseInt(GetColumnValue(10 + offset)); entity.Stage1TargetHeadCount = ParseInt(GetColumnValue(11 + offset)); entity.Stage2TargetHeadCount = ParseInt(GetColumnValue(12 + offset)); entity.ReachedStage1 = GetColumnValue(13 + offset); entity.ReachedStage2 = GetColumnValue(14 + offset); entity.StageRewardAmount = ParseDecimal(GetColumnValue(15 + offset)); entity.Stage1Reward = ParseDecimal(GetColumnValue(16 + offset)); entity.Stage2Reward = ParseDecimal(GetColumnValue(17 + offset)); entity.BaseSalary = ParseDecimal(GetColumnValue(18 + offset)); entity.PhoneManagementFee = ParseDecimal(GetColumnValue(19 + offset)); entity.WorkingDays = ParseInt(GetColumnValue(20 + offset)); entity.LeaveDays = ParseInt(GetColumnValue(21 + offset)); entity.GrossSalary = ParseDecimal(GetColumnValue(22 + offset)); entity.ActualSalary = ParseDecimal(GetColumnValue(23 + offset)); entity.TotalDeduction = ParseDecimal(GetColumnValue(24 + offset)); entity.TotalSubsidy = ParseDecimal(GetColumnValue(25 + offset)); entity.Bonus = ParseDecimal(GetColumnValue(26 + offset)); entity.ReturnPhoneDeposit = ParseDecimal(GetColumnValue(27 + offset)); entity.ReturnAccommodationDeposit = ParseDecimal(GetColumnValue(28 + offset)); entity.LastMonthSupplement = ParseDecimal(GetColumnValue(29 + offset)); entity.MonthlyPaymentStatus = GetColumnValue(30 + offset); entity.PaidAmount = ParseDecimal(GetColumnValue(31 + offset)); entity.PendingAmount = ParseDecimal(GetColumnValue(32 + offset)); entity.MonthlyTotalPayment = ParseDecimal(GetColumnValue(33 + offset)); entity.IsNewStore = GetColumnValue(34 + offset) == "是" ? "是" : "否"; entity.NewStoreProtectionStage = ParseInt(GetColumnValue(35 + offset)); if (existing != null) { entity.StoreId = existing.StoreId; entity.EmployeeId = existing.EmployeeId; entity.StatisticsMonth = existing.StatisticsMonth; } else { // 对于新记录,尝试通过员工姓名和门店名称匹配已有记录以获取统计月份 LqAssistantSalaryStatisticsEntity matchedRecord = null; if (!string.IsNullOrWhiteSpace(employeeName)) { var user = await _db.Queryable() .Where(u => u.RealName == employeeName).FirstAsync(); if (user != null) { entity.EmployeeId = user.Id; if (!string.IsNullOrEmpty(user.Mdid) && string.IsNullOrWhiteSpace(storeName)) entity.StoreId = user.Mdid; // 尝试通过员工ID匹配已有记录获取统计月份 matchedRecord = await _db.Queryable() .Where(x => x.EmployeeId == user.Id) .WhereIF(!string.IsNullOrWhiteSpace(storeName), x => x.StoreName == storeName) .OrderBy(x => x.CreateTime, OrderByType.Desc) .FirstAsync(); } 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; // 如果没有匹配到记录,再通过门店ID尝试 if (matchedRecord == null) { matchedRecord = await _db.Queryable() .Where(x => x.StoreId == store.Id) .WhereIF(!string.IsNullOrWhiteSpace(employeeName), x => x.EmployeeName == employeeName) .OrderBy(x => x.CreateTime, OrderByType.Desc) .FirstAsync(); } } } } // 如果有匹配的记录,使用其统计月份;否则使用当前年月作为默认值 if (matchedRecord != null && !string.IsNullOrWhiteSpace(matchedRecord.StatisticsMonth)) { entity.StatisticsMonth = matchedRecord.StatisticsMonth; } else { // 如果没有匹配记录,使用当前年月(YYYYMM格式) var now = DateTime.Now; entity.StatisticsMonth = $"{now.Year}{now.Month:D2}"; } } entity.UpdateTime = DateTime.Now; 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, failCount, skippedCount, errors = errorMessages }; } catch (Exception ex) { throw NCCException.Oh($"导入店助工资数据失败: {ex.Message}"); } } #endregion } }