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;
}
}
}