Commit 41c75b24ff9920291a9881dbf795419a2e0ad7e0

Authored by “wangming”
1 parent 3d57a288

refactor: 移除所有薪酬服务列表查询中的自动计算逻辑

- 移除9个薪酬服务列表查询方法中的自动计算工资逻辑
- 列表查询现在只负责查询已存在的工资数据
- 工资计算功能需要单独调用计算接口
- 新增健康师工资核算规则说明文档
- 新增员工门店归属变更问题分析与解决方案文档
- 新增员工门店归属快照表方案详细设计文档

涉及服务:
1. LqSalaryService (健康师工资)
2. LqTechTeacherSalaryService (科技部老师工资)
3. LqAssistantSalaryService (店助工资)
4. LqStoreManagerSalaryService (店长工资)
5. LqDirectorSalaryService (主任工资)
6. LqMajorProjectTeacherSalaryService (大项目老师工资)
7. LqMajorProjectDirectorSalaryService (大项目主管工资)
8. LqTechGeneralManagerSalaryService (科技部总经理工资)
9. LqBusinessUnitManagerSalaryService (事业部总经理工资)
docs/健康师工资核算规则说明.md 0 → 100644
  1 +# 健康师工资核算规则说明
  2 +
  3 +## 一、适用范围
  4 +
  5 +本规则适用于所有岗位为"健康师"的员工。
  6 +
  7 +## 二、工资构成
  8 +
  9 +健康师工资 = **底薪** + **提成** + **手工费** + **补贴合计** - **扣款合计**
  10 +
  11 +---
  12 +
  13 +## 三、底薪计算规则
  14 +
  15 +### 3.1 底薪档位标准(按日均计算)
  16 +
  17 +底薪按**日均消耗**和**日均项目数**两项指标评定星级,取两项指标中的较低星级作为最终星级。
  18 +
  19 +**日均标准计算方式:**
  20 +- 一星标准:月消耗 10,000元 ÷ 当月天数,项目数 96个 ÷ 当月天数
  21 +- 二星标准:月消耗 20,000元 ÷ 当月天数,项目数 126个 ÷ 当月天数
  22 +- 三星标准:月消耗 40,000元 ÷ 当月天数,项目数 156个 ÷ 当月天数
  23 +
  24 +**星级评定规则:**
  25 +- **0星**:两项指标均未达标 → 底薪 1,800元
  26 +- **1星**:日均消耗 ≥ 一星标准 且 日均项目数 ≥ 一星标准 → 底薪 2,000元
  27 +- **2星**:日均消耗 ≥ 二星标准 且 日均项目数 ≥ 二星标准 → 底薪 2,200元
  28 +- **3星**:日均消耗 ≥ 三星标准 且 日均项目数 ≥ 三星标准 → 底薪 2,400元
  29 +
  30 +**特殊规则:**
  31 +1. **单项达标保护**:如果消耗和项目数中仅一项未达标(达到0星),底薪按1星(2,000元)计算
  32 +2. **新店保底**:新店底薪最低为1星(2,000元),即使不满足1星标准也按1星计算
  33 +
  34 +### 3.2 底薪按在店天数计算
  35 +
  36 +**最终底薪 = (档位底薪 ÷ 当月天数)× 在店天数**
  37 +
  38 +**在店天数说明:**
  39 +- 从考勤数据中获取,仅统计有效工作日
  40 +- 不包含请假天数
  41 +
  42 +---
  43 +
  44 +## 四、业绩数据说明
  45 +
  46 +### 4.1 业绩分类
  47 +
  48 +- **基础业绩**:基础项目的开单业绩
  49 +- **合作业绩**:合作项目的开单业绩
  50 +- **总业绩**:基础业绩 + 合作业绩
  51 +- **新客业绩**:首次到店客户的开单业绩
  52 +- **升单业绩**:非首次到店客户的开单业绩
  53 +
  54 +### 4.2 业绩扣除
  55 +
  56 +- 所有业绩需扣除对应的退款金额
  57 +- 基础业绩扣除基础业绩退款
  58 +- 合作业绩扣除合作业绩退款
  59 +- 总业绩扣除所有退款
  60 +
  61 +### 4.3 实际基础业绩计算
  62 +
  63 +**实际基础业绩 = 基础业绩 - 基础奖励业绩 + 其他业绩加 - 其他业绩减**
  64 +
  65 +**新店特殊规则:**
  66 +- **第一阶段**:实际基础业绩 = 基础业绩 - 基础奖励业绩 + 其他业绩加 - 其他业绩减 - 新客业绩
  67 +- **第二阶段**:实际基础业绩 = 基础业绩 - 基础奖励业绩 + 其他业绩加 - 其他业绩减 - 升单业绩
  68 +- **第三阶段**:无特殊扣除
  69 +
  70 +### 4.4 实际合作业绩计算
  71 +
  72 +**实际合作业绩 = 合作业绩 - 合作奖励业绩**
  73 +
  74 +---
  75 +
  76 +## 五、提成计算规则
  77 +
  78 +### 5.1 提成前提条件
  79 +
  80 +**战队成员提成门槛:**
  81 +- 如果健康师属于战队成员,个人总业绩必须 ≥ 6,000元(按日均计算)
  82 +- 日均业绩门槛 = (6,000元 ÷ 当月天数)× 在店天数
  83 +- 如果个人总业绩低于门槛,所有提成为0
  84 +
  85 +**单人提成门槛:**
  86 +- 单人无此门槛限制
  87 +
  88 +### 5.2 提成点确定规则
  89 +
  90 +#### 5.2.1 战队成员(3人及以上)
  91 +
  92 +根据战队总业绩确定提成点(不按日均考核):
  93 +
  94 +| 战队总业绩 | 提成点 |
  95 +|-----------|--------|
  96 +| ≥ 15万元 | 7% |
  97 +| ≥ 12万元 | 6% |
  98 +| ≥ 9万元 | 5% |
  99 +| ≥ 6万元 | 4% |
  100 +| ≥ 3万元 | 3% |
  101 +| < 3万元 | 0% |
  102 +
  103 +#### 5.2.2 战队成员(2人)
  104 +
  105 +根据战队总业绩确定提成点(不按日均考核):
  106 +
  107 +| 战队总业绩 | 提成点 |
  108 +|-----------|--------|
  109 +| ≥ 8万元 | 6% |
  110 +| ≥ 6万元 | 5% |
  111 +| ≥ 4万元 | 4% |
  112 +| ≥ 2万元 | 3% |
  113 +| < 2万元 | 0% |
  114 +
  115 +#### 5.2.3 单人(或被剔除出战队)
  116 +
  117 +根据个人总业绩确定提成点(**按日均考核**):
  118 +
  119 +日均业绩门槛 = 标准门槛 ÷ 当月天数 × 在店天数
  120 +
  121 +| 日均业绩 | 提成点 |
  122 +|---------|--------|
  123 +| ≥ 6万元日均 | 6% |
  124 +| ≥ 4万元日均 | 5% |
  125 +| ≥ 2万元日均 | 4% |
  126 +| ≥ 1万元日均 | 3% |
  127 +| < 1万元日均 | 0% |
  128 +
  129 +### 5.3 基础业绩提成
  130 +
  131 +**基础业绩提成 = 实际基础业绩 × 0.95 × 提成点**
  132 +
  133 +### 5.4 合作业绩提成
  134 +
  135 +**合作业绩提成 = 实际合作业绩 × 0.95 × 0.65 × 提成点**
  136 +
  137 +### 5.5 新客转化率提成(仅新店第一阶段)
  138 +
  139 +**适用范围:** 新店第一阶段
  140 +
  141 +**提成比例:**
  142 +
  143 +| 新客转化率 | 提成比例 |
  144 +|-----------|---------|
  145 +| ≥ 50% | 20% |
  146 +| ≥ 45% | 15% |
  147 +| ≥ 35% | 10% |
  148 +| ≥ 0% | 6% |
  149 +
  150 +**计算公式:**
  151 +新客转化率提成 = 新客业绩 × 提成比例 × 0.95
  152 +
  153 +### 5.6 升单人头提成(仅新店第二阶段)
  154 +
  155 +**适用范围:** 新店第二阶段
  156 +
  157 +**提成比例:**
  158 +
  159 +| 升单人头数 | 提成比例 |
  160 +|----------|---------|
  161 +| ≥ 10人 | 12% |
  162 +| 7-9人 | 10% |
  163 +| 4-6人 | 7% |
  164 +| < 4人 | 0% |
  165 +
  166 +**计算公式:**
  167 +升单人头提成 = 升单业绩 × 提成比例 × 0.95
  168 +
  169 +### 5.7 顾问提成
  170 +
  171 +**适用范围:** 仅战队队长(岗位为"顾问")
  172 +
  173 +**前提条件:**
  174 +- 战队人数必须 ≥ 2人
  175 +- 单人战队无顾问提成
  176 +
  177 +**3人及以上战队规则:**
  178 +- 战队总业绩 ≥ 6万元
  179 +- 组员业绩(除顾问外的其他成员业绩总和)≥ 战队总业绩 × 40%
  180 +- 整组消耗 ≥ 6万元(新店不考核消耗)
  181 +- **提成金额 = 战队总业绩 × 0.8%**
  182 +
  183 +**2人战队规则:**
  184 +- 战队总业绩 ≥ 4万元
  185 +- 组员业绩(除顾问外的其他成员业绩总和)≥ 战队总业绩 × 30%
  186 +- 整组消耗 ≥ 4万元(新店不考核消耗)
  187 +- **提成金额 = 战队总业绩 × 0.3%**
  188 +
  189 +**说明:**
  190 +- "组员业绩"指除顾问外的其他成员业绩总和
  191 +- 只统计有效战队成员(考勤≥20天,未被剔除的成员)
  192 +- 新店不考核消耗条件
  193 +
  194 +### 5.8 门店T区提成
  195 +
  196 +**适用范围:** 姓名包含"T区"的员工
  197 +
  198 +**计算公式:**
  199 +门店T区提成 = 门店总业绩 × 0.05 × 0.05
  200 +
  201 +**特殊规则:**
  202 +- T区人员仅核算提成,其他项(底薪、手工费、基础提成、合作提成、顾问提成、新客提成、升单提成、补贴、扣款)全部归零
  203 +
  204 +### 5.9 提成合计
  205 +
  206 +**提成合计 = 基础业绩提成 + 合作业绩提成 + 顾问提成 + 新客转化率提成 + 升单人头提成 + 门店T区提成**
  207 +
  208 +---
  209 +
  210 +## 六、战队规则说明
  211 +
  212 +### 6.1 战队成员资格
  213 +
  214 +- 健康师需要被分配到战队(金三角战队)
  215 +- 战队成员共享战队总业绩
  216 +
  217 +### 6.2 考勤要求
  218 +
  219 +- **有效战队成员**:在店天数 ≥ 20天
  220 +- **无效成员处理**:在店天数 < 20天的成员,将被剔除出战队,按单人计算
  221 + - 移除战队标识
  222 + - 战队业绩 = 个人总业绩
  223 + - 岗位降级为"健康师"(如果是顾问,则降级)
  224 +
  225 +### 6.3 岗位说明
  226 +
  227 +- **健康师**:普通战队成员或单人
  228 +- **顾问**:战队队长(需在战队配置中设置为队长)
  229 + - 单人战队或被剔除出战队后,顾问自动降级为健康师
  230 + - 顾问可享受顾问提成
  231 +
  232 +---
  233 +
  234 +## 七、新店规则说明
  235 +
  236 +### 7.1 新店识别
  237 +
  238 +- 根据门店新店保护配置表判断
  239 +- 统计月份的第一天在保护期内,则视为新店
  240 +
  241 +### 7.2 新店阶段
  242 +
  243 +新店分为三个阶段,不同阶段有不同的业绩扣除和提成规则:
  244 +
  245 +- **第一阶段**:扣除新客业绩,计算新客转化率提成
  246 +- **第二阶段**:扣除升单业绩,计算升单人头提成
  247 +- **第三阶段**:无特殊扣除,不计算新客/升单提成
  248 +
  249 +### 7.3 新店特殊规则
  250 +
  251 +1. **底薪保底**:新店底薪最低为1星(2,000元)
  252 +2. **顾问提成**:新店顾问不考核消耗条件
  253 +3. **新客/升单提成**:仅在新店的第一阶段和第二阶段计算,且需乘以0.95系数
  254 +
  255 +---
  256 +
  257 +## 八、其他说明
  258 +
  259 +### 8.1 手工费
  260 +
  261 +- 从消耗记录中统计
  262 +- 按实际产生的手工费金额计算
  263 +
  264 +### 8.2 最终工资计算
  265 +
  266 +**实发工资 = 底薪 + 提成合计 + 手工费 + 补贴合计 - 扣款合计**
  267 +
  268 +### 8.3 数据来源
  269 +
  270 +- **业绩数据**:开单记录表(lq_kd_jksyj)
  271 +- **退款数据**:退款记录表(lq_hytk_jksyj)
  272 +- **消耗数据**:消耗记录表(lq_xh_jksyj)
  273 +- **考勤数据**:考勤汇总表(lq_attendance_summary)
  274 +- **战队信息**:战队成员表(lq_jinsanjiao_user)
  275 +- **新店信息**:门店新店保护表(lq_md_xdbhsj)
  276 +- **额外计算数据**:健康师工资额外计算表(lq_salary_extra_calculation)
  277 +
  278 +### 8.4 注意事项
  279 +
  280 +1. 所有金额计算保留2位小数
  281 +2. 在店天数必须 > 0,否则底薪为0
  282 +3. 业绩和消耗数据仅统计有效记录(IsEffective = 1)
  283 +4. 退款金额从总业绩中扣除
  284 +5. 提成计算使用实际业绩(扣除奖励业绩和其他调整后的业绩)
  285 +6. 战队成员按战队总业绩确定提成点,单人按个人总业绩确定提成点
  286 +7. 单人提成点按日均考核,战队成员提成点不按日均考核
  287 +
  288 +---
  289 +
  290 +## 九、核算流程建议
  291 +
  292 +1. **数据准备**
  293 + - 确认当月天数
  294 + - 确认新店保护配置
  295 + - 确认战队成员配置
  296 + - 确认考勤数据完整
  297 +
  298 +2. **底薪核算**
  299 + - 计算日均消耗和日均项目数
  300 + - 评定星级
  301 + - 计算最终底薪
  302 +
  303 +3. **业绩统计**
  304 + - 统计个人业绩(基础、合作、新客、升单)
  305 + - 扣除退款
  306 + - 计算实际业绩
  307 +
  308 +4. **提成核算**
  309 + - 判断是否满足提成门槛
  310 + - 确定提成点
  311 + - 计算各项提成
  312 + - 汇总提成合计
  313 +
  314 +5. **最终核算**
  315 + - 汇总底薪、提成、手工费
  316 + - 加上补贴
  317 + - 扣除扣款
  318 + - 得出实发工资
  319 +
  320 +---
  321 +
  322 +## 十、常见问题
  323 +
  324 +**Q1:如果健康师在店天数为0,底薪是多少?**
  325 +A:在店天数为0时,底薪为0。
  326 +
  327 +**Q2:战队成员如果个人业绩低于6,000元,是否还有提成?**
  328 +A:如果个人总业绩低于6,000元(按日均计算的门槛),所有提成为0。
  329 +
  330 +**Q3:新店三个阶段如何区分?**
  331 +A:根据门店新店保护配置表中的阶段字段(Stage)区分:1=第一阶段,2=第二阶段,3=第三阶段。
  332 +
  333 +**Q4:顾问提成如何计算?**
  334 +A:顾问提成基于战队总业绩,需要满足业绩、组员业绩占比、消耗(新店不考核)等条件,具体比例见"5.7 顾问提成"章节。
  335 +
  336 +**Q5:T区人员如何核算?**
  337 +A:T区人员仅核算门店T区提成(门店总业绩 × 0.05 × 0.05),其他所有项目(底薪、手工费、其他提成等)均为0。
  338 +
  339 +---
  340 +
  341 +**文档版本:** v1.0
  342 +**最后更新:** 2026-01-09
  343 +**适用范围:** 健康师工资核算
... ...
docs/员工门店归属变更问题分析与解决方案.md 0 → 100644
  1 +# 员工门店归属变更问题分析与解决方案
  2 +
  3 +## 📋 问题描述
  4 +
  5 +### 业务场景
  6 +员工每个月的归属门店可能会发生变化。当发生改变后,在以下场景中会遇到门店归属问题:
  7 +
  8 +1. **工资计算场景**:
  9 + - 员工A在1月在门店A工作
  10 + - 2月调到门店B(`BASE_USER.F_Mdid` 更新为门店B)
  11 + - 计算1月工资时,如何确定该员工1月的归属门店?
  12 +
  13 +2. **数据补录场景**:
  14 + - 员工A在1月在门店A产生开单/消耗数据
  15 + - 2月调到门店B
  16 + - 如果1月的某些开单/消耗数据在2月补录,这些数据应该归属到哪个门店?
  17 +
  18 +### 核心矛盾
  19 +- **时间维度**:数据应该按照实际发生时间归属门店
  20 +- **当前归属**:`BASE_USER.F_Mdid` 只记录当前门店归属,无法追溯历史
  21 +- **数据完整性**:补录的历史数据需要正确的门店归属
  22 +
  23 +---
  24 +
  25 +## 🔍 现状分析
  26 +
  27 +### 当前数据存储情况
  28 +
  29 +1. **员工门店归属**:
  30 + - 存储位置:`BASE_USER.F_Mdid`
  31 + - 特点:只有一个字段,记录当前门店ID
  32 + - 问题:无法追溯历史门店归属
  33 +
  34 +2. **开单/消耗数据**:
  35 + - 开单业绩表 `lq_kd_jksyj`:有 `F_StoreId` 字段(记录开单时的门店)
  36 + - 消耗表 `lq_xh_jksyj`:有 `F_StoreId` 字段(记录消耗时的门店)
  37 + - 开单主表 `lq_kd_kdjlb`:有 `djmd` 字段(单据门店)
  38 +
  39 +3. **工资计算逻辑**(以健康师工资为例):
  40 + ```csharp
  41 + // 当前逻辑:
  42 + // 1. 优先从业绩数据中获取门店(performanceData中的StoreId)
  43 + // 2. 如果没有,从消耗数据中获取门店
  44 + // 3. 如果还没有,使用 BASE_USER.F_Mdid
  45 + ```
  46 +
  47 +### 存在的问题
  48 +
  49 +1. **门店归属判断不准确**:
  50 + - 如果业务数据(开单/消耗)中没有门店信息,会回退到使用 `BASE_USER.F_Mdid`
  51 + - 但 `BASE_USER.F_Mdid` 是当前门店,不是历史门店
  52 +
  53 +2. **补录数据归属问题**:
  54 + - 补录历史数据时,如果没有明确的门店信息,可能被错误归属
  55 +
  56 +3. **工资计算偏差**:
  57 + - 计算历史月份工资时,可能使用了错误的门店归属
  58 + - 影响门店统计、新店判断等逻辑
  59 +
  60 +---
  61 +
  62 +## 💡 解决方案思路
  63 +
  64 +### 方案一:时间维度优先 + 门店归属快照表(推荐)
  65 +
  66 +#### 核心思路
  67 +以**时间维度**为唯一标准,通过门店归属快照表记录员工每月的门店归属。
  68 +
  69 +#### 实现方案
  70 +
  71 +1. **创建门店归属快照表**
  72 + ```
  73 + 表名:lq_employee_store_assignment(员工门店归属表)
  74 +
  75 + 字段:
  76 + - F_Id:主键ID
  77 + - F_EmployeeId:员工ID
  78 + - F_StoreId:门店ID
  79 + - F_StoreName:门店名称(冗余字段,便于查询)
  80 + - F_Year:年份
  81 + - F_Month:月份
  82 + - F_StatisticsMonth:统计月份(YYYYMM格式)
  83 + - F_StartDate:归属开始日期(精确到天,用于处理月中调店)
  84 + - F_EndDate:归属结束日期(可为空,表示当前)
  85 + - F_CreateTime:创建时间
  86 + - F_UpdateTime:更新时间
  87 + - F_CreateUser:创建人
  88 + ```
  89 +
  90 +2. **门店归属维护机制**
  91 + - 在员工调店时,自动创建/更新归属记录
  92 + - 支持月中调店(一个员工一个月可能归属多个门店)
  93 + - 支持批量导入历史归属数据
  94 +
  95 +3. **工资计算逻辑调整**
  96 + ```
  97 + 工资计算时:
  98 + 1. 根据统计月份(YYYYMM)查询门店归属快照表
  99 + 2. 如果一个月有多个门店,按时间范围拆分计算
  100 + 3. 如果查询不到归属记录,再回退到当前逻辑
  101 + ```
  102 +
  103 +4. **数据补录逻辑**
  104 + ```
  105 + 补录数据时:
  106 + 1. 根据数据实际发生时间(Yjsj)确定月份
  107 + 2. 查询该月份的门店归属快照
  108 + 3. 将数据的StoreId设置为归属门店
  109 + ```
  110 +
  111 +#### 优点
  112 +- ✅ 时间维度清晰,历史可追溯
  113 +- ✅ 支持月中调店场景
  114 +- ✅ 不影响现有数据结构
  115 +- ✅ 工资计算和数据补录都基于时间维度
  116 +
  117 +#### 缺点
  118 +- ⚠️ 需要维护额外的归属表
  119 +- ⚠️ 需要迁移历史数据
  120 +
  121 +---
  122 +
  123 +### 方案二:业务数据中强制记录门店信息
  124 +
  125 +#### 核心思路
  126 +在开单/消耗数据录入时,强制要求记录门店信息,工资计算完全依赖业务数据中的门店。
  127 +
  128 +#### 实现方案
  129 +
  130 +1. **数据录入规则**
  131 + - 开单/消耗数据必须包含门店信息(StoreId)
  132 + - 补录数据时,根据数据发生时间,查询当时的门店归属
  133 + - 如果无法确定,要求用户手动选择门店
  134 +
  135 +2. **工资计算逻辑**
  136 + ```
  137 + 工资计算时:
  138 + 1. 只从业务数据(开单/消耗)中获取门店信息
  139 + 2. 如果业务数据中没有门店信息,报错或跳过
  140 + 3. 不再使用 BASE_USER.F_Mdid 作为回退
  141 + ```
  142 +
  143 +#### 优点
  144 +- ✅ 数据来源单一,逻辑清晰
  145 +- ✅ 不需要额外的表结构
  146 +
  147 +#### 缺点
  148 +- ❌ 如果业务数据中没有门店信息,无法计算工资
  149 +- ❌ 补录历史数据时,需要手动确定门店归属
  150 +- ❌ 数据完整性要求高
  151 +
  152 +---
  153 +
  154 +### 方案三:BASE_USER表增加门店归属历史字段
  155 +
  156 +#### 核心思路
  157 +在 `BASE_USER` 表中增加字段,记录门店归属历史(JSON格式或关联表)。
  158 +
  159 +#### 实现方案
  160 +
  161 +1. **字段设计**
  162 + ```
  163 + 方案A:JSON字段
  164 + F_StoreAssignmentHistory:JSON格式存储历史归属
  165 + {
  166 + "202501": "store_id_1",
  167 + "202502": "store_id_2",
  168 + ...
  169 + }
  170 +
  171 + 方案B:关联表
  172 + 创建 BASE_USER_STORE_HISTORY 表
  173 + 记录员工每个月的门店归属
  174 + ```
  175 +
  176 +2. **维护机制**
  177 + - 员工调店时,更新历史记录
  178 + - 支持批量导入历史数据
  179 +
  180 +#### 优点
  181 +- ✅ 数据集中管理
  182 +- ✅ 查询方便
  183 +
  184 +#### 缺点
  185 +- ⚠️ 修改核心用户表,影响面大
  186 +- ⚠️ JSON字段查询性能较差
  187 +- ⚠️ 关联表方案与方案一类似
  188 +
  189 +---
  190 +
  191 +### 方案四:工资统计表记录门店归属
  192 +
  193 +#### 核心思路
  194 +在工资统计表中记录门店归属,计算工资时使用该记录,但首次计算需要确定归属。
  195 +
  196 +#### 实现方案
  197 +
  198 +1. **工资统计表已有字段**
  199 + - `F_StoreId`:门店ID
  200 + - `F_StoreName`:门店名称
  201 + - 这些字段已经记录了工资计算时的门店归属
  202 +
  203 +2. **逻辑调整**
  204 + ```
  205 + 工资计算时:
  206 + 1. 查询历史工资统计记录
  207 + 2. 如果存在记录,使用记录中的门店归属
  208 + 3. 如果不存在,使用当前逻辑确定门店归属
  209 + ```
  210 +
  211 +#### 优点
  212 +- ✅ 不需要额外的表结构
  213 +- ✅ 工资记录本身就是历史快照
  214 +
  215 +#### 缺点
  216 +- ❌ 首次计算工资时,门店归属判断仍有问题
  217 +- ❌ 数据补录时,无法确定门店归属
  218 +- ❌ 不能解决数据补录的问题
  219 +
  220 +---
  221 +
  222 +## 🎯 推荐方案
  223 +
  224 +### 推荐:方案一(门店归属快照表)
  225 +
  226 +#### 理由
  227 +1. **时间维度清晰**:以时间维度为唯一标准,符合业务逻辑
  228 +2. **完整解决**:同时解决工资计算和数据补录的问题
  229 +3. **扩展性好**:支持月中调店、批量导入等场景
  230 +4. **影响面小**:新增表结构,不影响现有逻辑
  231 +
  232 +#### 实施步骤建议
  233 +
  234 +1. **第一阶段:数据准备**
  235 + - 创建门店归属快照表
  236 + - 整理历史员工门店归属数据
  237 + - 批量导入历史归属数据
  238 +
  239 +2. **第二阶段:功能开发**
  240 + - 开发门店归属维护功能(调店时自动创建记录)
  241 + - 调整工资计算逻辑(优先使用快照表)
  242 + - 调整数据补录逻辑(根据时间查询归属)
  243 +
  244 +3. **第三阶段:数据迁移**
  245 + - 迁移历史数据
  246 + - 验证数据准确性
  247 + - 逐步切换到新逻辑
  248 +
  249 +4. **第四阶段:优化**
  250 + - 性能优化
  251 + - 用户体验优化
  252 + - 监控和告警
  253 +
  254 +---
  255 +
  256 +## 📊 方案对比
  257 +
  258 +| 方案 | 实施难度 | 数据完整性 | 可追溯性 | 扩展性 | 推荐度 |
  259 +|------|---------|-----------|---------|--------|--------|
  260 +| 方案一:快照表 | 中等 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
  261 +| 方案二:业务数据强制 | 低 | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐ |
  262 +| 方案三:BASE_USER扩展 | 高 | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ |
  263 +| 方案四:工资表记录 | 低 | ⭐⭐ | ⭐⭐⭐ | ⭐⭐ | ⭐⭐ |
  264 +
  265 +---
  266 +
  267 +## 🔧 技术实现要点
  268 +
  269 +### 1. 门店归属快照表设计
  270 +
  271 +```sql
  272 +CREATE TABLE lq_employee_store_assignment (
  273 + F_Id VARCHAR(50) PRIMARY KEY,
  274 + F_EmployeeId VARCHAR(50) NOT NULL COMMENT '员工ID',
  275 + F_StoreId VARCHAR(50) NOT NULL COMMENT '门店ID',
  276 + F_StoreName VARCHAR(200) COMMENT '门店名称',
  277 + F_Year INT NOT NULL COMMENT '年份',
  278 + F_Month INT NOT NULL COMMENT '月份',
  279 + F_StatisticsMonth VARCHAR(6) NOT NULL COMMENT '统计月份YYYYMM',
  280 + F_StartDate DATE COMMENT '归属开始日期',
  281 + F_EndDate DATE COMMENT '归属结束日期',
  282 + F_CreateTime DATETIME,
  283 + F_UpdateTime DATETIME,
  284 + F_CreateUser VARCHAR(50),
  285 + INDEX idx_employee_month (F_EmployeeId, F_StatisticsMonth),
  286 + INDEX idx_store_month (F_StoreId, F_StatisticsMonth)
  287 +) COMMENT '员工门店归属表';
  288 +```
  289 +
  290 +### 2. 工资计算逻辑调整要点
  291 +
  292 +```csharp
  293 +// 伪代码示例
  294 +public async Task CalculateSalary(int year, int month)
  295 +{
  296 + var monthStr = $"{year}{month:D2}";
  297 +
  298 + // 1. 查询员工门店归属(优先使用快照表)
  299 + var storeAssignments = await _db.Queryable<EmployeeStoreAssignmentEntity>()
  300 + .Where(x => x.StatisticsMonth == monthStr)
  301 + .ToListAsync();
  302 +
  303 + // 2. 如果没有快照记录,使用当前逻辑(回退方案)
  304 + if (!storeAssignments.Any())
  305 + {
  306 + // 使用现有逻辑:从业务数据或BASE_USER获取
  307 + }
  308 +
  309 + // 3. 按门店归属计算工资
  310 + foreach (var assignment in storeAssignments)
  311 + {
  312 + // 查询该员工在该门店的业绩数据
  313 + // 计算工资
  314 + }
  315 +}
  316 +```
  317 +
  318 +### 3. 数据补录逻辑调整要点
  319 +
  320 +```csharp
  321 +// 伪代码示例
  322 +public async Task ImportBillingData(DateTime billingDate, string employeeId)
  323 +{
  324 + var monthStr = $"{billingDate.Year}{billingDate.Month:D2}";
  325 +
  326 + // 1. 查询该月份的门店归属
  327 + var assignment = await _db.Queryable<EmployeeStoreAssignmentEntity>()
  328 + .Where(x => x.EmployeeId == employeeId && x.StatisticsMonth == monthStr)
  329 + .Where(x => billingDate >= x.StartDate && (x.EndDate == null || billingDate <= x.EndDate))
  330 + .FirstAsync();
  331 +
  332 + // 2. 设置数据的门店ID
  333 + billingRecord.StoreId = assignment?.StoreId ?? GetStoreIdByCurrentLogic();
  334 +}
  335 +```
  336 +
  337 +---
  338 +
  339 +## ⚠️ 注意事项
  340 +
  341 +1. **数据一致性**
  342 + - 门店归属快照表的数据必须准确
  343 + - 需要定期检查和校验
  344 +
  345 +2. **性能考虑**
  346 + - 门店归属查询需要建立合适的索引
  347 + - 工资计算时批量查询,避免N+1问题
  348 +
  349 +3. **回退方案**
  350 + - 如果快照表中没有记录,需要有回退逻辑
  351 + - 回退逻辑可以使用现有逻辑(业务数据或BASE_USER)
  352 +
  353 +4. **月中调店处理**
  354 + - 如果一个员工一个月内调店,需要创建多条记录
  355 + - 工资计算时,需要按时间范围拆分计算
  356 +
  357 +5. **历史数据迁移**
  358 + - 需要整理历史员工门店归属数据
  359 + - 可能需要手动确认部分数据
  360 +
  361 +---
  362 +
  363 +## 📝 总结
  364 +
  365 +员工门店归属变更问题的核心是**时间维度与当前归属的矛盾**。
  366 +
  367 +**推荐使用方案一(门店归属快照表)**,理由:
  368 +- 以时间维度为唯一标准,符合业务逻辑
  369 +- 完整解决工资计算和数据补录的问题
  370 +- 扩展性好,支持复杂场景
  371 +- 实施难度适中,影响面可控
  372 +
  373 +实施时需要重点关注:
  374 +- 数据准确性(门店归属快照表)
  375 +- 性能优化(索引、批量查询)
  376 +- 回退方案(数据缺失时的处理)
  377 +- 历史数据迁移(数据整理和导入)
  378 +
  379 +---
  380 +
  381 +**文档版本:** v1.0
  382 +**创建日期:** 2026-01-09
  383 +**文档性质:** 问题分析与方案设计(仅思考,不修改代码)
... ...
docs/员工门店归属快照表方案详细设计.md 0 → 100644
  1 +# 员工门店归属快照表方案详细设计
  2 +
  3 +## 📋 方案概述
  4 +
  5 +通过创建门店归属快照表,记录员工每个月的门店归属历史,以时间维度为唯一标准,解决工资计算和数据补录中的门店归属问题。
  6 +
  7 +---
  8 +
  9 +## 🗄️ 数据模型设计
  10 +
  11 +### 1. 核心表设计
  12 +
  13 +#### 表名:`lq_employee_store_assignment`(员工门店归属表)
  14 +
  15 +#### 字段设计
  16 +
  17 +| 字段名 | 类型 | 说明 | 约束 | 备注 |
  18 +|--------|------|------|------|------|
  19 +| F_Id | VARCHAR(50) | 主键ID | PRIMARY KEY | 使用YitIdHelper生成 |
  20 +| F_EmployeeId | VARCHAR(50) | 员工ID | NOT NULL | 关联BASE_USER.F_Id |
  21 +| F_StoreId | VARCHAR(50) | 门店ID | NOT NULL | 关联lq_mdxx.F_Id |
  22 +| F_StoreName | VARCHAR(200) | 门店名称 | | 冗余字段,便于查询和显示 |
  23 +| F_Year | INT | 年份 | NOT NULL | 如:2025 |
  24 +| F_Month | INT | 月份 | NOT NULL | 1-12 |
  25 +| F_StatisticsMonth | VARCHAR(6) | 统计月份 | NOT NULL | YYYYMM格式,如:202501 |
  26 +| F_StartDate | DATE | 归属开始日期 | NOT NULL | 精确到天,用于月中调店 |
  27 +| F_EndDate | DATE | 归属结束日期 | NULL | 为空表示当前仍在归属,精确到天 |
  28 +| F_CreateTime | DATETIME | 创建时间 | | |
  29 +| F_UpdateTime | DATETIME | 更新时间 | | |
  30 +| F_CreateUser | VARCHAR(50) | 创建人 | | |
  31 +| F_UpdateUser | VARCHAR(50) | 更新人 | | |
  32 +| F_IsEffective | INT | 是否有效 | DEFAULT 1 | 1=有效,0=无效(软删除) |
  33 +| F_Remark | VARCHAR(500) | 备注 | | 记录调店原因等 |
  34 +
  35 +#### 索引设计
  36 +
  37 +```sql
  38 +-- 主键索引(自动创建)
  39 +PRIMARY KEY (F_Id)
  40 +
  41 +-- 员工+月份查询索引(最常用)
  42 +INDEX idx_employee_month (F_EmployeeId, F_StatisticsMonth, F_IsEffective)
  43 +
  44 +-- 门店+月份查询索引(用于门店维度统计)
  45 +INDEX idx_store_month (F_StoreId, F_StatisticsMonth, F_IsEffective)
  46 +
  47 +-- 日期范围查询索引(用于精确查询某天的归属)
  48 +INDEX idx_employee_date (F_EmployeeId, F_StartDate, F_EndDate)
  49 +
  50 +-- 时间范围查询索引(用于查询某个时间点的归属)
  51 +INDEX idx_date_range (F_StartDate, F_EndDate, F_IsEffective)
  52 +```
  53 +
  54 +#### 唯一约束
  55 +
  56 +**重要考虑:是否允许一个员工在一个月内归属多个门店?**
  57 +
  58 +**建议:允许**(支持月中调店场景)
  59 +
  60 +**唯一约束设计:**
  61 +- 不设置唯一约束(允许一个员工一个月有多条记录)
  62 +- 通过业务逻辑保证:同一个员工的同一天只能有一条有效记录
  63 +- 通过应用层校验:插入/更新时检查是否有日期重叠
  64 +
  65 +---
  66 +
  67 +### 2. 数据关系图
  68 +
  69 +```
  70 +BASE_USER (员工表)
  71 + ↓ F_Id
  72 +lq_employee_store_assignment (门店归属表)
  73 + ↓ F_StoreId
  74 +lq_mdxx (门店表)
  75 + ↓
  76 +lq_salary_statistics (工资统计表) - 使用归属表的门店信息
  77 +lq_kd_jksyj (开单业绩表) - 使用归属表的门店信息
  78 +lq_xh_jksyj (消耗业绩表) - 使用归属表的门店信息
  79 +```
  80 +
  81 +---
  82 +
  83 +## 📐 业务规则设计
  84 +
  85 +### 1. 基础规则
  86 +
  87 +#### 1.1 时间维度原则
  88 +- **唯一标准**:以数据实际发生的时间(年、月、日)作为门店归属的判断标准
  89 +- **不可变更**:历史归属记录一旦创建,原则上不允许修改(可通过软删除+新增的方式调整)
  90 +
  91 +#### 1.2 归属记录创建规则
  92 +
  93 +**场景A:月初调店(整月归属一个门店)**
  94 +- 员工在1月1日从门店A调到门店B
  95 +- 创建两条记录:
  96 + - 记录1:1月,门店A,开始日期:1月1日,结束日期:1月1日(或空,如果跨月)
  97 + - 记录2:1月,门店B,开始日期:1月2日,结束日期:空(或1月31日)
  98 +
  99 +**场景B:月中调店(一个月归属两个门店)**
  100 +- 员工在1月15日从门店A调到门店B
  101 +- 创建两条记录:
  102 + - 记录1:1月,门店A,开始日期:1月1日,结束日期:1月14日
  103 + - 记录2:1月,门店B,开始日期:1月15日,结束日期:1月31日(或空)
  104 +
  105 +**场景C:整月归属一个门店**
  106 +- 员工整个月都在门店A
  107 +- 创建一条记录:
  108 + - 记录:1月,门店A,开始日期:1月1日,结束日期:1月31日(或空)
  109 +
  110 +#### 1.3 归属记录查询规则
  111 +
  112 +**查询某员工某月的所有门店归属:**
  113 +```sql
  114 +SELECT * FROM lq_employee_store_assignment
  115 +WHERE F_EmployeeId = @EmployeeId
  116 + AND F_StatisticsMonth = @StatisticsMonth
  117 + AND F_IsEffective = 1
  118 +ORDER BY F_StartDate
  119 +```
  120 +
  121 +**查询某员工某天的门店归属:**
  122 +```sql
  123 +SELECT * FROM lq_employee_store_assignment
  124 +WHERE F_EmployeeId = @EmployeeId
  125 + AND F_StartDate <= @Date
  126 + AND (F_EndDate >= @Date OR F_EndDate IS NULL)
  127 + AND F_IsEffective = 1
  128 +ORDER BY F_StartDate DESC
  129 +LIMIT 1
  130 +```
  131 +
  132 +**查询某门店某月的所有员工:**
  133 +```sql
  134 +SELECT DISTINCT F_EmployeeId FROM lq_employee_store_assignment
  135 +WHERE F_StoreId = @StoreId
  136 + AND F_StatisticsMonth = @StatisticsMonth
  137 + AND F_IsEffective = 1
  138 +```
  139 +
  140 +### 2. 数据维护规则
  141 +
  142 +#### 2.1 自动维护机制
  143 +
  144 +**触发时机:员工调店时**
  145 +
  146 +**操作步骤:**
  147 +1. 用户在前端操作:员工从门店A调到门店B(选择调店日期)
  148 +2. 后端处理逻辑:
  149 + - 查询当前员工的有效归属记录(结束日期为空的记录)
  150 + - 如果存在,将结束日期设置为调店日期的前一天
  151 + - 创建新的归属记录:开始日期为调店日期,结束日期为空
  152 + - 如果调店日期是月初,更新统计月份;如果是月中,保持原月份,新增记录
  153 +
  154 +#### 2.2 手动维护机制
  155 +
  156 +**适用场景:**
  157 +- 历史数据补录
  158 +- 数据修正
  159 +- 批量导入
  160 +
  161 +**操作方式:**
  162 +- 提供管理界面:员工门店归属管理
  163 +- 支持批量导入Excel
  164 +- 支持手动添加/编辑/删除(软删除)
  165 +
  166 +#### 2.3 数据校验规则
  167 +
  168 +**插入/更新时的校验:**
  169 +1. **日期范围校验**:
  170 + - 开始日期不能大于结束日期
  171 + - 开始日期和结束日期必须在同一个月内(或跨月但统计月份相同)
  172 +
  173 +2. **重叠校验**:
  174 + - 同一个员工,同一天不能有多条有效记录
  175 + - 如果存在重叠,需要先处理旧记录(结束或删除)
  176 +
  177 +3. **完整性校验**:
  178 + - 员工ID、门店ID必须存在
  179 + - 统计月份格式正确(YYYYMM)
  180 +
  181 +4. **业务逻辑校验**:
  182 + - 门店必须有效(未删除)
  183 + - 员工必须有效(未删除、已启用)
  184 +
  185 +---
  186 +
  187 +## 🔄 与现有系统集成
  188 +
  189 +### 1. 工资计算集成
  190 +
  191 +#### 1.1 健康师工资计算
  192 +
  193 +**当前逻辑:**
  194 +```csharp
  195 +// 1. 优先从业绩数据中获取门店(performanceData中的StoreId)
  196 +// 2. 如果没有,从消耗数据中获取门店
  197 +// 3. 如果还没有,使用 BASE_USER.F_Mdid
  198 +```
  199 +
  200 +**调整后逻辑:**
  201 +```csharp
  202 +// 1. 查询门店归属快照表(按统计月份)
  203 +var storeAssignments = await QueryStoreAssignment(employeeId, statisticsMonth);
  204 +
  205 +// 2. 如果快照表有记录,使用快照表的门店归属
  206 +if (storeAssignments.Any())
  207 +{
  208 + // 按时间范围拆分计算(如果一个月有多个门店)
  209 + foreach (var assignment in storeAssignments)
  210 + {
  211 + // 查询该员工在该门店、该时间范围内的业绩数据
  212 + // 计算该部分工资
  213 + }
  214 +}
  215 +// 3. 如果快照表没有记录,使用当前逻辑(回退方案)
  216 +else
  217 +{
  218 + // 使用现有逻辑:从业务数据或BASE_USER获取
  219 +}
  220 +```
  221 +
  222 +**特殊处理:月中调店**
  223 +- 如果一个月有多个门店归属,需要按时间范围拆分计算
  224 +- 每个时间段的业绩数据单独统计
  225 +- 工资计算结果需要合并(一个员工一个月可能有多条工资记录?还是合并为一条?)
  226 +
  227 +**建议:合并为一条记录**
  228 +- 工资统计表仍然是一个员工一个月一条记录
  229 +- 但需要记录主要门店(归属时间最长的门店,或业绩最多的门店)
  230 +
  231 +#### 1.2 其他岗位工资计算
  232 +
  233 +**店长工资:**
  234 +- 店长通常是固定门店,但也要支持调店
  235 +- 计算逻辑与健康师类似
  236 +
  237 +**主任工资:**
  238 +- 类似逻辑
  239 +
  240 +**其他岗位:**
  241 +- 统一使用快照表逻辑
  242 +
  243 +### 2. 数据补录集成
  244 +
  245 +#### 2.1 开单数据补录
  246 +
  247 +**当前问题:**
  248 +- 补录历史数据时,如果没有门店信息,可能使用当前门店(BASE_USER.F_Mdid)
  249 +
  250 +**调整后逻辑:**
  251 +```csharp
  252 +// 补录开单数据时
  253 +public async Task ImportBillingData(BillingRecord record)
  254 +{
  255 + var billingDate = record.BillingDate; // 开单日期
  256 + var employeeId = record.EmployeeId; // 健康师ID
  257 + var statisticsMonth = $"{billingDate.Year}{billingDate.Month:D2}";
  258 +
  259 + // 1. 查询该日期该员工的门店归属
  260 + var assignment = await QueryStoreAssignmentByDate(employeeId, billingDate);
  261 +
  262 + // 2. 设置门店ID
  263 + if (assignment != null)
  264 + {
  265 + record.StoreId = assignment.StoreId;
  266 + }
  267 + else
  268 + {
  269 + // 回退方案:使用当前逻辑
  270 + record.StoreId = GetStoreIdByCurrentLogic(employeeId);
  271 +
  272 + // 可选:记录警告日志,提示需要维护门店归属
  273 + _logger.LogWarning($"员工{employeeId}在{billingDate}的门店归属未找到,使用当前门店");
  274 + }
  275 +
  276 + // 3. 保存数据
  277 + await SaveBillingRecord(record);
  278 +}
  279 +```
  280 +
  281 +#### 2.2 消耗数据补录
  282 +
  283 +**类似逻辑:**
  284 +- 根据消耗日期(Yjsj)查询门店归属
  285 +- 设置消耗记录的StoreId
  286 +
  287 +### 3. 数据查询集成
  288 +
  289 +#### 3.1 员工列表查询
  290 +
  291 +**当前逻辑:**
  292 +- 直接从BASE_USER表查询,显示F_Mdid对应的门店
  293 +
  294 +**调整后逻辑:**
  295 +- 如果要显示历史门店,需要关联查询快照表
  296 +- 当前门店:BASE_USER.F_Mdid(实时)
  297 +- 历史门店:从快照表查询
  298 +
  299 +#### 3.2 门店员工列表查询
  300 +
  301 +**场景:查询某门店某月的所有员工**
  302 +
  303 +**当前逻辑:**
  304 +- 查询BASE_USER表,F_Mdid = 门店ID
  305 +
  306 +**调整后逻辑:**
  307 +```sql
  308 +-- 查询某门店某月的所有员工
  309 +SELECT DISTINCT esa.F_EmployeeId, u.F_RealName
  310 +FROM lq_employee_store_assignment esa
  311 +INNER JOIN BASE_USER u ON esa.F_EmployeeId = u.F_Id
  312 +WHERE esa.F_StoreId = @StoreId
  313 + AND esa.F_StatisticsMonth = @StatisticsMonth
  314 + AND esa.F_IsEffective = 1
  315 + AND u.F_DeleteMark IS NULL
  316 + AND u.F_EnabledMark = 1
  317 +```
  318 +
  319 +---
  320 +
  321 +## 📊 数据迁移策略
  322 +
  323 +### 1. 历史数据来源
  324 +
  325 +#### 1.1 数据来源分析
  326 +
  327 +**可能的数据来源:**
  328 +1. **工资统计表(lq_salary_statistics等)**
  329 + - 已有历史工资记录
  330 + - 工资记录中有门店ID(F_StoreId)
  331 + - 可以反向推导:员工+月份 → 门店
  332 +
  333 +2. **业务数据(开单/消耗表)**
  334 + - 开单业绩表(lq_kd_jksyj)有门店ID(F_StoreId)
  335 + - 消耗表(lq_xh_jksyj)有门店ID(F_StoreId)
  336 + - 可以统计:员工+月份 → 门店(取出现最多的门店)
  337 +
  338 +3. **BASE_USER表的变更日志(如果有)**
  339 + - 如果系统有记录用户表变更日志,可以追溯
  340 +
  341 +4. **人工确认**
  342 + - 对于无法确定的数据,需要人工确认
  343 +
  344 +#### 1.2 数据迁移优先级
  345 +
  346 +**优先级1:工资统计表(最可靠)**
  347 +- 理由:工资记录是最终结果,门店归属相对准确
  348 +- 方法:从工资统计表提取:员工ID + 统计月份 + 门店ID
  349 +- 时间范围:最近2年的数据(或更长时间,根据业务需要)
  350 +
  351 +**优先级2:业务数据统计(补充)**
  352 +- 理由:对于没有工资记录的月份,从业务数据推断
  353 +- 方法:统计每个员工每个月的业绩数据,取门店出现最多的
  354 +- 注意:可能存在一个月有多个门店的情况(月中调店)
  355 +
  356 +**优先级3:BASE_USER当前门店(兜底)**
  357 +- 理由:对于完全没有数据的员工,使用当前门店
  358 +- 方法:BASE_USER.F_Mdid作为最后兜底
  359 +
  360 +### 2. 数据迁移步骤
  361 +
  362 +#### 步骤1:数据提取
  363 +
  364 +```sql
  365 +-- 从工资统计表提取(健康师工资)
  366 +INSERT INTO lq_employee_store_assignment
  367 +(F_Id, F_EmployeeId, F_StoreId, F_StoreName, F_Year, F_Month, F_StatisticsMonth, F_StartDate, F_EndDate, ...)
  368 +SELECT
  369 + YitIdHelper.NextId(),
  370 + F_EmployeeId,
  371 + F_StoreId,
  372 + (SELECT F_Dm FROM lq_mdxx WHERE F_Id = F_StoreId),
  373 + YEAR(STR_TO_DATE(CONCAT(F_StatisticsMonth, '01'), '%Y%m%d')),
  374 + MONTH(STR_TO_DATE(CONCAT(F_StatisticsMonth, '01'), '%Y%m%d')),
  375 + F_StatisticsMonth,
  376 + STR_TO_DATE(CONCAT(F_StatisticsMonth, '01'), '%Y%m%d'), -- 开始日期:月初
  377 + LAST_DAY(STR_TO_DATE(CONCAT(F_StatisticsMonth, '01'), '%Y%m%d')), -- 结束日期:月末
  378 + NOW(),
  379 + NOW(),
  380 + 'System',
  381 + 'System',
  382 + 1
  383 +FROM lq_salary_statistics
  384 +WHERE F_StoreId IS NOT NULL AND F_StoreId != ''
  385 +GROUP BY F_EmployeeId, F_StatisticsMonth, F_StoreId
  386 +```
  387 +
  388 +#### 步骤2:数据清洗
  389 +
  390 +**处理重复记录:**
  391 +- 如果一个员工一个月有多条记录(可能是数据异常,也可能是月中调店)
  392 +- 需要人工确认:是数据异常还是确实调店
  393 +
  394 +**处理缺失数据:**
  395 +- 对于没有工资记录但可能有业务数据的员工
  396 +- 从业务数据统计推断
  397 +
  398 +#### 步骤3:数据验证
  399 +
  400 +**验证规则:**
  401 +1. 每个员工每个月至少有一条记录(如果有业务数据)
  402 +2. 门店ID必须存在
  403 +3. 统计月份格式正确
  404 +4. 日期范围合理
  405 +
  406 +**验证方法:**
  407 +- 抽样检查:随机抽取一定比例的数据,人工验证
  408 +- 逻辑检查:检查是否有明显异常(如同一员工同一天多个门店)
  409 +
  410 +#### 步骤4:数据导入
  411 +
  412 +**导入方式:**
  413 +- 批量导入(使用事务保证一致性)
  414 +- 分批次导入(避免锁表时间过长)
  415 +- 记录导入日志(成功/失败记录)
  416 +
  417 +### 3. 数据迁移时间窗口
  418 +
  419 +**建议:**
  420 +- 选择业务低峰期(如夜间或周末)
  421 +- 分批次迁移(先迁移最近3个月,验证无误后再迁移更多)
  422 +- 保留回滚方案(如果迁移失败,可以删除导入的数据)
  423 +
  424 +---
  425 +
  426 +## 🎯 边界情况处理
  427 +
  428 +### 1. 月中调店
  429 +
  430 +**场景:** 员工在1月15日从门店A调到门店B
  431 +
  432 +**处理方案:**
  433 +- 创建两条归属记录
  434 +- 工资计算时,按时间范围拆分计算
  435 +- 最终工资记录合并为一条(记录主要门店)
  436 +
  437 +**问题:**
  438 +- 工资统计表中的门店ID如何设置?
  439 + - 方案A:设置为主要门店(归属时间最长的门店,或业绩最多的门店)
  440 + - 方案B:设置为空,或记录为"多门店"
  441 + - **建议:方案A**,记录为主要门店,在备注或明细中说明
  442 +
  443 +### 2. 数据缺失
  444 +
  445 +**场景:** 快照表中没有某员工某月的归属记录
  446 +
  447 +**处理方案:**
  448 +- 回退到现有逻辑(从业务数据或BASE_USER获取)
  449 +- 记录警告日志,提示需要维护门店归属
  450 +- 可选:自动创建一条归属记录(使用当前门店或推断的门店)
  451 +
  452 +### 3. 数据冲突
  453 +
  454 +**场景:** 同一个员工同一天有多条归属记录(数据异常)
  455 +
  456 +**处理方案:**
  457 +- 数据校验时发现冲突,不允许插入/更新
  458 +- 提示用户先处理冲突记录
  459 +- 提供数据修正工具
  460 +
  461 +### 4. 跨月调店
  462 +
  463 +**场景:** 员工在1月31日调到门店B,实际在2月1日生效
  464 +
  465 +**处理方案:**
  466 +- 记录1月:门店A,结束日期:1月31日
  467 +- 记录2月:门店B,开始日期:2月1日
  468 +- 如果调店日期是月末最后一天,可以考虑归属到下个月
  469 +
  470 +### 5. 离职员工
  471 +
  472 +**场景:** 员工在月中离职
  473 +
  474 +**处理方案:**
  475 +- 创建归属记录:结束日期设置为离职日期
  476 +- 如果离职日期是月中,归属记录只到离职日期
  477 +- 工资计算时,只计算到离职日期
  478 +
  479 +### 6. 新员工入职
  480 +
  481 +**场景:** 新员工在月中入职
  482 +
  483 +**处理方案:**
  484 +- 创建归属记录:开始日期为入职日期
  485 +- 如果入职日期是月中,从入职日期开始归属
  486 +
  487 +---
  488 +
  489 +## ⚡ 性能考虑
  490 +
  491 +### 1. 查询性能优化
  492 +
  493 +#### 1.1 索引优化
  494 +- 已设计多个索引(见索引设计部分)
  495 +- 定期分析索引使用情况,优化索引
  496 +
  497 +#### 1.2 查询优化
  498 +
  499 +**批量查询:**
  500 +- 工资计算时,批量查询所有员工的归属记录,避免N+1查询
  501 +```csharp
  502 +// 批量查询
  503 +var employeeIds = salaryList.Select(s => s.EmployeeId).Distinct().ToList();
  504 +var assignments = await _db.Queryable<EmployeeStoreAssignmentEntity>()
  505 + .Where(x => employeeIds.Contains(x.EmployeeId) && x.StatisticsMonth == monthStr)
  506 + .ToListAsync();
  507 +var assignmentDict = assignments.GroupBy(x => x.EmployeeId).ToDictionary(g => g.Key, g => g.ToList());
  508 +```
  509 +
  510 +**缓存策略:**
  511 +- 对于频繁查询的数据(如当前月的归属记录),可以使用缓存
  512 +- 缓存失效策略:归属记录更新时,清除相关缓存
  513 +
  514 +### 2. 写入性能优化
  515 +
  516 +#### 2.1 批量插入
  517 +- 数据迁移时,使用批量插入(如每次插入1000条)
  518 +- 使用事务保证一致性
  519 +
  520 +#### 2.2 异步处理
  521 +- 数据迁移可以异步处理
  522 +- 员工调店时,归属记录的创建可以异步处理(如果对实时性要求不高)
  523 +
  524 +### 3. 表分区考虑
  525 +
  526 +**是否需要分区?**
  527 +- 如果数据量很大(如10年历史数据,每月几千员工),可以考虑按年份或月份分区
  528 +- 当前阶段可能不需要,但设计时要考虑扩展性
  529 +
  530 +---
  531 +
  532 +## 🔒 数据一致性保证
  533 +
  534 +### 1. 事务处理
  535 +
  536 +**关键操作使用事务:**
  537 +- 员工调店时:更新旧记录 + 创建新记录(在一个事务中)
  538 +- 数据迁移:批量导入使用事务
  539 +
  540 +### 2. 数据校验
  541 +
  542 +**应用层校验:**
  543 +- 插入/更新前校验数据完整性和业务逻辑
  544 +- 防止数据冲突
  545 +
  546 +**数据库层约束:**
  547 +- 外键约束(如果MySQL支持,但通常不使用外键,使用应用层保证)
  548 +- 非空约束、唯一约束(根据业务需要)
  549 +
  550 +### 3. 数据同步
  551 +
  552 +**与BASE_USER表同步:**
  553 +- 员工调店时,同时更新BASE_USER.F_Mdid和归属表
  554 +- 或者:归属表作为数据源,BASE_USER.F_Mdid从归属表计算(当前月份的主要门店)
  555 +
  556 +**建议:**
  557 +- BASE_USER.F_Mdid保持为当前门店(实时)
  558 +- 归属表记录历史归属
  559 +- 两者可以不一致(BASE_USER是当前状态,归属表是历史记录)
  560 +
  561 +---
  562 +
  563 +## 👤 用户体验设计
  564 +
  565 +### 1. 管理界面
  566 +
  567 +#### 1.1 员工门店归属管理页面
  568 +
  569 +**功能:**
  570 +- 列表展示:员工、门店、月份、时间范围
  571 +- 查询:按员工、门店、月份查询
  572 +- 添加:手动添加归属记录
  573 +- 编辑:修改归属记录(需要校验)
  574 +- 删除:软删除归属记录
  575 +- 批量导入:Excel导入
  576 +
  577 +#### 1.2 员工调店操作
  578 +
  579 +**操作流程:**
  580 +1. 选择员工
  581 +2. 选择目标门店
  582 +3. 选择调店日期(默认今天)
  583 +4. 系统自动:
  584 + - 查询当前有效归属记录
  585 + - 更新旧记录结束日期
  586 + - 创建新记录开始日期
  587 +5. 确认保存
  588 +
  589 +### 2. 数据展示
  590 +
  591 +#### 2.1 员工详情页
  592 +
  593 +**显示:**
  594 +- 当前门店:BASE_USER.F_Mdid(实时)
  595 +- 历史归属:从归属表查询,列表展示
  596 +
  597 +#### 2.2 工资计算页面
  598 +
  599 +**显示:**
  600 +- 如果使用了快照表的门店,显示来源标识
  601 +- 如果使用了回退逻辑,显示警告提示
  602 +
  603 +---
  604 +
  605 +## 🔮 扩展性考虑
  606 +
  607 +### 1. 未来可能的业务变化
  608 +
  609 +#### 1.1 多门店归属
  610 +**场景:** 一个员工可能同时归属多个门店(兼职)
  611 +
  612 +**扩展方案:**
  613 +- 当前设计已经支持(一个员工一个月可以有多条记录)
  614 +- 需要调整业务逻辑:工资计算时,如何分配业绩到不同门店
  615 +
  616 +#### 1.2 部门归属
  617 +**场景:** 不仅记录门店归属,还要记录部门归属
  618 +
  619 +**扩展方案:**
  620 +- 可以增加字段:F_DepartmentId(部门ID)
  621 +- 或者创建新的表:员工部门归属表
  622 +
  623 +#### 1.3 岗位变更
  624 +**场景:** 记录员工岗位变更历史
  625 +
  626 +**扩展方案:**
  627 +- 类似设计:员工岗位归属表
  628 +- 或者:在归属表中增加字段:F_Position(岗位)
  629 +
  630 +### 2. 性能扩展
  631 +
  632 +#### 2.1 数据归档
  633 +- 历史数据(如3年前的数据)可以归档到历史表
  634 +- 当前表只保留最近2-3年的数据
  635 +
  636 +#### 2.2 读写分离
  637 +- 如果查询压力大,可以考虑读写分离
  638 +- 归属表主要是查询操作,可以放到只读库
  639 +
  640 +---
  641 +
  642 +## ⚠️ 风险点与应对措施
  643 +
  644 +### 1. 数据准确性风险
  645 +
  646 +**风险:** 历史数据迁移可能不准确
  647 +
  648 +**应对:**
  649 +- 多数据源验证(工资表、业务数据、人工确认)
  650 +- 分批次迁移,每批验证
  651 +- 提供数据修正工具
  652 +
  653 +### 2. 性能风险
  654 +
  655 +**风险:** 查询性能可能下降
  656 +
  657 +**应对:**
  658 +- 合理设计索引
  659 +- 使用缓存
  660 +- 监控查询性能,及时优化
  661 +
  662 +### 3. 数据一致性风险
  663 +
  664 +**风险:** 归属表与BASE_USER表可能不一致
  665 +
  666 +**应对:**
  667 +- 明确两者的作用:BASE_USER是当前状态,归属表是历史记录
  668 +- 提供数据同步工具(可选)
  669 +- 在关键操作时校验一致性
  670 +
  671 +### 4. 业务复杂度风险
  672 +
  673 +**风险:** 月中调店等复杂场景增加业务复杂度
  674 +
  675 +**应对:**
  676 +- 提供清晰的业务规则文档
  677 +- 在管理界面提供操作指引
  678 +- 提供数据校验和提示
  679 +
  680 +---
  681 +
  682 +## 📋 实施 Checklist
  683 +
  684 +### 阶段一:数据准备
  685 +- [ ] 创建归属表结构
  686 +- [ ] 设计索引
  687 +- [ ] 编写数据迁移脚本
  688 +- [ ] 准备测试数据
  689 +
  690 +### 阶段二:功能开发
  691 +- [ ] 开发归属记录管理功能(CRUD)
  692 +- [ ] 开发员工调店功能(自动创建归属记录)
  693 +- [ ] 调整工资计算逻辑(集成快照表)
  694 +- [ ] 调整数据补录逻辑(集成快照表)
  695 +- [ ] 开发数据校验逻辑
  696 +
  697 +### 阶段三:数据迁移
  698 +- [ ] 备份现有数据
  699 +- [ ] 执行数据迁移(小批量测试)
  700 +- [ ] 验证数据准确性
  701 +- [ ] 全量数据迁移
  702 +- [ ] 数据验证和修正
  703 +
  704 +### 阶段四:测试验证
  705 +- [ ] 单元测试
  706 +- [ ] 集成测试
  707 +- [ ] 性能测试
  708 +- [ ] 用户验收测试
  709 +
  710 +### 阶段五:上线部署
  711 +- [ ] 部署到测试环境
  712 +- [ ] 测试环境验证
  713 +- [ ] 部署到生产环境
  714 +- [ ] 监控运行情况
  715 +- [ ] 数据修正和支持
  716 +
  717 +---
  718 +
  719 +## 🔍 参考现有系统设计
  720 +
  721 +### 1. 现有归属表分析
  722 +
  723 +系统中已经存在类似的归属表设计,可以参考其设计思路:
  724 +
  725 +#### 1.1 大项目部老师归属表(lq_md_major_project_teacher_assignment)
  726 +
  727 +**设计特点:**
  728 +- 只记录月份级别(Year + Month),不记录日期范围
  729 +- 唯一约束:门店 + 年份 + 月份 + 老师ID
  730 +- 一个老师一个月只能有一条记录(不支持月中调店)
  731 +
  732 +**适用场景:**
  733 +- 大项目部老师通常是整月归属一个门店
  734 +- 如果需要月中调店,需要拆分月份处理
  735 +
  736 +#### 1.2 总经理门店归属表(lq_md_general_manager_lifeline)
  737 +
  738 +**设计特点:**
  739 +- 记录月份级别(Month,YYYYMM格式)
  740 +- 唯一约束:门店 + 月份 + 总经理ID
  741 +- 一个总经理一个月在一个门店只能有一条记录
  742 +- 但一个总经理可以管理多个门店(通过多条记录实现)
  743 +
  744 +**适用场景:**
  745 +- 总经理/经理通常管理多个门店
  746 +- 整月归属,不涉及月中调店
  747 +
  748 +### 2. 设计对比与选择
  749 +
  750 +#### 方案A:按月设计(参考现有归属表)
  751 +
  752 +**特点:**
  753 +- 只记录月份级别(Year + Month)
  754 +- 一个员工一个月只能有一条记录
  755 +- 不支持月中调店(如果需要调店,需要拆分月份)
  756 +
  757 +**优点:**
  758 +- 设计简单,与现有归属表一致
  759 +- 查询逻辑简单
  760 +- 性能好(数据量少)
  761 +
  762 +**缺点:**
  763 +- 不支持月中调店场景
  764 +- 如果员工在月中调店,需要手动拆分月份
  765 +
  766 +#### 方案B:按日期范围设计(推荐方案)
  767 +
  768 +**特点:**
  769 +- 记录日期范围(StartDate + EndDate)
  770 +- 一个员工一个月可以有多条记录
  771 +- 支持月中调店
  772 +
  773 +**优点:**
  774 +- 支持月中调店等复杂场景
  775 +- 时间维度精确
  776 +- 更灵活
  777 +
  778 +**缺点:**
  779 +- 设计相对复杂
  780 +- 查询逻辑需要处理日期范围
  781 +- 数据量可能更多
  782 +
  783 +### 3. 推荐方案
  784 +
  785 +**建议采用方案B(按日期范围设计)**,理由:
  786 +
  787 +1. **业务需求**:健康师等岗位确实存在月中调店的场景
  788 +2. **数据准确性**:日期范围设计更精确,能准确反映实际归属情况
  789 +3. **扩展性**:未来如果有更细粒度的需求,日期范围设计更容易扩展
  790 +4. **兼容性**:可以与现有按月设计的归属表共存,互不影响
  791 +
  792 +**但是要注意:**
  793 +- 如果某些岗位(如大项目部老师、总经理)不需要支持月中调店,可以继续使用现有的按月设计
  794 +- 门店归属快照表主要用于健康师、店助、店长等需要精确时间范围的岗位
  795 +
  796 +---
  797 +
  798 +## 💭 额外思考点
  799 +
  800 +### 1. 数据一致性策略
  801 +
  802 +#### 1.1 BASE_USER.F_Mdid 与归属表的关系
  803 +
  804 +**问题:** BASE_USER.F_Mdid 应该如何处理?
  805 +
  806 +**方案A:保持独立**
  807 +- BASE_USER.F_Mdid 保持为当前门店(实时状态)
  808 +- 归属表记录历史归属
  809 +- 两者可以不一致(BASE_USER是当前状态,归属表是历史记录)
  810 +
  811 +**方案B:同步更新**
  812 +- 员工调店时,同时更新 BASE_USER.F_Mdid 和归属表
  813 +- 保持一致
  814 +
  815 +**方案C:BASE_USER.F_Mdid 从归属表计算**
  816 +- BASE_USER.F_Mdid 不再手动维护
  817 +- 从归属表计算当前月份的主要门店
  818 +- 查询时实时计算或定期同步
  819 +
  820 +**推荐:方案A**
  821 +- 理由:BASE_USER.F_Mdid 是实时状态,归属表是历史记录,用途不同
  822 +- BASE_USER.F_Mdid 用于当前业务查询(如:查询当前门店的员工)
  823 +- 归属表用于历史数据查询(如:计算历史月份工资)
  824 +- 两者可以不一致,但需要明确各自的作用
  825 +
  826 +#### 1.2 数据同步策略
  827 +
  828 +**如果采用方案A,需要考虑:**
  829 +- 员工调店时,BASE_USER.F_Mdid 和归属表的更新是否需要在同一个事务中?
  830 +- 建议:在同一个事务中更新,保证一致性
  831 +
  832 +### 2. 数据维护的便利性
  833 +
  834 +#### 2.1 自动维护 vs 手动维护
  835 +
  836 +**自动维护:**
  837 +- 员工调店时,自动创建归属记录
  838 +- 优点:减少人工操作,数据准确
  839 +- 缺点:如果调店操作本身有问题,归属记录也会有问题
  840 +
  841 +**手动维护:**
  842 +- 需要手动创建/编辑归属记录
  843 +- 优点:可控性强
  844 +- 缺点:增加操作复杂度,可能遗漏
  845 +
  846 +**推荐:混合模式**
  847 +- 员工调店时,自动创建归属记录(默认)
  848 +- 提供手动维护界面,可以修改/补录
  849 +- 支持批量导入历史数据
  850 +
  851 +#### 2.2 数据修正机制
  852 +
  853 +**场景:发现历史归属数据有误**
  854 +
  855 +**处理方案:**
  856 +1. **不允许修改历史数据**(严格模式)
  857 + - 如果需要修正,只能删除(软删除)+ 新增
  858 + - 优点:保持数据可追溯性
  859 + - 缺点:操作复杂
  860 +
  861 +2. **允许修改历史数据**(灵活模式)
  862 + - 可以直接修改历史记录
  863 + - 优点:操作简单
  864 + - 缺点:无法追溯修改历史
  865 +
  866 +**推荐:方案1(严格模式)**
  867 +- 历史数据一旦创建,不允许直接修改
  868 +- 需要修正时,软删除旧记录,创建新记录
  869 +- 记录修正原因和操作人
  870 +
  871 +### 3. 查询性能优化
  872 +
  873 +#### 3.1 批量查询优化
  874 +
  875 +**场景:计算某月所有员工的工资**
  876 +
  877 +**优化策略:**
  878 +```csharp
  879 +// 一次性查询所有员工的归属记录
  880 +var employeeIds = allEmployees.Select(e => e.Id).ToList();
  881 +var assignments = await _db.Queryable<EmployeeStoreAssignmentEntity>()
  882 + .Where(x => employeeIds.Contains(x.EmployeeId) && x.StatisticsMonth == monthStr)
  883 + .ToListAsync();
  884 +
  885 +// 转换为字典,便于快速查找
  886 +var assignmentDict = assignments
  887 + .GroupBy(x => x.EmployeeId)
  888 + .ToDictionary(g => g.Key, g => g.OrderBy(x => x.StartDate).ToList());
  889 +```
  890 +
  891 +#### 3.2 缓存策略
  892 +
  893 +**缓存哪些数据?**
  894 +- 当前月的归属记录(变化较少)
  895 +- 员工当前门店(BASE_USER.F_Mdid)
  896 +
  897 +**缓存失效策略:**
  898 +- 归属记录更新时,清除相关缓存
  899 +- 定时刷新(如每天凌晨)
  900 +
  901 +#### 3.3 数据分区考虑
  902 +
  903 +**是否需要分区?**
  904 +- 如果数据量很大(如10年历史,每月几千员工),可以考虑按年份分区
  905 +- 当前阶段可能不需要,但设计时要考虑扩展性
  906 +
  907 +### 4. 异常情况处理
  908 +
  909 +#### 4.1 数据冲突检测
  910 +
  911 +**场景:同一个员工同一天有多条归属记录**
  912 +
  913 +**检测时机:**
  914 +- 插入/更新归属记录时
  915 +- 数据迁移时
  916 +
  917 +**处理方案:**
  918 +- 应用层校验:插入前检查是否有日期重叠
  919 +- 如果发现冲突,拒绝操作,提示用户处理
  920 +
  921 +**校验SQL示例:**
  922 +```sql
  923 +-- 检查是否存在日期重叠
  924 +SELECT COUNT(*)
  925 +FROM lq_employee_store_assignment
  926 +WHERE F_EmployeeId = @EmployeeId
  927 + AND F_IsEffective = 1
  928 + AND (
  929 + (F_StartDate <= @EndDate AND F_EndDate >= @StartDate)
  930 + OR (F_StartDate IS NULL AND F_EndDate IS NULL)
  931 + )
  932 +```
  933 +
  934 +#### 4.2 数据缺失处理
  935 +
  936 +**场景:快照表中没有某员工某月的归属记录**
  937 +
  938 +**处理方案:**
  939 +1. **回退到现有逻辑**(推荐)
  940 + - 从业务数据或BASE_USER获取
  941 + - 记录警告日志
  942 + - 可选:自动创建一条归属记录(使用当前门店或推断的门店)
  943 +
  944 +2. **报错处理**
  945 + - 如果快照表没有记录,直接报错
  946 + - 要求用户先维护归属记录
  947 +
  948 +**推荐:方案1**
  949 +- 保持系统的容错性
  950 +- 记录警告,便于后续补录数据
  951 +
  952 +### 5. 与现有系统的兼容性
  953 +
  954 +#### 5.1 渐进式迁移
  955 +
  956 +**迁移策略:**
  957 +1. **第一阶段:新建表,不影响现有逻辑**
  958 + - 创建归属表
  959 + - 数据迁移
  960 + - 但工资计算仍使用现有逻辑
  961 +
  962 +2. **第二阶段:双写模式**
  963 + - 工资计算时,同时使用新逻辑和旧逻辑
  964 + - 对比结果,验证新逻辑的正确性
  965 +
  966 +3. **第三阶段:切换到新逻辑**
  967 + - 新逻辑验证无误后,切换到新逻辑
  968 + - 保留旧逻辑作为回退方案
  969 +
  970 +4. **第四阶段:移除旧逻辑**
  971 + - 新逻辑稳定运行一段时间后,移除旧逻辑
  972 +
  973 +#### 5.2 数据迁移的准确性
  974 +
  975 +**迁移数据来源优先级:**
  976 +1. 工资统计表(最可靠)
  977 +2. 业务数据统计(补充)
  978 +3. BASE_USER当前门店(兜底)
  979 +
  980 +**迁移后的验证:**
  981 +- 抽样检查:随机抽取一定比例的数据,人工验证
  982 +- 逻辑检查:检查是否有明显异常
  983 +- 对比验证:对比迁移前后的工资计算结果
  984 +
  985 +### 6. 未来扩展性
  986 +
  987 +#### 6.1 多门店归属
  988 +
  989 +**场景:一个员工同时归属多个门店(兼职)**
  990 +
  991 +**扩展方案:**
  992 +- 当前设计已经支持(一个员工一个月可以有多条记录)
  993 +- 需要调整工资计算逻辑:如何分配业绩到不同门店
  994 +- 可能需要增加字段:业绩分配比例
  995 +
  996 +#### 6.2 部门归属
  997 +
  998 +**场景:不仅记录门店归属,还要记录部门归属**
  999 +
  1000 +**扩展方案:**
  1001 +- 可以增加字段:F_DepartmentId(部门ID)
  1002 +- 或者创建新表:员工部门归属表
  1003 +- 或者:在归属表中增加字段:F_DepartmentId(如果需要同时记录门店和部门)
  1004 +
  1005 +#### 6.3 岗位变更历史
  1006 +
  1007 +**场景:记录员工岗位变更历史**
  1008 +
  1009 +**扩展方案:**
  1010 +- 可以创建新表:员工岗位归属表(类似设计)
  1011 +- 或者:在归属表中增加字段:F_Position(岗位)
  1012 +
  1013 +#### 6.4 其他业务属性
  1014 +
  1015 +**场景:需要记录其他业务属性(如:职级、薪资等级等)**
  1016 +
  1017 +**扩展方案:**
  1018 +- 可以在归属表中增加字段
  1019 +- 或者创建关联表
  1020 +
  1021 +**设计原则:**
  1022 +- 优先考虑在归属表中增加字段(如果属性是时间相关的)
  1023 +- 如果属性与门店归属无关,考虑创建新表
  1024 +
  1025 +---
  1026 +
  1027 +## 📝 总结
  1028 +
  1029 +门店归属快照表方案是一个相对完善的解决方案,能够:
  1030 +
  1031 +1. **解决核心问题**:以时间维度为唯一标准,准确记录历史门店归属
  1032 +2. **支持复杂场景**:月中调店、跨月调店等
  1033 +3. **保证数据完整性**:通过多数据源验证和数据校验
  1034 +4. **考虑性能**:合理设计索引和查询优化
  1035 +5. **易于扩展**:设计时考虑了未来的业务变化
  1036 +
  1037 +**关键成功因素:**
  1038 +- 数据迁移的准确性
  1039 +- 业务规则的清晰性
  1040 +- 用户操作的简便性
  1041 +- 系统性能的稳定性
  1042 +
  1043 +---
  1044 +
  1045 +**文档版本:** v1.0
  1046 +**创建日期:** 2026-01-09
  1047 +**文档性质:** 详细设计方案(仅思考,不修改代码)
... ...
netcore/src/Modularity/Extend/NCC.Extend/LqAssistantSalaryService.cs
1 1 using Microsoft.AspNetCore.Authorization;
  2 +using Microsoft.AspNetCore.Http;
2 3 using Microsoft.AspNetCore.Mvc;
  4 +using Microsoft.Extensions.Logging;
3 5 using NCC.Common.Filter;
4 6 using NCC.Common.Helper;
5 7 using NCC.Dependency;
6 8 using NCC.DynamicApiController;
7 9 using NCC.Extend.Entitys.Dto.LqAssistantSalary;
  10 +using NCC.Extend.Entitys.Dto.LqSalary;
8 11 using NCC.Extend.Entitys.lq_assistant_salary_statistics;
9 12 using NCC.Extend.Entitys.lq_attendance_summary;
10 13 using NCC.Extend.Entitys.lq_hytk_hytk;
... ... @@ -14,10 +17,13 @@ using NCC.Extend.Entitys.lq_md_xdbhsj;
14 17 using NCC.Extend.Entitys.lq_mdxx;
15 18 using NCC.Extend.Entitys.lq_xh_hyhk;
16 19 using NCC.Extend.Entitys.lq_xh_jksyj;
  20 +using NCC.FriendlyException;
17 21 using NCC.System.Entitys.Permission;
18 22 using SqlSugar;
19 23 using System;
20 24 using System.Collections.Generic;
  25 +using System.Data;
  26 +using System.IO;
21 27 using System.Linq;
22 28 using System.Threading.Tasks;
23 29 using Yitter.IdGenerator;
... ... @@ -32,13 +38,15 @@ namespace NCC.Extend
32 38 public class LqAssistantSalaryService : IDynamicApiController, ITransient
33 39 {
34 40 private readonly ISqlSugarClient _db;
  41 + private readonly ILogger<LqAssistantSalaryService> _logger;
35 42  
36 43 /// <summary>
37 44 /// 初始化一个<see cref="LqAssistantSalaryService"/>类型的新实例
38 45 /// </summary>
39   - public LqAssistantSalaryService(ISqlSugarClient db)
  46 + public LqAssistantSalaryService(ISqlSugarClient db, ILogger<LqAssistantSalaryService> logger)
40 47 {
41 48 _db = db;
  49 + _logger = logger;
42 50 }
43 51  
44 52 /// <summary>
... ... @@ -51,17 +59,7 @@ namespace NCC.Extend
51 59 {
52 60 var monthStr = $"{input.Year}{input.Month:D2}";
53 61  
54   - // 1. 检查当月是否已生成工资数据
55   - var exists = await _db.Queryable<LqAssistantSalaryStatisticsEntity>()
56   - .AnyAsync(x => x.StatisticsMonth == monthStr);
57   -
58   - // 2. 如果没有数据,则进行计算
59   - if (!exists)
60   - {
61   - await CalculateAssistantSalary(input.Year, input.Month);
62   - }
63   -
64   - // 3. 查询数据
  62 + // 查询数据
65 63 var query = _db.Queryable<LqAssistantSalaryStatisticsEntity>()
66 64 .Where(x => x.StatisticsMonth == monthStr);
67 65  
... ... @@ -125,6 +123,69 @@ namespace NCC.Extend
125 123 }
126 124  
127 125 /// <summary>
  126 + /// 通过月份和员工ID查询工资
  127 + /// </summary>
  128 + [HttpGet("query-by-employee")]
  129 + public async Task<AssistantSalaryOutput> GetSalaryByEmployee([FromQuery] SalaryQueryByEmployeeInput input)
  130 + {
  131 + if (input.Year <= 0 || input.Month <= 0 || input.Month > 12)
  132 + throw NCCException.Oh("年份和月份参数不正确");
  133 + if (string.IsNullOrWhiteSpace(input.EmployeeId))
  134 + throw NCCException.Oh("员工ID不能为空");
  135 + var monthStr = $"{input.Year}{input.Month:D2}";
  136 + var salary = await _db.Queryable<LqAssistantSalaryStatisticsEntity>()
  137 + .Where(x => x.StatisticsMonth == monthStr && x.EmployeeId == input.EmployeeId)
  138 + .Select(x => new AssistantSalaryOutput
  139 + {
  140 + Id = x.Id,
  141 + StoreName = x.StoreName,
  142 + EmployeeName = x.EmployeeName,
  143 + Position = x.Position,
  144 + StoreTotalPerformance = x.StoreTotalPerformance,
  145 + StoreBillingPerformance = x.StoreBillingPerformance,
  146 + StoreRefundPerformance = x.StoreRefundPerformance,
  147 + StoreLifeline = x.StoreLifeline,
  148 + PerformanceCompletionRate = x.PerformanceCompletionRate,
  149 + CommissionRate = x.CommissionRate,
  150 + CommissionAmount = x.CommissionAmount,
  151 + HeadCount = x.HeadCount,
  152 + Stage1TargetHeadCount = x.Stage1TargetHeadCount,
  153 + Stage2TargetHeadCount = x.Stage2TargetHeadCount,
  154 + ReachedStage1 = x.ReachedStage1,
  155 + ReachedStage2 = x.ReachedStage2,
  156 + StageRewardAmount = x.StageRewardAmount,
  157 + Stage1Reward = x.Stage1Reward,
  158 + Stage2Reward = x.Stage2Reward,
  159 + BaseSalary = x.BaseSalary,
  160 + PhoneManagementFee = x.PhoneManagementFee,
  161 + WorkingDays = x.WorkingDays,
  162 + LeaveDays = x.LeaveDays,
  163 + GrossSalary = x.GrossSalary,
  164 + ActualSalary = x.ActualSalary,
  165 + TotalDeduction = x.TotalDeduction,
  166 + TotalSubsidy = x.TotalSubsidy,
  167 + Bonus = x.Bonus,
  168 + ReturnPhoneDeposit = x.ReturnPhoneDeposit,
  169 + ReturnAccommodationDeposit = x.ReturnAccommodationDeposit,
  170 + MonthlyPaymentStatus = x.MonthlyPaymentStatus,
  171 + PaidAmount = x.PaidAmount,
  172 + PendingAmount = x.PendingAmount,
  173 + LastMonthSupplement = x.LastMonthSupplement,
  174 + MonthlyTotalPayment = x.MonthlyTotalPayment,
  175 + IsLocked = x.IsLocked,
  176 + UpdateTime = x.UpdateTime,
  177 + StoreType = x.StoreType,
  178 + StoreCategory = x.StoreCategory,
  179 + IsNewStore = x.IsNewStore,
  180 + NewStoreProtectionStage = x.NewStoreProtectionStage
  181 + })
  182 + .FirstAsync();
  183 + if (salary == null)
  184 + throw NCCException.Oh($"未找到员工{input.EmployeeId}在{input.Year}年{input.Month}月的工资记录");
  185 + return salary;
  186 + }
  187 +
  188 + /// <summary>
128 189 /// 计算店助工资
129 190 /// </summary>
130 191 /// <param name="year">年份</param>
... ... @@ -356,7 +417,7 @@ namespace NCC.Extend
356 417 // - 100%以上部分:0.6%
357 418 totalCommission = CalculateAssistantCommission(salary.StoreTotalPerformance, salary.StoreLifeline);
358 419 }
359   -
  420 +
360 421 // 计算平均提成比例(用于显示)
361 422 if (salary.StoreTotalPerformance > 0)
362 423 {
... ... @@ -465,7 +526,7 @@ namespace NCC.Extend
465 526 {
466 527 storeTotalCommission = CalculateAssistantCommission(salary.StoreTotalPerformance, salary.StoreLifeline);
467 528 }
468   -
  529 +
469 530 // 按比例计算提成:门店总提成 / 当月天数 × 在店天数
470 531 salary.CommissionAmount = storeTotalCommission / daysInMonth * workingDays;
471 532  
... ... @@ -540,12 +601,71 @@ namespace NCC.Extend
540 601 // 3. 保存数据
541 602 if (assistantSalaryList.Any())
542 603 {
543   - // 先删除当月旧数据 (防止重复)
544   - await _db.Deleteable<LqAssistantSalaryStatisticsEntity>()
  604 + // 查询当月已存在的记录(用于检查是否已锁定或已确认)
  605 + var existingRecords = await _db.Queryable<LqAssistantSalaryStatisticsEntity>()
545 606 .Where(x => x.StatisticsMonth == monthStr)
546   - .ExecuteCommandAsync();
  607 + .ToListAsync();
  608 +
  609 + var existingDict = existingRecords
  610 + .Where(x => !string.IsNullOrEmpty(x.EmployeeId))
  611 + .GroupBy(x => x.EmployeeId)
  612 + .ToDictionary(g => g.Key, g => g.First());
  613 +
  614 + // 分离需要插入的新记录和需要更新的记录,以及需要跳过的记录
  615 + var recordsToInsert = new List<LqAssistantSalaryStatisticsEntity>();
  616 + var recordsToUpdate = new List<LqAssistantSalaryStatisticsEntity>();
  617 + var skippedCount = 0;
547 618  
548   - await _db.Insertable(assistantSalaryList).ExecuteCommandAsync();
  619 + foreach (var salary in assistantSalaryList)
  620 + {
  621 + if (existingDict.ContainsKey(salary.EmployeeId))
  622 + {
  623 + var existing = existingDict[salary.EmployeeId];
  624 + // 如果已锁定或已确认,则跳过,不更新
  625 + if (existing.IsLocked == 1 || existing.EmployeeConfirmStatus == 1)
  626 + {
  627 + skippedCount++;
  628 + continue; // 跳过,不更新
  629 + }
  630 +
  631 + // 更新现有记录(保留确认状态相关字段)
  632 + salary.Id = existing.Id;
  633 + salary.EmployeeConfirmStatus = existing.EmployeeConfirmStatus;
  634 + salary.EmployeeConfirmTime = existing.EmployeeConfirmTime;
  635 + salary.EmployeeConfirmRemark = existing.EmployeeConfirmRemark;
  636 + salary.IsLocked = existing.IsLocked; // 保留锁定状态
  637 + salary.CreateTime = existing.CreateTime;
  638 + salary.CreateUser = existing.CreateUser;
  639 + recordsToUpdate.Add(salary);
  640 + }
  641 + else
  642 + {
  643 + // 新记录
  644 + salary.Id = YitIdHelper.NextId().ToString();
  645 + salary.EmployeeConfirmStatus = 0;
  646 + salary.IsLocked = 0;
  647 + salary.CreateTime = DateTime.Now;
  648 + salary.CreateUser = ""; // 新记录,创建人为空或系统
  649 + recordsToInsert.Add(salary);
  650 + }
  651 + }
  652 +
  653 + // 批量插入新记录
  654 + if (recordsToInsert.Any())
  655 + {
  656 + await _db.Insertable(recordsToInsert).ExecuteCommandAsync();
  657 + }
  658 +
  659 + // 批量更新现有记录
  660 + if (recordsToUpdate.Any())
  661 + {
  662 + await _db.Updateable(recordsToUpdate).ExecuteCommandAsync();
  663 + }
  664 +
  665 + if (skippedCount > 0)
  666 + {
  667 + _logger.LogWarning($"计算工资时跳过了 {skippedCount} 条已锁定或已确认的记录(月份:{monthStr})");
  668 + }
549 669 }
550 670 }
551 671  
... ... @@ -666,6 +786,487 @@ namespace NCC.Extend
666 786 };
667 787 }
668 788  
  789 + #region 员工工资确认
  790 +
  791 + /// <summary>
  792 + /// 员工确认工资条
  793 + /// </summary>
  794 + /// <remarks>
  795 + /// 员工确认自己的工资条,确认后工资数据不可再修改
  796 + ///
  797 + /// 示例请求:
  798 + /// <code>
  799 + /// {
  800 + /// "id": "工资记录ID",
  801 + /// "employeeId": "员工ID",
  802 + /// "remark": "确认备注(可选)"
  803 + /// }
  804 + /// </code>
  805 + ///
  806 + /// 参数说明:
  807 + /// - id: 工资记录ID(必填)
  808 + /// - employeeId: 员工ID(必填)
  809 + /// - remark: 确认备注(可选)
  810 + ///
  811 + /// 注意事项:
  812 + /// - 只能确认自己的工资条
  813 + /// - 只能确认已锁定的工资条(IsLocked = 1)
  814 + /// - 已确认的工资条不能重复确认
  815 + /// </remarks>
  816 + /// <param name="input">确认参数</param>
  817 + /// <returns>操作结果</returns>
  818 + /// <response code="200">确认成功</response>
  819 + /// <response code="400">参数错误或记录不存在</response>
  820 + [HttpPost("confirm")]
  821 + public async Task<string> ConfirmSalary([FromBody] SalaryConfirmInput input)
  822 + {
  823 + try
  824 + {
  825 + if (string.IsNullOrWhiteSpace(input.Id))
  826 + {
  827 + throw NCCException.Oh("工资记录ID不能为空");
  828 + }
  829 +
  830 + if (string.IsNullOrWhiteSpace(input.EmployeeId))
  831 + {
  832 + throw NCCException.Oh("员工ID不能为空");
  833 + }
  834 +
  835 + var salary = await _db.Queryable<LqAssistantSalaryStatisticsEntity>()
  836 + .Where(s => s.Id == input.Id && s.EmployeeId == input.EmployeeId)
  837 + .FirstAsync();
  838 +
  839 + if (salary == null)
  840 + {
  841 + throw NCCException.Oh("工资记录不存在或不属于该员工");
  842 + }
  843 +
  844 + if (salary.EmployeeConfirmStatus == 1)
  845 + {
  846 + throw NCCException.Oh("该工资条已确认,不能重复确认");
  847 + }
  848 +
  849 + if (salary.IsLocked != 1)
  850 + {
  851 + throw NCCException.Oh("该工资条尚未锁定,请等待管理员锁定后再确认");
  852 + }
  853 +
  854 + salary.EmployeeConfirmStatus = 1;
  855 + salary.EmployeeConfirmTime = DateTime.Now;
  856 + salary.EmployeeConfirmRemark = input.Remark;
  857 + salary.UpdateTime = DateTime.Now;
  858 +
  859 + await _db.Updateable(salary).ExecuteCommandAsync();
  860 +
  861 + return "确认成功";
  862 + }
  863 + catch (Exception ex)
  864 + {
  865 + throw NCCException.Oh($"确认工资条失败: {ex.Message}");
  866 + }
  867 + }
  868 +
  869 + #endregion
  870 +
  871 + #region 工资锁定/解锁
  872 +
  873 + /// <summary>
  874 + /// 批量锁定/解锁工资条
  875 + /// </summary>
  876 + [HttpPost("lock")]
  877 + public async Task<string> LockSalary([FromBody] SalaryLockInput input)
  878 + {
  879 + try
  880 + {
  881 + if (input == null || input.Ids == null || !input.Ids.Any())
  882 + throw NCCException.Oh("工资记录ID列表不能为空");
  883 + var salaries = await _db.Queryable<LqAssistantSalaryStatisticsEntity>()
  884 + .Where(s => input.Ids.Contains(s.Id))
  885 + .ToListAsync();
  886 + if (!salaries.Any())
  887 + throw NCCException.Oh("未找到指定的工资记录");
  888 + var lockedCount = 0;
  889 + var unlockedCount = 0;
  890 + var skippedCount = 0;
  891 + foreach (var salary in salaries)
  892 + {
  893 + if (salary.EmployeeConfirmStatus == 1 && !input.IsLocked)
  894 + {
  895 + skippedCount++;
  896 + continue;
  897 + }
  898 + salary.IsLocked = input.IsLocked ? 1 : 0;
  899 + salary.UpdateTime = DateTime.Now;
  900 + if (input.IsLocked) lockedCount++; else unlockedCount++;
  901 + }
  902 + await _db.Updateable(salaries).ExecuteCommandAsync();
  903 + var action = input.IsLocked ? "锁定" : "解锁";
  904 + var count = input.IsLocked ? lockedCount : unlockedCount;
  905 + var message = $"{action}成功:{count}条";
  906 + if (skippedCount > 0)
  907 + message += $",跳过{skippedCount}条(已确认的记录不能解锁)";
  908 + return message;
  909 + }
  910 + catch (Exception ex)
  911 + {
  912 + throw NCCException.Oh($"锁定/解锁工资条失败: {ex.Message}");
  913 + }
  914 + }
  915 +
  916 + /// <summary>
  917 + /// 批量锁定当月所有工资
  918 + /// </summary>
  919 + /// <param name="input">批量锁定输入参数</param>
  920 + /// <returns>锁定结果</returns>
  921 + [HttpPost("lock-by-month")]
  922 + public async Task<dynamic> LockSalaryByMonth([FromBody] SalaryLockByMonthInput input)
  923 + {
  924 + try
  925 + {
  926 + if (input == null)
  927 + throw NCCException.Oh("参数不能为空");
  928 +
  929 + if (input.Year <= 0 || input.Month <= 0 || input.Month > 12)
  930 + throw NCCException.Oh("年份和月份参数不正确");
  931 +
  932 + var monthStr = $"{input.Year}{input.Month:D2}";
  933 +
  934 + var salaries = await _db.Queryable<LqAssistantSalaryStatisticsEntity>()
  935 + .Where(s => s.StatisticsMonth == monthStr)
  936 + .ToListAsync();
  937 +
  938 + if (!salaries.Any())
  939 + throw NCCException.Oh($"未找到{input.Year}年{input.Month}月的工资记录");
  940 +
  941 + var lockedCount = 0;
  942 + var unlockedCount = 0;
  943 + var skippedCount = 0;
  944 + var alreadyLockedCount = 0;
  945 +
  946 + foreach (var salary in salaries)
  947 + {
  948 + if (salary.EmployeeConfirmStatus == 1 && !input.IsLocked)
  949 + {
  950 + skippedCount++;
  951 + continue;
  952 + }
  953 +
  954 + if (salary.IsLocked == 1 && input.IsLocked)
  955 + {
  956 + alreadyLockedCount++;
  957 + continue;
  958 + }
  959 +
  960 + if (salary.IsLocked == 0 && !input.IsLocked)
  961 + {
  962 + alreadyLockedCount++;
  963 + continue;
  964 + }
  965 +
  966 + salary.IsLocked = input.IsLocked ? 1 : 0;
  967 + salary.UpdateTime = DateTime.Now;
  968 +
  969 + if (input.IsLocked)
  970 + lockedCount++;
  971 + else
  972 + unlockedCount++;
  973 + }
  974 +
  975 + if (lockedCount > 0 || unlockedCount > 0)
  976 + {
  977 + var salariesToUpdate = salaries.Where(s =>
  978 + (input.IsLocked && s.IsLocked == 0) ||
  979 + (!input.IsLocked && s.IsLocked == 1 && s.EmployeeConfirmStatus != 1)
  980 + ).ToList();
  981 +
  982 + if (salariesToUpdate.Any())
  983 + {
  984 + await _db.Updateable(salariesToUpdate)
  985 + .UpdateColumns(s => new { s.IsLocked, s.UpdateTime })
  986 + .ExecuteCommandAsync();
  987 + }
  988 + }
  989 +
  990 + var action = input.IsLocked ? "锁定" : "解锁";
  991 + var count = input.IsLocked ? lockedCount : unlockedCount;
  992 + var message = $"{action}成功:{count}条";
  993 +
  994 + if (alreadyLockedCount > 0)
  995 + message += $",跳过{alreadyLockedCount}条(已是{action}状态)";
  996 +
  997 + if (skippedCount > 0)
  998 + message += $",跳过{skippedCount}条(已确认的记录不能解锁)";
  999 +
  1000 + return new
  1001 + {
  1002 + success = true,
  1003 + message = message,
  1004 + total = salaries.Count,
  1005 + locked = lockedCount,
  1006 + unlocked = unlockedCount,
  1007 + skipped = skippedCount,
  1008 + alreadyLocked = alreadyLockedCount
  1009 + };
  1010 + }
  1011 + catch (Exception ex)
  1012 + {
  1013 + _logger.LogError(ex, "批量锁定当月工资失败");
  1014 + var action = input?.IsLocked == true ? "锁定" : "解锁";
  1015 + throw NCCException.Oh($"批量{action}当月工资失败: {ex.Message}");
  1016 + }
  1017 + }
  1018 +
  1019 + #endregion
  1020 +
  1021 + #region 导入工资
  1022 +
  1023 + /// <summary>
  1024 + /// 从Excel导入店助工资数据
  1025 + /// </summary>
  1026 + /// <param name="file">Excel文件</param>
  1027 + /// <returns>导入结果</returns>
  1028 + [HttpPost("import")]
  1029 + public async Task<dynamic> ImportSalaryFromExcel(IFormFile file)
  1030 + {
  1031 + try
  1032 + {
  1033 + if (file == null || file.Length == 0)
  1034 + throw NCCException.Oh("请选择要上传的Excel文件");
  1035 +
  1036 + var allowedExtensions = new[] { ".xlsx", ".xls" };
  1037 + var fileExtension = Path.GetExtension(file.FileName).ToLowerInvariant();
  1038 + if (!allowedExtensions.Contains(fileExtension))
  1039 + throw NCCException.Oh("只支持.xlsx和.xls格式的Excel文件");
  1040 +
  1041 + var recordsToInsert = new List<LqAssistantSalaryStatisticsEntity>();
  1042 + var recordsToUpdate = new List<LqAssistantSalaryStatisticsEntity>();
  1043 + var errorMessages = new List<string>();
  1044 + var successCount = 0;
  1045 + var failCount = 0;
  1046 + var skippedCount = 0;
  1047 +
  1048 + var tempFilePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString() + Path.GetExtension(file.FileName));
  1049 + try
  1050 + {
  1051 + using (var stream = new FileStream(tempFilePath, FileMode.Create))
  1052 + {
  1053 + await file.CopyToAsync(stream);
  1054 + }
  1055 +
  1056 + var dataTable = ExcelImportHelper.ToDataTable(tempFilePath, 0, 0);
  1057 + if (dataTable.Rows.Count == 0)
  1058 + throw NCCException.Oh("Excel文件中没有数据行");
  1059 +
  1060 + Func<string, decimal> ParseDecimal = (str) =>
  1061 + {
  1062 + if (string.IsNullOrWhiteSpace(str)) return 0;
  1063 + var cleaned = str.Trim().Replace(",", "").Replace(",", "").Replace("¥", "").Replace("$", "").Replace("元", "").Replace("%", "").Replace(" ", "");
  1064 + return decimal.TryParse(cleaned, out decimal result) ? result : 0;
  1065 + };
  1066 +
  1067 + Func<string, int> ParseInt = (str) =>
  1068 + {
  1069 + if (string.IsNullOrWhiteSpace(str)) return 0;
  1070 + var cleaned = str.Trim().Replace(",", "").Replace(",", "").Replace(" ", "");
  1071 + return int.TryParse(cleaned, out int result) ? result : 0;
  1072 + };
  1073 +
  1074 + for (int i = 1; i < dataTable.Rows.Count; i++)
  1075 + {
  1076 + try
  1077 + {
  1078 + var row = dataTable.Rows[i];
  1079 + Func<int, string> GetColumnValue = (colIndex) => colIndex < row.ItemArray.Length && row[colIndex] != null ? row[colIndex].ToString().Trim() : "";
  1080 +
  1081 + var firstColumnValue = GetColumnValue(0);
  1082 + bool isOldFormat = !string.IsNullOrWhiteSpace(firstColumnValue) && (firstColumnValue == "门店名称" || (!long.TryParse(firstColumnValue, out _) && firstColumnValue.Length > 20));
  1083 +
  1084 + int storeNameIndex = isOldFormat ? 0 : 1;
  1085 + int employeeNameIndex = isOldFormat ? 1 : 2;
  1086 + int offset = isOldFormat ? 0 : 1;
  1087 +
  1088 + var id = isOldFormat ? "" : GetColumnValue(0);
  1089 + var employeeName = GetColumnValue(employeeNameIndex);
  1090 + var storeName = GetColumnValue(storeNameIndex);
  1091 +
  1092 + if (string.IsNullOrWhiteSpace(id) && string.IsNullOrWhiteSpace(employeeName))
  1093 + continue;
  1094 +
  1095 + if (string.IsNullOrWhiteSpace(id) && !string.IsNullOrWhiteSpace(employeeName))
  1096 + {
  1097 + var matchedRecord = await _db.Queryable<LqAssistantSalaryStatisticsEntity>()
  1098 + .Where(x => x.EmployeeName == employeeName)
  1099 + .WhereIF(!string.IsNullOrWhiteSpace(storeName), x => x.StoreName == storeName)
  1100 + .OrderBy(x => x.CreateTime, OrderByType.Desc)
  1101 + .FirstAsync();
  1102 + if (matchedRecord != null) id = matchedRecord.Id;
  1103 + }
  1104 +
  1105 + if (string.IsNullOrWhiteSpace(employeeName))
  1106 + {
  1107 + errorMessages.Add($"第{i + 1}行:员工姓名不能为空");
  1108 + failCount++;
  1109 + continue;
  1110 + }
  1111 +
  1112 + LqAssistantSalaryStatisticsEntity existing = null;
  1113 + if (!string.IsNullOrWhiteSpace(id))
  1114 + {
  1115 + existing = await _db.Queryable<LqAssistantSalaryStatisticsEntity>()
  1116 + .Where(x => x.Id == id).FirstAsync();
  1117 +
  1118 + if (existing != null && (existing.IsLocked == 1 || existing.EmployeeConfirmStatus == 1))
  1119 + {
  1120 + skippedCount++;
  1121 + failCount++;
  1122 + continue;
  1123 + }
  1124 + }
  1125 +
  1126 + var entity = existing ?? new LqAssistantSalaryStatisticsEntity
  1127 + {
  1128 + Id = string.IsNullOrWhiteSpace(id) ? YitIdHelper.NextId().ToString() : id,
  1129 + EmployeeConfirmStatus = 0,
  1130 + IsLocked = 0,
  1131 + CreateTime = DateTime.Now,
  1132 + CreateUser = ""
  1133 + };
  1134 +
  1135 + entity.StoreName = storeName;
  1136 + entity.EmployeeName = employeeName;
  1137 + entity.Position = GetColumnValue(2 + offset);
  1138 + entity.StoreTotalPerformance = ParseDecimal(GetColumnValue(3 + offset));
  1139 + entity.StoreBillingPerformance = ParseDecimal(GetColumnValue(4 + offset));
  1140 + entity.StoreRefundPerformance = ParseDecimal(GetColumnValue(5 + offset));
  1141 + entity.StoreLifeline = ParseDecimal(GetColumnValue(6 + offset));
  1142 + entity.PerformanceCompletionRate = ParseDecimal(GetColumnValue(7 + offset));
  1143 + entity.CommissionRate = ParseDecimal(GetColumnValue(8 + offset));
  1144 + entity.CommissionAmount = ParseDecimal(GetColumnValue(9 + offset));
  1145 + entity.HeadCount = ParseInt(GetColumnValue(10 + offset));
  1146 + entity.Stage1TargetHeadCount = ParseInt(GetColumnValue(11 + offset));
  1147 + entity.Stage2TargetHeadCount = ParseInt(GetColumnValue(12 + offset));
  1148 + entity.ReachedStage1 = GetColumnValue(13 + offset);
  1149 + entity.ReachedStage2 = GetColumnValue(14 + offset);
  1150 + entity.StageRewardAmount = ParseDecimal(GetColumnValue(15 + offset));
  1151 + entity.Stage1Reward = ParseDecimal(GetColumnValue(16 + offset));
  1152 + entity.Stage2Reward = ParseDecimal(GetColumnValue(17 + offset));
  1153 + entity.BaseSalary = ParseDecimal(GetColumnValue(18 + offset));
  1154 + entity.PhoneManagementFee = ParseDecimal(GetColumnValue(19 + offset));
  1155 + entity.WorkingDays = ParseInt(GetColumnValue(20 + offset));
  1156 + entity.LeaveDays = ParseInt(GetColumnValue(21 + offset));
  1157 + entity.GrossSalary = ParseDecimal(GetColumnValue(22 + offset));
  1158 + entity.ActualSalary = ParseDecimal(GetColumnValue(23 + offset));
  1159 + entity.TotalDeduction = ParseDecimal(GetColumnValue(24 + offset));
  1160 + entity.TotalSubsidy = ParseDecimal(GetColumnValue(25 + offset));
  1161 + entity.Bonus = ParseDecimal(GetColumnValue(26 + offset));
  1162 + entity.ReturnPhoneDeposit = ParseDecimal(GetColumnValue(27 + offset));
  1163 + entity.ReturnAccommodationDeposit = ParseDecimal(GetColumnValue(28 + offset));
  1164 + entity.LastMonthSupplement = ParseDecimal(GetColumnValue(29 + offset));
  1165 + entity.MonthlyPaymentStatus = GetColumnValue(30 + offset);
  1166 + entity.PaidAmount = ParseDecimal(GetColumnValue(31 + offset));
  1167 + entity.PendingAmount = ParseDecimal(GetColumnValue(32 + offset));
  1168 + entity.MonthlyTotalPayment = ParseDecimal(GetColumnValue(33 + offset));
  1169 + entity.IsNewStore = GetColumnValue(34 + offset) == "是" ? "是" : "否";
  1170 + entity.NewStoreProtectionStage = ParseInt(GetColumnValue(35 + offset));
  1171 +
  1172 + if (existing != null)
  1173 + {
  1174 + entity.StoreId = existing.StoreId;
  1175 + entity.EmployeeId = existing.EmployeeId;
  1176 + entity.StatisticsMonth = existing.StatisticsMonth;
  1177 + }
  1178 + else
  1179 + {
  1180 + // 对于新记录,尝试通过员工姓名和门店名称匹配已有记录以获取统计月份
  1181 + LqAssistantSalaryStatisticsEntity matchedRecord = null;
  1182 + if (!string.IsNullOrWhiteSpace(employeeName))
  1183 + {
  1184 + var user = await _db.Queryable<UserEntity>()
  1185 + .Where(u => u.RealName == employeeName).FirstAsync();
  1186 + if (user != null)
  1187 + {
  1188 + entity.EmployeeId = user.Id;
  1189 + if (!string.IsNullOrEmpty(user.Mdid) && string.IsNullOrWhiteSpace(storeName))
  1190 + entity.StoreId = user.Mdid;
  1191 +
  1192 + // 尝试通过员工ID匹配已有记录获取统计月份
  1193 + matchedRecord = await _db.Queryable<LqAssistantSalaryStatisticsEntity>()
  1194 + .Where(x => x.EmployeeId == user.Id)
  1195 + .WhereIF(!string.IsNullOrWhiteSpace(storeName), x => x.StoreName == storeName)
  1196 + .OrderBy(x => x.CreateTime, OrderByType.Desc)
  1197 + .FirstAsync();
  1198 + }
  1199 +
  1200 + if (!string.IsNullOrWhiteSpace(storeName) && string.IsNullOrEmpty(entity.StoreId))
  1201 + {
  1202 + var store = await _db.Queryable<LqMdxxEntity>()
  1203 + .Where(s => s.Dm == storeName).FirstAsync();
  1204 + if (store != null)
  1205 + {
  1206 + entity.StoreId = store.Id;
  1207 + // 如果没有匹配到记录,再通过门店ID尝试
  1208 + if (matchedRecord == null)
  1209 + {
  1210 + matchedRecord = await _db.Queryable<LqAssistantSalaryStatisticsEntity>()
  1211 + .Where(x => x.StoreId == store.Id)
  1212 + .WhereIF(!string.IsNullOrWhiteSpace(employeeName), x => x.EmployeeName == employeeName)
  1213 + .OrderBy(x => x.CreateTime, OrderByType.Desc)
  1214 + .FirstAsync();
  1215 + }
  1216 + }
  1217 + }
  1218 + }
  1219 +
  1220 + // 如果有匹配的记录,使用其统计月份;否则使用当前年月作为默认值
  1221 + if (matchedRecord != null && !string.IsNullOrWhiteSpace(matchedRecord.StatisticsMonth))
  1222 + {
  1223 + entity.StatisticsMonth = matchedRecord.StatisticsMonth;
  1224 + }
  1225 + else
  1226 + {
  1227 + // 如果没有匹配记录,使用当前年月(YYYYMM格式)
  1228 + var now = DateTime.Now;
  1229 + entity.StatisticsMonth = $"{now.Year}{now.Month:D2}";
  1230 + }
  1231 + }
  1232 +
  1233 + entity.UpdateTime = DateTime.Now;
  1234 + if (existing != null) recordsToUpdate.Add(entity);
  1235 + else recordsToInsert.Add(entity);
  1236 + successCount++;
  1237 + }
  1238 + catch (Exception ex)
  1239 + {
  1240 + errorMessages.Add($"第{i + 1}行数据处理失败: {ex.Message}");
  1241 + failCount++;
  1242 + }
  1243 + }
  1244 + }
  1245 + finally
  1246 + {
  1247 + if (File.Exists(tempFilePath)) File.Delete(tempFilePath);
  1248 + }
  1249 +
  1250 + if (recordsToInsert.Any()) await _db.Insertable(recordsToInsert).ExecuteCommandAsync();
  1251 + if (recordsToUpdate.Any()) await _db.Updateable(recordsToUpdate).ExecuteCommandAsync();
  1252 +
  1253 + return new
  1254 + {
  1255 + success = true,
  1256 + message = $"导入完成:成功 {successCount} 条,失败 {failCount} 条,跳过 {skippedCount} 条(已锁定或已确认)",
  1257 + successCount,
  1258 + failCount,
  1259 + skippedCount,
  1260 + errors = errorMessages
  1261 + };
  1262 + }
  1263 + catch (Exception ex)
  1264 + {
  1265 + throw NCCException.Oh($"导入店助工资数据失败: {ex.Message}");
  1266 + }
  1267 + }
  1268 +
  1269 + #endregion
669 1270 }
670 1271 }
671 1272  
... ...
netcore/src/Modularity/Extend/NCC.Extend/LqBusinessUnitManagerSalaryService.cs
... ... @@ -62,17 +62,7 @@ namespace NCC.Extend
62 62 {
63 63 var monthStr = $"{input.Year}{input.Month:D2}";
64 64  
65   - // 1. 检查当月是否已生成工资数据
66   - var exists = await _db.Queryable<LqBusinessUnitManagerSalaryStatisticsEntity>()
67   - .AnyAsync(x => x.StatisticsMonth == monthStr);
68   -
69   - // 2. 如果没有数据,则进行计算
70   - if (!exists)
71   - {
72   - await CalculateBusinessUnitManagerSalary(input.Year, input.Month);
73   - }
74   -
75   - // 3. 查询数据
  65 + // 查询数据
76 66 var query = _db.Queryable<LqBusinessUnitManagerSalaryStatisticsEntity>()
77 67 .Where(x => x.StatisticsMonth == monthStr);
78 68  
... ... @@ -141,6 +131,74 @@ namespace NCC.Extend
141 131 }
142 132  
143 133 /// <summary>
  134 + /// 通过月份和员工ID查询工资
  135 + /// </summary>
  136 + [HttpGet("query-by-employee")]
  137 + public async Task<BusinessUnitManagerSalaryOutput> GetSalaryByEmployee([FromQuery] SalaryQueryByEmployeeInput input)
  138 + {
  139 + if (input.Year <= 0 || input.Month <= 0 || input.Month > 12)
  140 + throw NCCException.Oh("年份和月份参数不正确");
  141 + if (string.IsNullOrWhiteSpace(input.EmployeeId))
  142 + throw NCCException.Oh("员工ID不能为空");
  143 + var monthStr = $"{input.Year}{input.Month:D2}";
  144 + var salary = await _db.Queryable<LqBusinessUnitManagerSalaryStatisticsEntity>()
  145 + .Where(x => x.StatisticsMonth == monthStr && x.EmployeeId == input.EmployeeId)
  146 + .Select(x => new BusinessUnitManagerSalaryOutput
  147 + {
  148 + Id = x.Id,
  149 + StatisticsMonth = x.StatisticsMonth,
  150 + Position = x.Position,
  151 + EmployeeName = x.EmployeeName,
  152 + EmployeeId = x.EmployeeId,
  153 + EmployeeAccount = x.EmployeeAccount,
  154 + ManagerType = x.ManagerType,
  155 + IsTerminated = x.IsTerminated,
  156 + StorePerformanceDetail = x.StorePerformanceDetail,
  157 + SalesPerformance = x.SalesPerformance,
  158 + ProductMaterial = x.ProductMaterial,
  159 + CooperationCost = x.CooperationCost,
  160 + StoreExpense = x.StoreExpense,
  161 + LaundryCost = x.LaundryCost,
  162 + GrossProfit = x.GrossProfit,
  163 + BaseSalary = x.BaseSalary,
  164 + TotalCommission = x.TotalCommission,
  165 + WorkingDays = x.WorkingDays,
  166 + LeaveDays = x.LeaveDays,
  167 + CalculatedGrossSalary = x.CalculatedGrossSalary,
  168 + FinalGrossSalary = x.FinalGrossSalary,
  169 + MonthlyTrainingSubsidy = x.MonthlyTrainingSubsidy,
  170 + MonthlyTransportSubsidy = x.MonthlyTransportSubsidy,
  171 + LastMonthTrainingSubsidy = x.LastMonthTrainingSubsidy,
  172 + LastMonthTransportSubsidy = x.LastMonthTransportSubsidy,
  173 + TotalSubsidy = x.TotalSubsidy,
  174 + MissingCard = x.MissingCard,
  175 + LateArrival = x.LateArrival,
  176 + LeaveDeduction = x.LeaveDeduction,
  177 + SocialInsuranceDeduction = x.SocialInsuranceDeduction,
  178 + RewardDeduction = x.RewardDeduction,
  179 + AccommodationDeduction = x.AccommodationDeduction,
  180 + StudyPeriodDeduction = x.StudyPeriodDeduction,
  181 + WorkClothesDeduction = x.WorkClothesDeduction,
  182 + TotalDeduction = x.TotalDeduction,
  183 + Bonus = x.Bonus,
  184 + ReturnPhoneDeposit = x.ReturnPhoneDeposit,
  185 + ReturnAccommodationDeposit = x.ReturnAccommodationDeposit,
  186 + ActualSalary = x.ActualSalary,
  187 + MonthlyPaymentStatus = x.MonthlyPaymentStatus,
  188 + PaidAmount = x.PaidAmount,
  189 + PendingAmount = x.PendingAmount,
  190 + LastMonthSupplement = x.LastMonthSupplement,
  191 + MonthlyTotalPayment = x.MonthlyTotalPayment,
  192 + IsLocked = x.IsLocked,
  193 + UpdateTime = x.UpdateTime
  194 + })
  195 + .FirstAsync();
  196 + if (salary == null)
  197 + throw NCCException.Oh($"未找到员工{input.EmployeeId}在{input.Year}年{input.Month}月的工资记录");
  198 + return salary;
  199 + }
  200 +
  201 + /// <summary>
144 202 /// 计算事业部总经理/经理工资
145 203 /// </summary>
146 204 /// <param name="year">年份</param>
... ... @@ -635,6 +693,35 @@ namespace NCC.Extend
635 693  
636 694 #region 员工工资确认
637 695  
  696 + /// <summary>
  697 + /// 员工确认工资条
  698 + /// </summary>
  699 + /// <remarks>
  700 + /// 员工确认自己的工资条,确认后工资数据不可再修改
  701 + ///
  702 + /// 示例请求:
  703 + /// <code>
  704 + /// {
  705 + /// "id": "工资记录ID",
  706 + /// "employeeId": "员工ID",
  707 + /// "remark": "确认备注(可选)"
  708 + /// }
  709 + /// </code>
  710 + ///
  711 + /// 参数说明:
  712 + /// - id: 工资记录ID(必填)
  713 + /// - employeeId: 员工ID(必填)
  714 + /// - remark: 确认备注(可选)
  715 + ///
  716 + /// 注意事项:
  717 + /// - 只能确认自己的工资条
  718 + /// - 只能确认已锁定的工资条(IsLocked = 1)
  719 + /// - 已确认的工资条不能重复确认
  720 + /// </remarks>
  721 + /// <param name="input">确认参数</param>
  722 + /// <returns>操作结果</returns>
  723 + /// <response code="200">确认成功</response>
  724 + /// <response code="400">参数错误或记录不存在</response>
638 725 [HttpPost("confirm")]
639 726 public async Task<string> ConfirmSalary([FromBody] SalaryConfirmInput input)
640 727 {
... ... @@ -662,6 +749,156 @@ namespace NCC.Extend
662 749  
663 750 #endregion
664 751  
  752 + #region 工资锁定/解锁
  753 +
  754 + /// <summary>
  755 + /// 批量锁定/解锁工资条
  756 + /// </summary>
  757 + [HttpPost("lock")]
  758 + public async Task<string> LockSalary([FromBody] SalaryLockInput input)
  759 + {
  760 + try
  761 + {
  762 + if (input == null || input.Ids == null || !input.Ids.Any())
  763 + throw NCCException.Oh("工资记录ID列表不能为空");
  764 + var salaries = await _db.Queryable<LqBusinessUnitManagerSalaryStatisticsEntity>()
  765 + .Where(s => input.Ids.Contains(s.Id))
  766 + .ToListAsync();
  767 + if (!salaries.Any())
  768 + throw NCCException.Oh("未找到指定的工资记录");
  769 + var lockedCount = 0;
  770 + var unlockedCount = 0;
  771 + var skippedCount = 0;
  772 + foreach (var salary in salaries)
  773 + {
  774 + if (salary.EmployeeConfirmStatus == 1 && !input.IsLocked)
  775 + {
  776 + skippedCount++;
  777 + continue;
  778 + }
  779 + salary.IsLocked = input.IsLocked ? 1 : 0;
  780 + salary.UpdateTime = DateTime.Now;
  781 + if (input.IsLocked) lockedCount++; else unlockedCount++;
  782 + }
  783 + await _db.Updateable(salaries).ExecuteCommandAsync();
  784 + var action = input.IsLocked ? "锁定" : "解锁";
  785 + var count = input.IsLocked ? lockedCount : unlockedCount;
  786 + var message = $"{action}成功:{count}条";
  787 + if (skippedCount > 0)
  788 + message += $",跳过{skippedCount}条(已确认的记录不能解锁)";
  789 + return message;
  790 + }
  791 + catch (Exception ex)
  792 + {
  793 + throw NCCException.Oh($"锁定/解锁工资条失败: {ex.Message}");
  794 + }
  795 + }
  796 +
  797 + /// <summary>
  798 + /// 批量锁定当月所有工资
  799 + /// </summary>
  800 + /// <param name="input">批量锁定输入参数</param>
  801 + /// <returns>锁定结果</returns>
  802 + [HttpPost("lock-by-month")]
  803 + public async Task<dynamic> LockSalaryByMonth([FromBody] SalaryLockByMonthInput input)
  804 + {
  805 + try
  806 + {
  807 + if (input == null)
  808 + throw NCCException.Oh("参数不能为空");
  809 +
  810 + if (input.Year <= 0 || input.Month <= 0 || input.Month > 12)
  811 + throw NCCException.Oh("年份和月份参数不正确");
  812 +
  813 + var monthStr = $"{input.Year}{input.Month:D2}";
  814 +
  815 + var salaries = await _db.Queryable<LqBusinessUnitManagerSalaryStatisticsEntity>()
  816 + .Where(s => s.StatisticsMonth == monthStr)
  817 + .ToListAsync();
  818 +
  819 + if (!salaries.Any())
  820 + throw NCCException.Oh($"未找到{input.Year}年{input.Month}月的工资记录");
  821 +
  822 + var lockedCount = 0;
  823 + var unlockedCount = 0;
  824 + var skippedCount = 0;
  825 + var alreadyLockedCount = 0;
  826 +
  827 + foreach (var salary in salaries)
  828 + {
  829 + if (salary.EmployeeConfirmStatus == 1 && !input.IsLocked)
  830 + {
  831 + skippedCount++;
  832 + continue;
  833 + }
  834 +
  835 + if (salary.IsLocked == 1 && input.IsLocked)
  836 + {
  837 + alreadyLockedCount++;
  838 + continue;
  839 + }
  840 +
  841 + if (salary.IsLocked == 0 && !input.IsLocked)
  842 + {
  843 + alreadyLockedCount++;
  844 + continue;
  845 + }
  846 +
  847 + salary.IsLocked = input.IsLocked ? 1 : 0;
  848 + salary.UpdateTime = DateTime.Now;
  849 +
  850 + if (input.IsLocked)
  851 + lockedCount++;
  852 + else
  853 + unlockedCount++;
  854 + }
  855 +
  856 + if (lockedCount > 0 || unlockedCount > 0)
  857 + {
  858 + var salariesToUpdate = salaries.Where(s =>
  859 + (input.IsLocked && s.IsLocked == 0) ||
  860 + (!input.IsLocked && s.IsLocked == 1 && s.EmployeeConfirmStatus != 1)
  861 + ).ToList();
  862 +
  863 + if (salariesToUpdate.Any())
  864 + {
  865 + await _db.Updateable(salariesToUpdate)
  866 + .UpdateColumns(s => new { s.IsLocked, s.UpdateTime })
  867 + .ExecuteCommandAsync();
  868 + }
  869 + }
  870 +
  871 + var action = input.IsLocked ? "锁定" : "解锁";
  872 + var count = input.IsLocked ? lockedCount : unlockedCount;
  873 + var message = $"{action}成功:{count}条";
  874 +
  875 + if (alreadyLockedCount > 0)
  876 + message += $",跳过{alreadyLockedCount}条(已是{action}状态)";
  877 +
  878 + if (skippedCount > 0)
  879 + message += $",跳过{skippedCount}条(已确认的记录不能解锁)";
  880 +
  881 + return new
  882 + {
  883 + success = true,
  884 + message = message,
  885 + total = salaries.Count,
  886 + locked = lockedCount,
  887 + unlocked = unlockedCount,
  888 + skipped = skippedCount,
  889 + alreadyLocked = alreadyLockedCount
  890 + };
  891 + }
  892 + catch (Exception ex)
  893 + {
  894 + _logger.LogError(ex, "批量锁定当月工资失败");
  895 + var action = input?.IsLocked == true ? "锁定" : "解锁";
  896 + throw NCCException.Oh($"批量{action}当月工资失败: {ex.Message}");
  897 + }
  898 + }
  899 +
  900 + #endregion
  901 +
665 902 #region 导入工资
666 903  
667 904 /// <summary>
... ...
netcore/src/Modularity/Extend/NCC.Extend/LqDirectorSalaryService.cs
1 1 using Microsoft.AspNetCore.Authorization;
  2 +using Microsoft.AspNetCore.Http;
2 3 using Microsoft.AspNetCore.Mvc;
  4 +using Microsoft.Extensions.Logging;
3 5 using NCC.Common.Filter;
4 6 using NCC.Common.Helper;
5 7 using NCC.Dependency;
6 8 using NCC.DynamicApiController;
7 9 using NCC.Extend.Entitys.Dto.LqDirectorSalary;
  10 +using NCC.Extend.Entitys.Dto.LqSalary;
8 11 using NCC.Extend.Entitys.Enum;
9 12 using NCC.Extend.Entitys.lq_attendance_summary;
10 13 using NCC.Extend.Entitys.lq_cooperation_cost;
... ... @@ -18,10 +21,13 @@ using NCC.Extend.Entitys.lq_mdxx;
18 21 using NCC.Extend.Entitys.lq_store_expense;
19 22 using NCC.Extend.Entitys.lq_xh_hyhk;
20 23 using NCC.Extend.Entitys.lq_xh_jksyj;
  24 +using NCC.FriendlyException;
21 25 using NCC.System.Entitys.Permission;
22 26 using SqlSugar;
23 27 using System;
24 28 using System.Collections.Generic;
  29 +using System.Data;
  30 +using System.IO;
25 31 using System.Linq;
26 32 using System.Threading.Tasks;
27 33 using Yitter.IdGenerator;
... ... @@ -36,13 +42,15 @@ namespace NCC.Extend
36 42 public class LqDirectorSalaryService : IDynamicApiController, ITransient
37 43 {
38 44 private readonly ISqlSugarClient _db;
  45 + private readonly ILogger<LqDirectorSalaryService> _logger;
39 46  
40 47 /// <summary>
41 48 /// 初始化一个<see cref="LqDirectorSalaryService"/>类型的新实例
42 49 /// </summary>
43   - public LqDirectorSalaryService(ISqlSugarClient db)
  50 + public LqDirectorSalaryService(ISqlSugarClient db, ILogger<LqDirectorSalaryService> logger)
44 51 {
45 52 _db = db;
  53 + _logger = logger;
46 54 }
47 55  
48 56 /// <summary>
... ... @@ -55,17 +63,7 @@ namespace NCC.Extend
55 63 {
56 64 var monthStr = $"{input.Year}{input.Month:D2}";
57 65  
58   - // 1. 检查当月是否已生成工资数据
59   - var exists = await _db.Queryable<LqDirectorSalaryStatisticsEntity>()
60   - .AnyAsync(x => x.StatisticsMonth == monthStr);
61   -
62   - // 2. 如果没有数据,则进行计算
63   - if (!exists)
64   - {
65   - await CalculateDirectorSalary(input.Year, input.Month);
66   - }
67   -
68   - // 3. 查询数据
  66 + // 查询数据
69 67 var query = _db.Queryable<LqDirectorSalaryStatisticsEntity>()
70 68 .Where(x => x.StatisticsMonth == monthStr);
71 69  
... ... @@ -133,6 +131,59 @@ namespace NCC.Extend
133 131 }
134 132  
135 133 /// <summary>
  134 + /// 通过月份和员工ID查询工资
  135 + /// </summary>
  136 + [HttpGet("query-by-employee")]
  137 + public async Task<DirectorSalaryOutput> GetSalaryByEmployee([FromQuery] SalaryQueryByEmployeeInput input)
  138 + {
  139 + if (input.Year <= 0 || input.Month <= 0 || input.Month > 12)
  140 + throw NCCException.Oh("年份和月份参数不正确");
  141 + if (string.IsNullOrWhiteSpace(input.EmployeeId))
  142 + throw NCCException.Oh("员工ID不能为空");
  143 + var monthStr = $"{input.Year}{input.Month:D2}";
  144 + var salary = await _db.Queryable<LqDirectorSalaryStatisticsEntity>()
  145 + .Where(x => x.StatisticsMonth == monthStr && x.EmployeeId == input.EmployeeId)
  146 + .Select(x => new DirectorSalaryOutput
  147 + {
  148 + Id = x.Id,
  149 + StoreName = x.StoreName,
  150 + EmployeeName = x.EmployeeName,
  151 + Position = x.Position,
  152 + StoreTotalPerformance = x.StoreTotalPerformance,
  153 + StoreBillingPerformance = x.StoreBillingPerformance,
  154 + StoreRefundPerformance = x.StoreRefundPerformance,
  155 + StoreLifeline = x.StoreLifeline,
  156 + PerformanceCompletionRate = x.PerformanceCompletionRate,
  157 + CommissionRateBelowLifeline = x.CommissionRateBelowLifeline,
  158 + CommissionRateAboveLifeline = x.CommissionRateAboveLifeline,
  159 + CommissionAmountBelowLifeline = x.CommissionAmountBelowLifeline,
  160 + CommissionAmountAboveLifeline = x.CommissionAmountAboveLifeline,
  161 + TotalCommissionAmount = x.TotalCommissionAmount,
  162 + BaseSalary = x.BaseSalary,
  163 + WorkingDays = x.WorkingDays,
  164 + LeaveDays = x.LeaveDays,
  165 + GrossSalary = x.GrossSalary,
  166 + ActualSalary = x.ActualSalary,
  167 + TotalDeduction = x.TotalDeduction,
  168 + TotalSubsidy = x.TotalSubsidy,
  169 + Bonus = x.Bonus,
  170 + MonthlyPaymentStatus = x.MonthlyPaymentStatus,
  171 + PaidAmount = x.PaidAmount,
  172 + PendingAmount = x.PendingAmount,
  173 + IsLocked = x.IsLocked,
  174 + StoreType = x.StoreType,
  175 + StoreCategory = x.StoreCategory,
  176 + IsNewStore = x.IsNewStore,
  177 + NewStoreProtectionStage = x.NewStoreProtectionStage,
  178 + UpdateTime = x.UpdateTime
  179 + })
  180 + .FirstAsync();
  181 + if (salary == null)
  182 + throw NCCException.Oh($"未找到员工{input.EmployeeId}在{input.Year}年{input.Month}月的工资记录");
  183 + return salary;
  184 + }
  185 +
  186 + /// <summary>
136 187 /// 计算主任工资
137 188 /// </summary>
138 189 /// <param name="year">年份</param>
... ... @@ -552,12 +603,42 @@ namespace NCC.Extend
552 603 // 3. 保存数据
553 604 if (directorSalaryList.Any())
554 605 {
555   - // 先删除当月旧数据 (防止重复)
556   - await _db.Deleteable<LqDirectorSalaryStatisticsEntity>()
  606 + var existingRecords = await _db.Queryable<LqDirectorSalaryStatisticsEntity>()
557 607 .Where(x => x.StatisticsMonth == monthStr)
558   - .ExecuteCommandAsync();
559   -
560   - await _db.Insertable(directorSalaryList).ExecuteCommandAsync();
  608 + .ToListAsync();
  609 + var existingDict = existingRecords.Where(x => !string.IsNullOrEmpty(x.EmployeeId))
  610 + .GroupBy(x => x.EmployeeId).ToDictionary(g => g.Key, g => g.First());
  611 + var recordsToInsert = new List<LqDirectorSalaryStatisticsEntity>();
  612 + var recordsToUpdate = new List<LqDirectorSalaryStatisticsEntity>();
  613 + var skippedCount = 0;
  614 + foreach (var salary in directorSalaryList)
  615 + {
  616 + if (existingDict.ContainsKey(salary.EmployeeId))
  617 + {
  618 + var existing = existingDict[salary.EmployeeId];
  619 + if (existing.IsLocked == 1 || existing.EmployeeConfirmStatus == 1) { skippedCount++; continue; }
  620 + salary.Id = existing.Id;
  621 + salary.EmployeeConfirmStatus = existing.EmployeeConfirmStatus;
  622 + salary.EmployeeConfirmTime = existing.EmployeeConfirmTime;
  623 + salary.EmployeeConfirmRemark = existing.EmployeeConfirmRemark;
  624 + salary.IsLocked = existing.IsLocked;
  625 + salary.CreateTime = existing.CreateTime;
  626 + salary.CreateUser = existing.CreateUser;
  627 + recordsToUpdate.Add(salary);
  628 + }
  629 + else
  630 + {
  631 + salary.Id = YitIdHelper.NextId().ToString();
  632 + salary.EmployeeConfirmStatus = 0;
  633 + salary.IsLocked = 0;
  634 + salary.CreateTime = DateTime.Now;
  635 + salary.CreateUser = "";
  636 + recordsToInsert.Add(salary);
  637 + }
  638 + }
  639 + if (recordsToInsert.Any()) await _db.Insertable(recordsToInsert).ExecuteCommandAsync();
  640 + if (recordsToUpdate.Any()) await _db.Updateable(recordsToUpdate).ExecuteCommandAsync();
  641 + if (skippedCount > 0) _logger.LogWarning($"计算工资时跳过了 {skippedCount} 条已锁定或已确认的记录(月份:{monthStr})");
561 642 }
562 643 }
563 644  
... ... @@ -649,6 +730,448 @@ namespace NCC.Extend
649 730  
650 731 salary.TotalCommissionAmount = salary.CommissionAmountBelowLifeline + salary.CommissionAmountAboveLifeline;
651 732 }
  733 +
  734 + #region 员工工资确认
  735 +
  736 + /// <summary>
  737 + /// 员工确认工资条
  738 + /// </summary>
  739 + /// <remarks>
  740 + /// 员工确认自己的工资条,确认后工资数据不可再修改
  741 + ///
  742 + /// 示例请求:
  743 + /// <code>
  744 + /// {
  745 + /// "id": "工资记录ID",
  746 + /// "employeeId": "员工ID",
  747 + /// "remark": "确认备注(可选)"
  748 + /// }
  749 + /// </code>
  750 + ///
  751 + /// 参数说明:
  752 + /// - id: 工资记录ID(必填)
  753 + /// - employeeId: 员工ID(必填)
  754 + /// - remark: 确认备注(可选)
  755 + ///
  756 + /// 注意事项:
  757 + /// - 只能确认自己的工资条
  758 + /// - 只能确认已锁定的工资条(IsLocked = 1)
  759 + /// - 已确认的工资条不能重复确认
  760 + /// </remarks>
  761 + /// <param name="input">确认参数</param>
  762 + /// <returns>操作结果</returns>
  763 + /// <response code="200">确认成功</response>
  764 + /// <response code="400">参数错误或记录不存在</response>
  765 + [HttpPost("confirm")]
  766 + public async Task<string> ConfirmSalary([FromBody] SalaryConfirmInput input)
  767 + {
  768 + try
  769 + {
  770 + if (string.IsNullOrWhiteSpace(input.Id) || string.IsNullOrWhiteSpace(input.EmployeeId))
  771 + throw NCCException.Oh("工资记录ID和员工ID不能为空");
  772 + var salary = await _db.Queryable<LqDirectorSalaryStatisticsEntity>()
  773 + .Where(s => s.Id == input.Id && s.EmployeeId == input.EmployeeId).FirstAsync();
  774 + if (salary == null) throw NCCException.Oh("工资记录不存在或不属于该员工");
  775 + if (salary.EmployeeConfirmStatus == 1) throw NCCException.Oh("该工资条已确认,不能重复确认");
  776 + if (salary.IsLocked != 1) throw NCCException.Oh("该工资条尚未锁定,请等待管理员锁定后再确认");
  777 + salary.EmployeeConfirmStatus = 1;
  778 + salary.EmployeeConfirmTime = DateTime.Now;
  779 + salary.EmployeeConfirmRemark = input.Remark;
  780 + salary.UpdateTime = DateTime.Now;
  781 + await _db.Updateable(salary).ExecuteCommandAsync();
  782 + return "确认成功";
  783 + }
  784 + catch (Exception ex)
  785 + {
  786 + throw NCCException.Oh($"确认工资条失败: {ex.Message}");
  787 + }
  788 + }
  789 +
  790 + #endregion
  791 +
  792 + #region 工资锁定/解锁
  793 +
  794 + /// <summary>
  795 + /// 批量锁定/解锁工资条
  796 + /// </summary>
  797 + [HttpPost("lock")]
  798 + public async Task<string> LockSalary([FromBody] SalaryLockInput input)
  799 + {
  800 + try
  801 + {
  802 + if (input == null || input.Ids == null || !input.Ids.Any())
  803 + throw NCCException.Oh("工资记录ID列表不能为空");
  804 + var salaries = await _db.Queryable<LqDirectorSalaryStatisticsEntity>()
  805 + .Where(s => input.Ids.Contains(s.Id))
  806 + .ToListAsync();
  807 + if (!salaries.Any())
  808 + throw NCCException.Oh("未找到指定的工资记录");
  809 + var lockedCount = 0;
  810 + var unlockedCount = 0;
  811 + var skippedCount = 0;
  812 + foreach (var salary in salaries)
  813 + {
  814 + if (salary.EmployeeConfirmStatus == 1 && !input.IsLocked)
  815 + {
  816 + skippedCount++;
  817 + continue;
  818 + }
  819 + salary.IsLocked = input.IsLocked ? 1 : 0;
  820 + salary.UpdateTime = DateTime.Now;
  821 + if (input.IsLocked) lockedCount++; else unlockedCount++;
  822 + }
  823 + await _db.Updateable(salaries).ExecuteCommandAsync();
  824 + var action = input.IsLocked ? "锁定" : "解锁";
  825 + var count = input.IsLocked ? lockedCount : unlockedCount;
  826 + var message = $"{action}成功:{count}条";
  827 + if (skippedCount > 0)
  828 + message += $",跳过{skippedCount}条(已确认的记录不能解锁)";
  829 + return message;
  830 + }
  831 + catch (Exception ex)
  832 + {
  833 + throw NCCException.Oh($"锁定/解锁工资条失败: {ex.Message}");
  834 + }
  835 + }
  836 +
  837 + /// <summary>
  838 + /// 批量锁定当月所有工资
  839 + /// </summary>
  840 + /// <param name="input">批量锁定输入参数</param>
  841 + /// <returns>锁定结果</returns>
  842 + [HttpPost("lock-by-month")]
  843 + public async Task<dynamic> LockSalaryByMonth([FromBody] SalaryLockByMonthInput input)
  844 + {
  845 + try
  846 + {
  847 + if (input == null)
  848 + throw NCCException.Oh("参数不能为空");
  849 +
  850 + if (input.Year <= 0 || input.Month <= 0 || input.Month > 12)
  851 + throw NCCException.Oh("年份和月份参数不正确");
  852 +
  853 + var monthStr = $"{input.Year}{input.Month:D2}";
  854 +
  855 + var salaries = await _db.Queryable<LqDirectorSalaryStatisticsEntity>()
  856 + .Where(s => s.StatisticsMonth == monthStr)
  857 + .ToListAsync();
  858 +
  859 + if (!salaries.Any())
  860 + throw NCCException.Oh($"未找到{input.Year}年{input.Month}月的工资记录");
  861 +
  862 + var lockedCount = 0;
  863 + var unlockedCount = 0;
  864 + var skippedCount = 0;
  865 + var alreadyLockedCount = 0;
  866 +
  867 + foreach (var salary in salaries)
  868 + {
  869 + if (salary.EmployeeConfirmStatus == 1 && !input.IsLocked)
  870 + {
  871 + skippedCount++;
  872 + continue;
  873 + }
  874 +
  875 + if (salary.IsLocked == 1 && input.IsLocked)
  876 + {
  877 + alreadyLockedCount++;
  878 + continue;
  879 + }
  880 +
  881 + if (salary.IsLocked == 0 && !input.IsLocked)
  882 + {
  883 + alreadyLockedCount++;
  884 + continue;
  885 + }
  886 +
  887 + salary.IsLocked = input.IsLocked ? 1 : 0;
  888 + salary.UpdateTime = DateTime.Now;
  889 +
  890 + if (input.IsLocked)
  891 + lockedCount++;
  892 + else
  893 + unlockedCount++;
  894 + }
  895 +
  896 + if (lockedCount > 0 || unlockedCount > 0)
  897 + {
  898 + var salariesToUpdate = salaries.Where(s =>
  899 + (input.IsLocked && s.IsLocked == 0) ||
  900 + (!input.IsLocked && s.IsLocked == 1 && s.EmployeeConfirmStatus != 1)
  901 + ).ToList();
  902 +
  903 + if (salariesToUpdate.Any())
  904 + {
  905 + await _db.Updateable(salariesToUpdate)
  906 + .UpdateColumns(s => new { s.IsLocked, s.UpdateTime })
  907 + .ExecuteCommandAsync();
  908 + }
  909 + }
  910 +
  911 + var action = input.IsLocked ? "锁定" : "解锁";
  912 + var count = input.IsLocked ? lockedCount : unlockedCount;
  913 + var message = $"{action}成功:{count}条";
  914 +
  915 + if (alreadyLockedCount > 0)
  916 + message += $",跳过{alreadyLockedCount}条(已是{action}状态)";
  917 +
  918 + if (skippedCount > 0)
  919 + message += $",跳过{skippedCount}条(已确认的记录不能解锁)";
  920 +
  921 + return new
  922 + {
  923 + success = true,
  924 + message = message,
  925 + total = salaries.Count,
  926 + locked = lockedCount,
  927 + unlocked = unlockedCount,
  928 + skipped = skippedCount,
  929 + alreadyLocked = alreadyLockedCount
  930 + };
  931 + }
  932 + catch (Exception ex)
  933 + {
  934 + _logger.LogError(ex, "批量锁定当月工资失败");
  935 + var action = input?.IsLocked == true ? "锁定" : "解锁";
  936 + throw NCCException.Oh($"批量{action}当月工资失败: {ex.Message}");
  937 + }
  938 + }
  939 +
  940 + #endregion
  941 +
  942 + #region 导入工资
  943 +
  944 + /// <summary>
  945 + /// 从Excel导入主任工资数据
  946 + /// </summary>
  947 + /// <param name="file">Excel文件</param>
  948 + /// <returns>导入结果</returns>
  949 + [HttpPost("import")]
  950 + public async Task<dynamic> ImportSalaryFromExcel(IFormFile file)
  951 + {
  952 + try
  953 + {
  954 + if (file == null || file.Length == 0)
  955 + throw NCCException.Oh("请选择要上传的Excel文件");
  956 +
  957 + var allowedExtensions = new[] { ".xlsx", ".xls" };
  958 + var fileExtension = Path.GetExtension(file.FileName).ToLowerInvariant();
  959 + if (!allowedExtensions.Contains(fileExtension))
  960 + throw NCCException.Oh("只支持.xlsx和.xls格式的Excel文件");
  961 +
  962 + var recordsToInsert = new List<LqDirectorSalaryStatisticsEntity>();
  963 + var recordsToUpdate = new List<LqDirectorSalaryStatisticsEntity>();
  964 + var errorMessages = new List<string>();
  965 + var successCount = 0;
  966 + var failCount = 0;
  967 + var skippedCount = 0;
  968 +
  969 + var tempFilePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString() + Path.GetExtension(file.FileName));
  970 + try
  971 + {
  972 + using (var stream = new FileStream(tempFilePath, FileMode.Create))
  973 + {
  974 + await file.CopyToAsync(stream);
  975 + }
  976 +
  977 + var dataTable = ExcelImportHelper.ToDataTable(tempFilePath, 0, 0);
  978 + if (dataTable.Rows.Count == 0)
  979 + throw NCCException.Oh("Excel文件中没有数据行");
  980 +
  981 + Func<string, decimal> ParseDecimal = (str) =>
  982 + {
  983 + if (string.IsNullOrWhiteSpace(str)) return 0;
  984 + var cleaned = str.Trim().Replace(",", "").Replace(",", "").Replace("¥", "").Replace("$", "").Replace("元", "").Replace("%", "").Replace(" ", "");
  985 + return decimal.TryParse(cleaned, out decimal result) ? result : 0;
  986 + };
  987 +
  988 + Func<string, int> ParseInt = (str) =>
  989 + {
  990 + if (string.IsNullOrWhiteSpace(str)) return 0;
  991 + var cleaned = str.Trim().Replace(",", "").Replace(",", "").Replace(" ", "");
  992 + return int.TryParse(cleaned, out int result) ? result : 0;
  993 + };
  994 +
  995 + for (int i = 1; i < dataTable.Rows.Count; i++)
  996 + {
  997 + try
  998 + {
  999 + var row = dataTable.Rows[i];
  1000 + Func<int, string> GetColumnValue = (colIndex) => colIndex < row.ItemArray.Length && row[colIndex] != null ? row[colIndex].ToString().Trim() : "";
  1001 +
  1002 + var firstColumnValue = GetColumnValue(0);
  1003 + bool isOldFormat = !string.IsNullOrWhiteSpace(firstColumnValue) && (firstColumnValue == "门店名称" || (!long.TryParse(firstColumnValue, out _) && firstColumnValue.Length > 20));
  1004 +
  1005 + int storeNameIndex = isOldFormat ? 0 : 1;
  1006 + int employeeNameIndex = isOldFormat ? 1 : 2;
  1007 + int offset = isOldFormat ? 0 : 1;
  1008 +
  1009 + var id = isOldFormat ? "" : GetColumnValue(0);
  1010 + var employeeName = GetColumnValue(employeeNameIndex);
  1011 + var storeName = GetColumnValue(storeNameIndex);
  1012 +
  1013 + if (string.IsNullOrWhiteSpace(id) && string.IsNullOrWhiteSpace(employeeName))
  1014 + continue;
  1015 +
  1016 + if (string.IsNullOrWhiteSpace(id) && !string.IsNullOrWhiteSpace(employeeName))
  1017 + {
  1018 + var matchedRecord = await _db.Queryable<LqDirectorSalaryStatisticsEntity>()
  1019 + .Where(x => x.EmployeeName == employeeName)
  1020 + .WhereIF(!string.IsNullOrWhiteSpace(storeName), x => x.StoreName == storeName)
  1021 + .OrderBy(x => x.CreateTime, OrderByType.Desc)
  1022 + .FirstAsync();
  1023 + if (matchedRecord != null) id = matchedRecord.Id;
  1024 + }
  1025 +
  1026 + if (string.IsNullOrWhiteSpace(employeeName))
  1027 + {
  1028 + errorMessages.Add($"第{i + 1}行:员工姓名不能为空");
  1029 + failCount++;
  1030 + continue;
  1031 + }
  1032 +
  1033 + LqDirectorSalaryStatisticsEntity existing = null;
  1034 + if (!string.IsNullOrWhiteSpace(id))
  1035 + {
  1036 + existing = await _db.Queryable<LqDirectorSalaryStatisticsEntity>()
  1037 + .Where(x => x.Id == id).FirstAsync();
  1038 +
  1039 + if (existing != null && (existing.IsLocked == 1 || existing.EmployeeConfirmStatus == 1))
  1040 + {
  1041 + skippedCount++;
  1042 + failCount++;
  1043 + continue;
  1044 + }
  1045 + }
  1046 +
  1047 + var entity = existing ?? new LqDirectorSalaryStatisticsEntity
  1048 + {
  1049 + Id = string.IsNullOrWhiteSpace(id) ? YitIdHelper.NextId().ToString() : id,
  1050 + EmployeeConfirmStatus = 0,
  1051 + IsLocked = 0,
  1052 + CreateTime = DateTime.Now,
  1053 + CreateUser = ""
  1054 + };
  1055 +
  1056 + // Excel字段映射(主任工资43列,Excel顺序:门店名称,员工姓名,岗位,实发工资,补贴合计,扣款合计,是否锁定,是否新店,新店保护阶段,门店总业绩...)
  1057 + entity.StoreName = storeName;
  1058 + entity.EmployeeName = employeeName;
  1059 + entity.Position = GetColumnValue(2 + offset);
  1060 + entity.ActualSalary = ParseDecimal(GetColumnValue(3 + offset));
  1061 + entity.TotalSubsidy = ParseDecimal(GetColumnValue(4 + offset));
  1062 + entity.TotalDeduction = ParseDecimal(GetColumnValue(5 + offset));
  1063 + // 跳过"是否锁定"字段(第7列),在最后处理
  1064 + entity.IsNewStore = GetColumnValue(7 + offset) == "是" ? "是" : "否";
  1065 + entity.NewStoreProtectionStage = ParseInt(GetColumnValue(8 + offset));
  1066 + entity.StoreTotalPerformance = ParseDecimal(GetColumnValue(9 + offset));
  1067 + entity.StoreBillingPerformance = ParseDecimal(GetColumnValue(10 + offset));
  1068 + entity.StoreRefundPerformance = ParseDecimal(GetColumnValue(11 + offset));
  1069 + entity.StoreLifeline = ParseDecimal(GetColumnValue(12 + offset));
  1070 + entity.PerformanceCompletionRate = ParseDecimal(GetColumnValue(13 + offset));
  1071 + entity.PerformanceReached = GetColumnValue(14 + offset);
  1072 + entity.HeadCountReached = GetColumnValue(15 + offset);
  1073 + entity.ConsumeReached = GetColumnValue(16 + offset);
  1074 + entity.AssessmentDeduction = ParseDecimal(GetColumnValue(17 + offset));
  1075 + entity.UnreachedIndicatorCount = ParseInt(GetColumnValue(18 + offset));
  1076 + entity.HeadCount = ParseInt(GetColumnValue(19 + offset));
  1077 + entity.TargetHeadCount = ParseDecimal(GetColumnValue(20 + offset));
  1078 + entity.StoreConsume = ParseDecimal(GetColumnValue(21 + offset));
  1079 + entity.TargetConsume = ParseDecimal(GetColumnValue(22 + offset));
  1080 + entity.CommissionRateBelowLifeline = ParseDecimal(GetColumnValue(23 + offset));
  1081 + entity.CommissionRateAboveLifeline = ParseDecimal(GetColumnValue(24 + offset));
  1082 + entity.CommissionAmountBelowLifeline = ParseDecimal(GetColumnValue(25 + offset));
  1083 + entity.CommissionAmountAboveLifeline = ParseDecimal(GetColumnValue(26 + offset));
  1084 + entity.TotalCommissionAmount = ParseDecimal(GetColumnValue(27 + offset));
  1085 + entity.BaseSalary = ParseDecimal(GetColumnValue(28 + offset));
  1086 + entity.ActualBaseSalary = ParseDecimal(GetColumnValue(29 + offset));
  1087 + entity.WorkingDays = ParseInt(GetColumnValue(30 + offset));
  1088 + entity.LeaveDays = ParseInt(GetColumnValue(31 + offset));
  1089 + entity.GrossSalary = ParseDecimal(GetColumnValue(32 + offset));
  1090 + entity.Bonus = ParseDecimal(GetColumnValue(33 + offset));
  1091 + entity.ReturnPhoneDeposit = ParseDecimal(GetColumnValue(34 + offset));
  1092 + entity.ReturnAccommodationDeposit = ParseDecimal(GetColumnValue(35 + offset));
  1093 + entity.MonthlyPaymentStatus = GetColumnValue(36 + offset);
  1094 + entity.PaidAmount = ParseDecimal(GetColumnValue(37 + offset));
  1095 + entity.PendingAmount = ParseDecimal(GetColumnValue(38 + offset));
  1096 + entity.LastMonthSupplement = ParseDecimal(GetColumnValue(39 + offset));
  1097 + entity.MonthlyTotalPayment = ParseDecimal(GetColumnValue(40 + offset));
  1098 + // 处理门店类型和类别
  1099 + var storeTypeStr = GetColumnValue(41 + offset);
  1100 + if (!string.IsNullOrWhiteSpace(storeTypeStr) && int.TryParse(storeTypeStr, out int storeType))
  1101 + entity.StoreType = storeType;
  1102 + var storeCategoryStr = GetColumnValue(42 + offset);
  1103 + if (!string.IsNullOrWhiteSpace(storeCategoryStr) && int.TryParse(storeCategoryStr, out int storeCategory))
  1104 + entity.StoreCategory = storeCategory;
  1105 + // 处理锁定状态(第7列)
  1106 + var isLockedStr = GetColumnValue(6 + offset);
  1107 + if (isLockedStr == "已锁定" || isLockedStr == "1" || isLockedStr == "锁定") entity.IsLocked = 1;
  1108 + else entity.IsLocked = 0;
  1109 +
  1110 + if (existing != null)
  1111 + {
  1112 + entity.StoreId = existing.StoreId;
  1113 + entity.EmployeeId = existing.EmployeeId;
  1114 + entity.StatisticsMonth = existing.StatisticsMonth;
  1115 + }
  1116 + else
  1117 + {
  1118 + if (!string.IsNullOrWhiteSpace(employeeName))
  1119 + {
  1120 + var user = await _db.Queryable<UserEntity>()
  1121 + .Where(u => u.RealName == employeeName).FirstAsync();
  1122 + if (user != null)
  1123 + {
  1124 + entity.EmployeeId = user.Id;
  1125 + if (!string.IsNullOrEmpty(user.Mdid) && string.IsNullOrWhiteSpace(storeName))
  1126 + entity.StoreId = user.Mdid;
  1127 + }
  1128 +
  1129 + if (!string.IsNullOrWhiteSpace(storeName) && string.IsNullOrEmpty(entity.StoreId))
  1130 + {
  1131 + var store = await _db.Queryable<LqMdxxEntity>()
  1132 + .Where(s => s.Dm == storeName).FirstAsync();
  1133 + if (store != null) entity.StoreId = store.Id;
  1134 + }
  1135 + }
  1136 + }
  1137 +
  1138 + entity.UpdateTime = DateTime.Now;
  1139 + if (existing != null) recordsToUpdate.Add(entity);
  1140 + else recordsToInsert.Add(entity);
  1141 + successCount++;
  1142 + }
  1143 + catch (Exception ex)
  1144 + {
  1145 + errorMessages.Add($"第{i + 1}行数据处理失败: {ex.Message}");
  1146 + failCount++;
  1147 + }
  1148 + }
  1149 + }
  1150 + finally
  1151 + {
  1152 + if (File.Exists(tempFilePath)) File.Delete(tempFilePath);
  1153 + }
  1154 +
  1155 + if (recordsToInsert.Any()) await _db.Insertable(recordsToInsert).ExecuteCommandAsync();
  1156 + if (recordsToUpdate.Any()) await _db.Updateable(recordsToUpdate).ExecuteCommandAsync();
  1157 +
  1158 + return new
  1159 + {
  1160 + success = true,
  1161 + message = $"导入完成:成功 {successCount} 条,失败 {failCount} 条,跳过 {skippedCount} 条(已锁定或已确认)",
  1162 + successCount,
  1163 + failCount,
  1164 + skippedCount,
  1165 + errors = errorMessages
  1166 + };
  1167 + }
  1168 + catch (Exception ex)
  1169 + {
  1170 + throw NCCException.Oh($"导入主任工资数据失败: {ex.Message}");
  1171 + }
  1172 + }
  1173 +
  1174 + #endregion
652 1175 }
653 1176 }
654 1177  
... ...
netcore/src/Modularity/Extend/NCC.Extend/LqMajorProjectDirectorSalaryService.cs
1 1 using Microsoft.AspNetCore.Authorization;
2 2 using Microsoft.AspNetCore.Mvc;
  3 +using Microsoft.Extensions.Logging;
3 4 using NCC.Common.Filter;
4 5 using NCC.Common.Helper;
5 6 using NCC.Dependency;
6 7 using NCC.DynamicApiController;
7 8 using NCC.Extend.Entitys.Dto.LqMajorProjectDirectorSalary;
  9 +using NCC.Extend.Entitys.Dto.LqSalary;
8 10 using NCC.Extend.Entitys.lq_attendance_summary;
9 11 using NCC.Extend.Entitys.lq_hytk_hytk;
10 12 using NCC.Extend.Entitys.lq_hytk_mx;
... ... @@ -14,14 +16,18 @@ using NCC.Extend.Entitys.lq_kd_jksyj;
14 16 using NCC.Extend.Entitys.lq_md_target;
15 17 using NCC.Extend.Entitys.lq_mdxx;
16 18 using NCC.Extend.Entitys.lq_major_project_director_salary_statistics;
  19 +using NCC.FriendlyException;
17 20 using NCC.System.Entitys.Permission;
18 21 using SqlSugar;
19 22 using System;
20 23 using System.Collections.Generic;
  24 +using System.Data;
  25 +using System.IO;
21 26 using System.Linq;
22 27 using System.Threading.Tasks;
23 28 using Yitter.IdGenerator;
24 29 using Newtonsoft.Json;
  30 +using Microsoft.AspNetCore.Http;
25 31  
26 32 namespace NCC.Extend
27 33 {
... ... @@ -33,13 +39,15 @@ namespace NCC.Extend
33 39 public class LqMajorProjectDirectorSalaryService : IDynamicApiController, ITransient
34 40 {
35 41 private readonly ISqlSugarClient _db;
  42 + private readonly ILogger<LqMajorProjectDirectorSalaryService> _logger;
36 43  
37 44 /// <summary>
38 45 /// 初始化一个<see cref="LqMajorProjectDirectorSalaryService"/>类型的新实例
39 46 /// </summary>
40   - public LqMajorProjectDirectorSalaryService(ISqlSugarClient db)
  47 + public LqMajorProjectDirectorSalaryService(ISqlSugarClient db, ILogger<LqMajorProjectDirectorSalaryService> logger)
41 48 {
42 49 _db = db;
  50 + _logger = logger;
43 51 }
44 52  
45 53 /// <summary>
... ... @@ -52,17 +60,7 @@ namespace NCC.Extend
52 60 {
53 61 var monthStr = $"{input.Year}{input.Month:D2}";
54 62  
55   - // 1. 检查当月是否已生成工资数据
56   - var exists = await _db.Queryable<LqMajorProjectDirectorSalaryStatisticsEntity>()
57   - .AnyAsync(x => x.StatisticsMonth == monthStr);
58   -
59   - // 2. 如果没有数据,则进行计算
60   - if (!exists)
61   - {
62   - await CalculateMajorProjectDirectorSalary(input.Year, input.Month);
63   - }
64   -
65   - // 3. 查询数据
  63 + // 查询数据
66 64 var query = _db.Queryable<LqMajorProjectDirectorSalaryStatisticsEntity>()
67 65 .Where(x => x.StatisticsMonth == monthStr);
68 66  
... ... @@ -128,6 +126,71 @@ namespace NCC.Extend
128 126 }
129 127  
130 128 /// <summary>
  129 + /// 通过月份和员工ID查询工资
  130 + /// </summary>
  131 + [HttpGet("query-by-employee")]
  132 + public async Task<MajorProjectDirectorSalaryOutput> GetSalaryByEmployee([FromQuery] SalaryQueryByEmployeeInput input)
  133 + {
  134 + if (input.Year <= 0 || input.Month <= 0 || input.Month > 12)
  135 + throw NCCException.Oh("年份和月份参数不正确");
  136 + if (string.IsNullOrWhiteSpace(input.EmployeeId))
  137 + throw NCCException.Oh("员工ID不能为空");
  138 + var monthStr = $"{input.Year}{input.Month:D2}";
  139 + var salary = await _db.Queryable<LqMajorProjectDirectorSalaryStatisticsEntity>()
  140 + .Where(x => x.StatisticsMonth == monthStr && x.EmployeeId == input.EmployeeId)
  141 + .Select(x => new MajorProjectDirectorSalaryOutput
  142 + {
  143 + Id = x.Id,
  144 + StatisticsMonth = x.StatisticsMonth,
  145 + Position = x.Position,
  146 + EmployeeName = x.EmployeeName,
  147 + EmployeeId = x.EmployeeId,
  148 + EmployeeAccount = x.EmployeeAccount,
  149 + IsTerminated = x.IsTerminated,
  150 + StoreDetail = x.StoreDetail,
  151 + TotalPerformance = x.TotalPerformance,
  152 + BillingAmount = x.BillingAmount,
  153 + RefundAmount = x.RefundAmount,
  154 + BaseSalary = x.BaseSalary,
  155 + CommissionRate = x.CommissionRate,
  156 + CommissionAmount = x.CommissionAmount,
  157 + WorkingDays = x.WorkingDays,
  158 + LeaveDays = x.LeaveDays,
  159 + CalculatedGrossSalary = x.CalculatedGrossSalary,
  160 + FinalGrossSalary = x.FinalGrossSalary,
  161 + MonthlyTrainingSubsidy = x.MonthlyTrainingSubsidy,
  162 + MonthlyTransportSubsidy = x.MonthlyTransportSubsidy,
  163 + LastMonthTrainingSubsidy = x.LastMonthTrainingSubsidy,
  164 + LastMonthTransportSubsidy = x.LastMonthTransportSubsidy,
  165 + TotalSubsidy = x.TotalSubsidy,
  166 + MissingCard = x.MissingCard,
  167 + LateArrival = x.LateArrival,
  168 + LeaveDeduction = x.LeaveDeduction,
  169 + SocialInsuranceDeduction = x.SocialInsuranceDeduction,
  170 + RewardDeduction = x.RewardDeduction,
  171 + AccommodationDeduction = x.AccommodationDeduction,
  172 + StudyPeriodDeduction = x.StudyPeriodDeduction,
  173 + WorkClothesDeduction = x.WorkClothesDeduction,
  174 + TotalDeduction = x.TotalDeduction,
  175 + Bonus = x.Bonus,
  176 + ReturnPhoneDeposit = x.ReturnPhoneDeposit,
  177 + ReturnAccommodationDeposit = x.ReturnAccommodationDeposit,
  178 + ActualSalary = x.ActualSalary,
  179 + MonthlyPaymentStatus = x.MonthlyPaymentStatus,
  180 + PaidAmount = x.PaidAmount,
  181 + PendingAmount = x.PendingAmount,
  182 + LastMonthSupplement = x.LastMonthSupplement,
  183 + MonthlyTotalPayment = x.MonthlyTotalPayment,
  184 + IsLocked = x.IsLocked,
  185 + UpdateTime = x.UpdateTime
  186 + })
  187 + .FirstAsync();
  188 + if (salary == null)
  189 + throw NCCException.Oh($"未找到员工{input.EmployeeId}在{input.Year}年{input.Month}月的工资记录");
  190 + return salary;
  191 + }
  192 +
  193 + /// <summary>
131 194 /// 计算大项目主管工资
132 195 /// </summary>
133 196 /// <param name="year">年份</param>
... ... @@ -362,12 +425,41 @@ namespace NCC.Extend
362 425 // 3. 保存数据
363 426 if (directorStats.Any())
364 427 {
365   - // 先删除当月旧数据 (防止重复)
366   - await _db.Deleteable<LqMajorProjectDirectorSalaryStatisticsEntity>()
367   - .Where(x => x.StatisticsMonth == monthStr)
368   - .ExecuteCommandAsync();
369   -
370   - await _db.Insertable(directorStats.Values.ToList()).ExecuteCommandAsync();
  428 + var existingRecords = await _db.Queryable<LqMajorProjectDirectorSalaryStatisticsEntity>()
  429 + .Where(x => x.StatisticsMonth == monthStr).ToListAsync();
  430 + var existingDict = existingRecords.Where(x => !string.IsNullOrEmpty(x.EmployeeId))
  431 + .GroupBy(x => x.EmployeeId).ToDictionary(g => g.Key, g => g.First());
  432 + var recordsToInsert = new List<LqMajorProjectDirectorSalaryStatisticsEntity>();
  433 + var recordsToUpdate = new List<LqMajorProjectDirectorSalaryStatisticsEntity>();
  434 + var skippedCount = 0;
  435 + foreach (var salary in directorStats.Values)
  436 + {
  437 + if (existingDict.ContainsKey(salary.EmployeeId))
  438 + {
  439 + var existing = existingDict[salary.EmployeeId];
  440 + if (existing.IsLocked == 1 || existing.EmployeeConfirmStatus == 1) { skippedCount++; continue; }
  441 + salary.Id = existing.Id;
  442 + salary.EmployeeConfirmStatus = existing.EmployeeConfirmStatus;
  443 + salary.EmployeeConfirmTime = existing.EmployeeConfirmTime;
  444 + salary.EmployeeConfirmRemark = existing.EmployeeConfirmRemark;
  445 + salary.IsLocked = existing.IsLocked;
  446 + salary.CreateTime = existing.CreateTime;
  447 + salary.CreateUser = existing.CreateUser;
  448 + recordsToUpdate.Add(salary);
  449 + }
  450 + else
  451 + {
  452 + salary.Id = YitIdHelper.NextId().ToString();
  453 + salary.EmployeeConfirmStatus = 0;
  454 + salary.IsLocked = 0;
  455 + salary.CreateTime = DateTime.Now;
  456 + salary.CreateUser = "";
  457 + recordsToInsert.Add(salary);
  458 + }
  459 + }
  460 + if (recordsToInsert.Any()) await _db.Insertable(recordsToInsert).ExecuteCommandAsync();
  461 + if (recordsToUpdate.Any()) await _db.Updateable(recordsToUpdate).ExecuteCommandAsync();
  462 + if (skippedCount > 0) _logger.LogWarning($"计算工资时跳过了 {skippedCount} 条已锁定或已确认的记录(月份:{monthStr})");
371 463 }
372 464 }
373 465  
... ... @@ -442,6 +534,420 @@ namespace NCC.Extend
442 534 public decimal RefundAmount { get; set; }
443 535 public decimal TotalPerformance { get; set; }
444 536 }
  537 +
  538 + #region 员工工资确认
  539 +
  540 + /// <summary>
  541 + /// 员工确认工资条
  542 + /// </summary>
  543 + /// <remarks>
  544 + /// 员工确认自己的工资条,确认后工资数据不可再修改
  545 + ///
  546 + /// 示例请求:
  547 + /// <code>
  548 + /// {
  549 + /// "id": "工资记录ID",
  550 + /// "employeeId": "员工ID",
  551 + /// "remark": "确认备注(可选)"
  552 + /// }
  553 + /// </code>
  554 + ///
  555 + /// 参数说明:
  556 + /// - id: 工资记录ID(必填)
  557 + /// - employeeId: 员工ID(必填)
  558 + /// - remark: 确认备注(可选)
  559 + ///
  560 + /// 注意事项:
  561 + /// - 只能确认自己的工资条
  562 + /// - 只能确认已锁定的工资条(IsLocked = 1)
  563 + /// - 已确认的工资条不能重复确认
  564 + /// </remarks>
  565 + /// <param name="input">确认参数</param>
  566 + /// <returns>操作结果</returns>
  567 + /// <response code="200">确认成功</response>
  568 + /// <response code="400">参数错误或记录不存在</response>
  569 + [HttpPost("confirm")]
  570 + public async Task<string> ConfirmSalary([FromBody] SalaryConfirmInput input)
  571 + {
  572 + try
  573 + {
  574 + if (string.IsNullOrWhiteSpace(input.Id) || string.IsNullOrWhiteSpace(input.EmployeeId))
  575 + throw NCCException.Oh("工资记录ID和员工ID不能为空");
  576 + var salary = await _db.Queryable<LqMajorProjectDirectorSalaryStatisticsEntity>()
  577 + .Where(s => s.Id == input.Id && s.EmployeeId == input.EmployeeId).FirstAsync();
  578 + if (salary == null) throw NCCException.Oh("工资记录不存在或不属于该员工");
  579 + if (salary.EmployeeConfirmStatus == 1) throw NCCException.Oh("该工资条已确认,不能重复确认");
  580 + if (salary.IsLocked != 1) throw NCCException.Oh("该工资条尚未锁定,请等待管理员锁定后再确认");
  581 + salary.EmployeeConfirmStatus = 1;
  582 + salary.EmployeeConfirmTime = DateTime.Now;
  583 + salary.EmployeeConfirmRemark = input.Remark;
  584 + salary.UpdateTime = DateTime.Now;
  585 + await _db.Updateable(salary).ExecuteCommandAsync();
  586 + return "确认成功";
  587 + }
  588 + catch (Exception ex)
  589 + {
  590 + throw NCCException.Oh($"确认工资条失败: {ex.Message}");
  591 + }
  592 + }
  593 +
  594 + #endregion
  595 +
  596 + #region 工资锁定/解锁
  597 +
  598 + /// <summary>
  599 + /// 批量锁定/解锁工资条
  600 + /// </summary>
  601 + [HttpPost("lock")]
  602 + public async Task<string> LockSalary([FromBody] SalaryLockInput input)
  603 + {
  604 + try
  605 + {
  606 + if (input == null || input.Ids == null || !input.Ids.Any())
  607 + throw NCCException.Oh("工资记录ID列表不能为空");
  608 + var salaries = await _db.Queryable<LqMajorProjectDirectorSalaryStatisticsEntity>()
  609 + .Where(s => input.Ids.Contains(s.Id))
  610 + .ToListAsync();
  611 + if (!salaries.Any())
  612 + throw NCCException.Oh("未找到指定的工资记录");
  613 + var lockedCount = 0;
  614 + var unlockedCount = 0;
  615 + var skippedCount = 0;
  616 + foreach (var salary in salaries)
  617 + {
  618 + if (salary.EmployeeConfirmStatus == 1 && !input.IsLocked)
  619 + {
  620 + skippedCount++;
  621 + continue;
  622 + }
  623 + salary.IsLocked = input.IsLocked ? 1 : 0;
  624 + salary.UpdateTime = DateTime.Now;
  625 + if (input.IsLocked) lockedCount++; else unlockedCount++;
  626 + }
  627 + await _db.Updateable(salaries).ExecuteCommandAsync();
  628 + var action = input.IsLocked ? "锁定" : "解锁";
  629 + var count = input.IsLocked ? lockedCount : unlockedCount;
  630 + var message = $"{action}成功:{count}条";
  631 + if (skippedCount > 0)
  632 + message += $",跳过{skippedCount}条(已确认的记录不能解锁)";
  633 + return message;
  634 + }
  635 + catch (Exception ex)
  636 + {
  637 + throw NCCException.Oh($"锁定/解锁工资条失败: {ex.Message}");
  638 + }
  639 + }
  640 +
  641 + /// <summary>
  642 + /// 批量锁定当月所有工资
  643 + /// </summary>
  644 + /// <param name="input">批量锁定输入参数</param>
  645 + /// <returns>锁定结果</returns>
  646 + [HttpPost("lock-by-month")]
  647 + public async Task<dynamic> LockSalaryByMonth([FromBody] SalaryLockByMonthInput input)
  648 + {
  649 + try
  650 + {
  651 + if (input == null)
  652 + throw NCCException.Oh("参数不能为空");
  653 +
  654 + if (input.Year <= 0 || input.Month <= 0 || input.Month > 12)
  655 + throw NCCException.Oh("年份和月份参数不正确");
  656 +
  657 + var monthStr = $"{input.Year}{input.Month:D2}";
  658 +
  659 + var salaries = await _db.Queryable<LqMajorProjectDirectorSalaryStatisticsEntity>()
  660 + .Where(s => s.StatisticsMonth == monthStr)
  661 + .ToListAsync();
  662 +
  663 + if (!salaries.Any())
  664 + throw NCCException.Oh($"未找到{input.Year}年{input.Month}月的工资记录");
  665 +
  666 + var lockedCount = 0;
  667 + var unlockedCount = 0;
  668 + var skippedCount = 0;
  669 + var alreadyLockedCount = 0;
  670 +
  671 + foreach (var salary in salaries)
  672 + {
  673 + if (salary.EmployeeConfirmStatus == 1 && !input.IsLocked)
  674 + {
  675 + skippedCount++;
  676 + continue;
  677 + }
  678 +
  679 + if (salary.IsLocked == 1 && input.IsLocked)
  680 + {
  681 + alreadyLockedCount++;
  682 + continue;
  683 + }
  684 +
  685 + if (salary.IsLocked == 0 && !input.IsLocked)
  686 + {
  687 + alreadyLockedCount++;
  688 + continue;
  689 + }
  690 +
  691 + salary.IsLocked = input.IsLocked ? 1 : 0;
  692 + salary.UpdateTime = DateTime.Now;
  693 +
  694 + if (input.IsLocked)
  695 + lockedCount++;
  696 + else
  697 + unlockedCount++;
  698 + }
  699 +
  700 + if (lockedCount > 0 || unlockedCount > 0)
  701 + {
  702 + var salariesToUpdate = salaries.Where(s =>
  703 + (input.IsLocked && s.IsLocked == 0) ||
  704 + (!input.IsLocked && s.IsLocked == 1 && s.EmployeeConfirmStatus != 1)
  705 + ).ToList();
  706 +
  707 + if (salariesToUpdate.Any())
  708 + {
  709 + await _db.Updateable(salariesToUpdate)
  710 + .UpdateColumns(s => new { s.IsLocked, s.UpdateTime })
  711 + .ExecuteCommandAsync();
  712 + }
  713 + }
  714 +
  715 + var action = input.IsLocked ? "锁定" : "解锁";
  716 + var count = input.IsLocked ? lockedCount : unlockedCount;
  717 + var message = $"{action}成功:{count}条";
  718 +
  719 + if (alreadyLockedCount > 0)
  720 + message += $",跳过{alreadyLockedCount}条(已是{action}状态)";
  721 +
  722 + if (skippedCount > 0)
  723 + message += $",跳过{skippedCount}条(已确认的记录不能解锁)";
  724 +
  725 + return new
  726 + {
  727 + success = true,
  728 + message = message,
  729 + total = salaries.Count,
  730 + locked = lockedCount,
  731 + unlocked = unlockedCount,
  732 + skipped = skippedCount,
  733 + alreadyLocked = alreadyLockedCount
  734 + };
  735 + }
  736 + catch (Exception ex)
  737 + {
  738 + _logger.LogError(ex, "批量锁定当月工资失败");
  739 + var action = input?.IsLocked == true ? "锁定" : "解锁";
  740 + throw NCCException.Oh($"批量{action}当月工资失败: {ex.Message}");
  741 + }
  742 + }
  743 +
  744 + #endregion
  745 +
  746 + #region 导入工资
  747 +
  748 + /// <summary>
  749 + /// 从Excel导入大项目主管工资数据
  750 + /// </summary>
  751 + /// <param name="file">Excel文件</param>
  752 + /// <returns>导入结果</returns>
  753 + [HttpPost("import")]
  754 + public async Task<dynamic> ImportSalaryFromExcel(IFormFile file)
  755 + {
  756 + try
  757 + {
  758 + if (file == null || file.Length == 0)
  759 + throw NCCException.Oh("请选择要上传的Excel文件");
  760 +
  761 + var allowedExtensions = new[] { ".xlsx", ".xls" };
  762 + var fileExtension = Path.GetExtension(file.FileName).ToLowerInvariant();
  763 + if (!allowedExtensions.Contains(fileExtension))
  764 + throw NCCException.Oh("只支持.xlsx和.xls格式的Excel文件");
  765 +
  766 + var recordsToInsert = new List<LqMajorProjectDirectorSalaryStatisticsEntity>();
  767 + var recordsToUpdate = new List<LqMajorProjectDirectorSalaryStatisticsEntity>();
  768 + var errorMessages = new List<string>();
  769 + var successCount = 0;
  770 + var failCount = 0;
  771 + var skippedCount = 0;
  772 +
  773 + var tempFilePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString() + Path.GetExtension(file.FileName));
  774 + try
  775 + {
  776 + using (var stream = new FileStream(tempFilePath, FileMode.Create))
  777 + {
  778 + await file.CopyToAsync(stream);
  779 + }
  780 +
  781 + var dataTable = ExcelImportHelper.ToDataTable(tempFilePath, 0, 0);
  782 + if (dataTable.Rows.Count == 0)
  783 + throw NCCException.Oh("Excel文件中没有数据行");
  784 +
  785 + Func<string, decimal> ParseDecimal = (str) =>
  786 + {
  787 + if (string.IsNullOrWhiteSpace(str)) return 0;
  788 + var cleaned = str.Trim().Replace(",", "").Replace(",", "").Replace("¥", "").Replace("$", "").Replace("元", "").Replace("%", "").Replace(" ", "");
  789 + return decimal.TryParse(cleaned, out decimal result) ? result : 0;
  790 + };
  791 +
  792 + Func<string, int> ParseInt = (str) =>
  793 + {
  794 + if (string.IsNullOrWhiteSpace(str)) return 0;
  795 + var cleaned = str.Trim().Replace(",", "").Replace(",", "").Replace(" ", "");
  796 + return int.TryParse(cleaned, out int result) ? result : 0;
  797 + };
  798 +
  799 + for (int i = 1; i < dataTable.Rows.Count; i++)
  800 + {
  801 + try
  802 + {
  803 + var row = dataTable.Rows[i];
  804 + Func<int, string> GetColumnValue = (colIndex) => colIndex < row.ItemArray.Length && row[colIndex] != null ? row[colIndex].ToString().Trim() : "";
  805 +
  806 + var firstColumnValue = GetColumnValue(0);
  807 + bool isOldFormat = !string.IsNullOrWhiteSpace(firstColumnValue) && (firstColumnValue == "员工姓名" || (!long.TryParse(firstColumnValue, out _) && firstColumnValue.Length > 20));
  808 +
  809 + int employeeNameIndex = isOldFormat ? 0 : 1;
  810 + int offset = isOldFormat ? 0 : 1;
  811 +
  812 + var id = isOldFormat ? "" : GetColumnValue(0);
  813 + var employeeName = GetColumnValue(employeeNameIndex);
  814 +
  815 + if (string.IsNullOrWhiteSpace(id) && string.IsNullOrWhiteSpace(employeeName))
  816 + continue;
  817 +
  818 + if (string.IsNullOrWhiteSpace(id) && !string.IsNullOrWhiteSpace(employeeName))
  819 + {
  820 + var matchedRecord = await _db.Queryable<LqMajorProjectDirectorSalaryStatisticsEntity>()
  821 + .Where(x => x.EmployeeName == employeeName)
  822 + .OrderBy(x => x.CreateTime, OrderByType.Desc)
  823 + .FirstAsync();
  824 + if (matchedRecord != null) id = matchedRecord.Id;
  825 + }
  826 +
  827 + if (string.IsNullOrWhiteSpace(employeeName))
  828 + {
  829 + errorMessages.Add($"第{i + 1}行:员工姓名不能为空");
  830 + failCount++;
  831 + continue;
  832 + }
  833 +
  834 + LqMajorProjectDirectorSalaryStatisticsEntity existing = null;
  835 + if (!string.IsNullOrWhiteSpace(id))
  836 + {
  837 + existing = await _db.Queryable<LqMajorProjectDirectorSalaryStatisticsEntity>()
  838 + .Where(x => x.Id == id).FirstAsync();
  839 +
  840 + if (existing != null && (existing.IsLocked == 1 || existing.EmployeeConfirmStatus == 1))
  841 + {
  842 + skippedCount++;
  843 + failCount++;
  844 + continue;
  845 + }
  846 + }
  847 +
  848 + var entity = existing ?? new LqMajorProjectDirectorSalaryStatisticsEntity
  849 + {
  850 + Id = string.IsNullOrWhiteSpace(id) ? YitIdHelper.NextId().ToString() : id,
  851 + EmployeeConfirmStatus = 0,
  852 + IsLocked = 0,
  853 + CreateTime = DateTime.Now,
  854 + CreateUser = ""
  855 + };
  856 +
  857 + // Excel字段映射(大项目主管工资39列:员工姓名,员工账号,核算岗位,统计月份,是否离职,开单金额,退卡金额,总业绩,底薪,提成比例,提成金额...)
  858 + entity.EmployeeName = employeeName;
  859 + entity.EmployeeAccount = GetColumnValue(1 + offset);
  860 + entity.Position = GetColumnValue(2 + offset);
  861 + entity.StatisticsMonth = GetColumnValue(3 + offset);
  862 + entity.IsTerminated = GetColumnValue(4 + offset) == "离职" || GetColumnValue(4 + offset) == "1" ? 1 : 0;
  863 + entity.BillingAmount = ParseDecimal(GetColumnValue(5 + offset));
  864 + entity.RefundAmount = ParseDecimal(GetColumnValue(6 + offset));
  865 + entity.TotalPerformance = ParseDecimal(GetColumnValue(7 + offset));
  866 + entity.BaseSalary = ParseDecimal(GetColumnValue(8 + offset));
  867 + entity.CommissionRate = ParseDecimal(GetColumnValue(9 + offset));
  868 + entity.CommissionAmount = ParseDecimal(GetColumnValue(10 + offset));
  869 + entity.WorkingDays = ParseDecimal(GetColumnValue(11 + offset));
  870 + entity.LeaveDays = ParseDecimal(GetColumnValue(12 + offset));
  871 + entity.CalculatedGrossSalary = ParseDecimal(GetColumnValue(13 + offset));
  872 + entity.FinalGrossSalary = ParseDecimal(GetColumnValue(14 + offset));
  873 + entity.MonthlyTrainingSubsidy = ParseDecimal(GetColumnValue(15 + offset));
  874 + entity.MonthlyTransportSubsidy = ParseDecimal(GetColumnValue(16 + offset));
  875 + entity.LastMonthTrainingSubsidy = ParseDecimal(GetColumnValue(17 + offset));
  876 + entity.LastMonthTransportSubsidy = ParseDecimal(GetColumnValue(18 + offset));
  877 + entity.TotalSubsidy = ParseDecimal(GetColumnValue(19 + offset));
  878 + entity.MissingCard = ParseDecimal(GetColumnValue(20 + offset));
  879 + entity.LateArrival = ParseDecimal(GetColumnValue(21 + offset));
  880 + entity.LeaveDeduction = ParseDecimal(GetColumnValue(22 + offset));
  881 + entity.SocialInsuranceDeduction = ParseDecimal(GetColumnValue(23 + offset));
  882 + entity.RewardDeduction = ParseDecimal(GetColumnValue(24 + offset));
  883 + entity.AccommodationDeduction = ParseDecimal(GetColumnValue(25 + offset));
  884 + entity.StudyPeriodDeduction = ParseDecimal(GetColumnValue(26 + offset));
  885 + entity.WorkClothesDeduction = ParseDecimal(GetColumnValue(27 + offset));
  886 + entity.TotalDeduction = ParseDecimal(GetColumnValue(28 + offset));
  887 + entity.Bonus = ParseDecimal(GetColumnValue(29 + offset));
  888 + entity.ReturnPhoneDeposit = ParseDecimal(GetColumnValue(30 + offset));
  889 + entity.ReturnAccommodationDeposit = ParseDecimal(GetColumnValue(31 + offset));
  890 + entity.LastMonthSupplement = ParseDecimal(GetColumnValue(32 + offset));
  891 + entity.ActualSalary = ParseDecimal(GetColumnValue(33 + offset));
  892 + entity.MonthlyPaymentStatus = GetColumnValue(34 + offset);
  893 + entity.PaidAmount = ParseDecimal(GetColumnValue(35 + offset));
  894 + entity.PendingAmount = ParseDecimal(GetColumnValue(36 + offset));
  895 + entity.MonthlyTotalPayment = ParseDecimal(GetColumnValue(37 + offset));
  896 + var isLockedStr = GetColumnValue(38 + offset);
  897 + entity.IsLocked = isLockedStr == "已锁定" || isLockedStr == "1" || isLockedStr == "锁定" ? 1 : 0;
  898 +
  899 + if (existing != null)
  900 + {
  901 + entity.EmployeeId = existing.EmployeeId;
  902 + entity.StoreDetail = existing.StoreDetail;
  903 + }
  904 + else
  905 + {
  906 + if (!string.IsNullOrWhiteSpace(employeeName))
  907 + {
  908 + var user = await _db.Queryable<UserEntity>()
  909 + .Where(u => u.RealName == employeeName).FirstAsync();
  910 + if (user != null) entity.EmployeeId = user.Id;
  911 + }
  912 + }
  913 +
  914 + entity.UpdateTime = DateTime.Now;
  915 + if (existing != null) recordsToUpdate.Add(entity);
  916 + else recordsToInsert.Add(entity);
  917 + successCount++;
  918 + }
  919 + catch (Exception ex)
  920 + {
  921 + errorMessages.Add($"第{i + 1}行数据处理失败: {ex.Message}");
  922 + failCount++;
  923 + }
  924 + }
  925 + }
  926 + finally
  927 + {
  928 + if (File.Exists(tempFilePath)) File.Delete(tempFilePath);
  929 + }
  930 +
  931 + if (recordsToInsert.Any()) await _db.Insertable(recordsToInsert).ExecuteCommandAsync();
  932 + if (recordsToUpdate.Any()) await _db.Updateable(recordsToUpdate).ExecuteCommandAsync();
  933 +
  934 + return new
  935 + {
  936 + success = true,
  937 + message = $"导入完成:成功 {successCount} 条,失败 {failCount} 条,跳过 {skippedCount} 条(已锁定或已确认)",
  938 + successCount,
  939 + failCount,
  940 + skippedCount,
  941 + errors = errorMessages
  942 + };
  943 + }
  944 + catch (Exception ex)
  945 + {
  946 + throw NCCException.Oh($"导入大项目主管工资数据失败: {ex.Message}");
  947 + }
  948 + }
  949 +
  950 + #endregion
445 951 }
446 952 }
447 953  
... ...
netcore/src/Modularity/Extend/NCC.Extend/LqMajorProjectTeacherSalaryService.cs
1 1 using Microsoft.AspNetCore.Authorization;
2 2 using Microsoft.AspNetCore.Mvc;
  3 +using Microsoft.Extensions.Logging;
3 4 using NCC.Common.Filter;
4 5 using NCC.Common.Helper;
5 6 using NCC.Dependency;
6 7 using NCC.DynamicApiController;
7 8 using NCC.Extend.Entitys.Dto.LqMajorProjectTeacherSalary;
  9 +using NCC.Extend.Entitys.Dto.LqSalary;
8 10 using NCC.Extend.Entitys.lq_attendance_summary;
9 11 using NCC.Extend.Entitys.lq_hytk_hytk;
10 12 using NCC.Extend.Entitys.lq_hytk_mx;
... ... @@ -15,13 +17,17 @@ using NCC.Extend.Entitys.lq_md_major_project_teacher_assignment;
15 17 using NCC.Extend.Entitys.lq_md_xdbhsj;
16 18 using NCC.Extend.Entitys.lq_mdxx;
17 19 using NCC.Extend.Entitys.lq_major_project_teacher_salary_statistics;
  20 +using NCC.FriendlyException;
18 21 using NCC.System.Entitys.Permission;
19 22 using SqlSugar;
20 23 using System;
21 24 using System.Collections.Generic;
  25 +using System.Data;
  26 +using System.IO;
22 27 using System.Linq;
23 28 using System.Threading.Tasks;
24 29 using Yitter.IdGenerator;
  30 +using Microsoft.AspNetCore.Http;
25 31  
26 32 namespace NCC.Extend
27 33 {
... ... @@ -33,13 +39,15 @@ namespace NCC.Extend
33 39 public class LqMajorProjectTeacherSalaryService : IDynamicApiController, ITransient
34 40 {
35 41 private readonly ISqlSugarClient _db;
  42 + private readonly ILogger<LqMajorProjectTeacherSalaryService> _logger;
36 43  
37 44 /// <summary>
38 45 /// 初始化一个<see cref="LqMajorProjectTeacherSalaryService"/>类型的新实例
39 46 /// </summary>
40   - public LqMajorProjectTeacherSalaryService(ISqlSugarClient db)
  47 + public LqMajorProjectTeacherSalaryService(ISqlSugarClient db, ILogger<LqMajorProjectTeacherSalaryService> logger)
41 48 {
42 49 _db = db;
  50 + _logger = logger;
43 51 }
44 52  
45 53 /// <summary>
... ... @@ -52,17 +60,7 @@ namespace NCC.Extend
52 60 {
53 61 var monthStr = $"{input.Year}{input.Month:D2}";
54 62  
55   - // 1. 检查当月是否已生成工资数据
56   - var exists = await _db.Queryable<LqMajorProjectTeacherSalaryStatisticsEntity>()
57   - .AnyAsync(x => x.StatisticsMonth == monthStr);
58   -
59   - // 2. 如果没有数据,则进行计算
60   - if (!exists)
61   - {
62   - await CalculateMajorProjectTeacherSalary(input.Year, input.Month);
63   - }
64   -
65   - // 3. 查询数据
  63 + // 查询数据
66 64 var query = _db.Queryable<LqMajorProjectTeacherSalaryStatisticsEntity>()
67 65 .Where(x => x.StatisticsMonth == monthStr);
68 66  
... ... @@ -143,6 +141,86 @@ namespace NCC.Extend
143 141 }
144 142  
145 143 /// <summary>
  144 + /// 通过月份和员工ID查询工资
  145 + /// </summary>
  146 + [HttpGet("query-by-employee")]
  147 + public async Task<MajorProjectTeacherSalaryOutput> GetSalaryByEmployee([FromQuery] SalaryQueryByEmployeeInput input)
  148 + {
  149 + if (input.Year <= 0 || input.Month <= 0 || input.Month > 12)
  150 + throw NCCException.Oh("年份和月份参数不正确");
  151 + if (string.IsNullOrWhiteSpace(input.EmployeeId))
  152 + throw NCCException.Oh("员工ID不能为空");
  153 + var monthStr = $"{input.Year}{input.Month:D2}";
  154 + var salary = await _db.Queryable<LqMajorProjectTeacherSalaryStatisticsEntity>()
  155 + .Where(x => x.StatisticsMonth == monthStr && x.EmployeeId == input.EmployeeId)
  156 + .Select(x => new MajorProjectTeacherSalaryOutput
  157 + {
  158 + Id = x.Id,
  159 + StatisticsMonth = x.StatisticsMonth,
  160 + StoreId = x.StoreId,
  161 + StoreName = x.StoreName,
  162 + Position = x.Position,
  163 + EmployeeName = x.EmployeeName,
  164 + EmployeeId = x.EmployeeId,
  165 + EmployeeAccount = x.EmployeeAccount,
  166 + OrderAchievement = x.OrderAchievement,
  167 + ConsumeAchievement = x.ConsumeAchievement,
  168 + RefundAchievement = x.RefundAchievement,
  169 + TotalPerformance = x.TotalPerformance,
  170 + BaseSalary = x.BaseSalary,
  171 + PerformanceCommissionRate = x.PerformanceCommissionRate,
  172 + PerformanceCommissionAmount = x.PerformanceCommissionAmount,
  173 + TotalCommission = x.TotalCommission,
  174 + HandworkFee = x.HandworkFee,
  175 + WorkingDays = x.WorkingDays,
  176 + LeaveDays = x.LeaveDays,
  177 + TransportationAllowance = x.TransportationAllowance,
  178 + LessRest = x.LessRest,
  179 + FullAttendance = x.FullAttendance,
  180 + CalculatedGrossSalary = x.CalculatedGrossSalary,
  181 + GuaranteedSalary = x.GuaranteedSalary,
  182 + GuaranteedLeaveDeduction = x.GuaranteedLeaveDeduction,
  183 + GuaranteedBaseSalary = x.GuaranteedBaseSalary,
  184 + GuaranteedSupplement = x.GuaranteedSupplement,
  185 + FinalGrossSalary = x.FinalGrossSalary,
  186 + MonthlyTrainingSubsidy = x.MonthlyTrainingSubsidy,
  187 + MonthlyTransportSubsidy = x.MonthlyTransportSubsidy,
  188 + LastMonthTrainingSubsidy = x.LastMonthTrainingSubsidy,
  189 + LastMonthTransportSubsidy = x.LastMonthTransportSubsidy,
  190 + TotalSubsidy = x.TotalSubsidy,
  191 + MissingCard = x.MissingCard,
  192 + LateArrival = x.LateArrival,
  193 + LeaveDeduction = x.LeaveDeduction,
  194 + SocialInsuranceDeduction = x.SocialInsuranceDeduction,
  195 + RewardDeduction = x.RewardDeduction,
  196 + AccommodationDeduction = x.AccommodationDeduction,
  197 + StudyPeriodDeduction = x.StudyPeriodDeduction,
  198 + WorkClothesDeduction = x.WorkClothesDeduction,
  199 + TotalDeduction = x.TotalDeduction,
  200 + Bonus = x.Bonus,
  201 + ReturnPhoneDeposit = x.ReturnPhoneDeposit,
  202 + ReturnAccommodationDeposit = x.ReturnAccommodationDeposit,
  203 + ActualSalary = x.ActualSalary,
  204 + MonthlyPaymentStatus = x.MonthlyPaymentStatus,
  205 + PaidAmount = x.PaidAmount,
  206 + PendingAmount = x.PendingAmount,
  207 + LastMonthSupplement = x.LastMonthSupplement,
  208 + MonthlyTotalPayment = x.MonthlyTotalPayment,
  209 + IsLocked = x.IsLocked,
  210 + IsTerminated = x.IsTerminated,
  211 + UpdateTime = x.UpdateTime,
  212 + StoreType = x.StoreType,
  213 + StoreCategory = x.StoreCategory,
  214 + IsNewStore = x.IsNewStore,
  215 + NewStoreProtectionStage = x.NewStoreProtectionStage
  216 + })
  217 + .FirstAsync();
  218 + if (salary == null)
  219 + throw NCCException.Oh($"未找到员工{input.EmployeeId}在{input.Year}年{input.Month}月的工资记录");
  220 + return salary;
  221 + }
  222 +
  223 + /// <summary>
146 224 /// 计算大项目部老师工资
147 225 /// </summary>
148 226 /// <param name="year">年份</param>
... ... @@ -418,12 +496,41 @@ namespace NCC.Extend
418 496 // 5. 保存数据
419 497 if (teacherStats.Any())
420 498 {
421   - // 先删除当月旧数据 (防止重复)
422   - await _db.Deleteable<LqMajorProjectTeacherSalaryStatisticsEntity>()
423   - .Where(x => x.StatisticsMonth == monthStr)
424   - .ExecuteCommandAsync();
425   -
426   - await _db.Insertable(teacherStats.Values.ToList()).ExecuteCommandAsync();
  499 + var existingRecords = await _db.Queryable<LqMajorProjectTeacherSalaryStatisticsEntity>()
  500 + .Where(x => x.StatisticsMonth == monthStr).ToListAsync();
  501 + var existingDict = existingRecords.Where(x => !string.IsNullOrEmpty(x.EmployeeId))
  502 + .GroupBy(x => x.EmployeeId).ToDictionary(g => g.Key, g => g.First());
  503 + var recordsToInsert = new List<LqMajorProjectTeacherSalaryStatisticsEntity>();
  504 + var recordsToUpdate = new List<LqMajorProjectTeacherSalaryStatisticsEntity>();
  505 + var skippedCount = 0;
  506 + foreach (var salary in teacherStats.Values)
  507 + {
  508 + if (existingDict.ContainsKey(salary.EmployeeId))
  509 + {
  510 + var existing = existingDict[salary.EmployeeId];
  511 + if (existing.IsLocked == 1 || existing.EmployeeConfirmStatus == 1) { skippedCount++; continue; }
  512 + salary.Id = existing.Id;
  513 + salary.EmployeeConfirmStatus = existing.EmployeeConfirmStatus;
  514 + salary.EmployeeConfirmTime = existing.EmployeeConfirmTime;
  515 + salary.EmployeeConfirmRemark = existing.EmployeeConfirmRemark;
  516 + salary.IsLocked = existing.IsLocked;
  517 + salary.CreateTime = existing.CreateTime;
  518 + salary.CreateUser = existing.CreateUser;
  519 + recordsToUpdate.Add(salary);
  520 + }
  521 + else
  522 + {
  523 + salary.Id = YitIdHelper.NextId().ToString();
  524 + salary.EmployeeConfirmStatus = 0;
  525 + salary.IsLocked = 0;
  526 + salary.CreateTime = DateTime.Now;
  527 + salary.CreateUser = "";
  528 + recordsToInsert.Add(salary);
  529 + }
  530 + }
  531 + if (recordsToInsert.Any()) await _db.Insertable(recordsToInsert).ExecuteCommandAsync();
  532 + if (recordsToUpdate.Any()) await _db.Updateable(recordsToUpdate).ExecuteCommandAsync();
  533 + if (skippedCount > 0) _logger.LogWarning($"计算工资时跳过了 {skippedCount} 条已锁定或已确认的记录(月份:{monthStr})");
427 534 }
428 535 }
429 536  
... ... @@ -450,6 +557,445 @@ namespace NCC.Extend
450 557 return (2.5m, totalPerformance * 0.025m);
451 558 }
452 559 }
  560 +
  561 + #region 员工工资确认
  562 +
  563 + /// <summary>
  564 + /// 员工确认工资条
  565 + /// </summary>
  566 + /// <remarks>
  567 + /// 员工确认自己的工资条,确认后工资数据不可再修改
  568 + ///
  569 + /// 示例请求:
  570 + /// <code>
  571 + /// {
  572 + /// "id": "工资记录ID",
  573 + /// "employeeId": "员工ID",
  574 + /// "remark": "确认备注(可选)"
  575 + /// }
  576 + /// </code>
  577 + ///
  578 + /// 参数说明:
  579 + /// - id: 工资记录ID(必填)
  580 + /// - employeeId: 员工ID(必填)
  581 + /// - remark: 确认备注(可选)
  582 + ///
  583 + /// 注意事项:
  584 + /// - 只能确认自己的工资条
  585 + /// - 只能确认已锁定的工资条(IsLocked = 1)
  586 + /// - 已确认的工资条不能重复确认
  587 + /// </remarks>
  588 + /// <param name="input">确认参数</param>
  589 + /// <returns>操作结果</returns>
  590 + /// <response code="200">确认成功</response>
  591 + /// <response code="400">参数错误或记录不存在</response>
  592 + [HttpPost("confirm")]
  593 + public async Task<string> ConfirmSalary([FromBody] SalaryConfirmInput input)
  594 + {
  595 + try
  596 + {
  597 + if (string.IsNullOrWhiteSpace(input.Id) || string.IsNullOrWhiteSpace(input.EmployeeId))
  598 + throw NCCException.Oh("工资记录ID和员工ID不能为空");
  599 + var salary = await _db.Queryable<LqMajorProjectTeacherSalaryStatisticsEntity>()
  600 + .Where(s => s.Id == input.Id && s.EmployeeId == input.EmployeeId).FirstAsync();
  601 + if (salary == null) throw NCCException.Oh("工资记录不存在或不属于该员工");
  602 + if (salary.EmployeeConfirmStatus == 1) throw NCCException.Oh("该工资条已确认,不能重复确认");
  603 + if (salary.IsLocked != 1) throw NCCException.Oh("该工资条尚未锁定,请等待管理员锁定后再确认");
  604 + salary.EmployeeConfirmStatus = 1;
  605 + salary.EmployeeConfirmTime = DateTime.Now;
  606 + salary.EmployeeConfirmRemark = input.Remark;
  607 + salary.UpdateTime = DateTime.Now;
  608 + await _db.Updateable(salary).ExecuteCommandAsync();
  609 + return "确认成功";
  610 + }
  611 + catch (Exception ex)
  612 + {
  613 + throw NCCException.Oh($"确认工资条失败: {ex.Message}");
  614 + }
  615 + }
  616 +
  617 + #endregion
  618 +
  619 + #region 工资锁定/解锁
  620 +
  621 + /// <summary>
  622 + /// 批量锁定/解锁工资条
  623 + /// </summary>
  624 + [HttpPost("lock")]
  625 + public async Task<string> LockSalary([FromBody] SalaryLockInput input)
  626 + {
  627 + try
  628 + {
  629 + if (input == null || input.Ids == null || !input.Ids.Any())
  630 + throw NCCException.Oh("工资记录ID列表不能为空");
  631 + var salaries = await _db.Queryable<LqMajorProjectTeacherSalaryStatisticsEntity>()
  632 + .Where(s => input.Ids.Contains(s.Id))
  633 + .ToListAsync();
  634 + if (!salaries.Any())
  635 + throw NCCException.Oh("未找到指定的工资记录");
  636 + var lockedCount = 0;
  637 + var unlockedCount = 0;
  638 + var skippedCount = 0;
  639 + foreach (var salary in salaries)
  640 + {
  641 + if (salary.EmployeeConfirmStatus == 1 && !input.IsLocked)
  642 + {
  643 + skippedCount++;
  644 + continue;
  645 + }
  646 + salary.IsLocked = input.IsLocked ? 1 : 0;
  647 + salary.UpdateTime = DateTime.Now;
  648 + if (input.IsLocked) lockedCount++; else unlockedCount++;
  649 + }
  650 + await _db.Updateable(salaries).ExecuteCommandAsync();
  651 + var action = input.IsLocked ? "锁定" : "解锁";
  652 + var count = input.IsLocked ? lockedCount : unlockedCount;
  653 + var message = $"{action}成功:{count}条";
  654 + if (skippedCount > 0)
  655 + message += $",跳过{skippedCount}条(已确认的记录不能解锁)";
  656 + return message;
  657 + }
  658 + catch (Exception ex)
  659 + {
  660 + throw NCCException.Oh($"锁定/解锁工资条失败: {ex.Message}");
  661 + }
  662 + }
  663 +
  664 + /// <summary>
  665 + /// 批量锁定当月所有工资
  666 + /// </summary>
  667 + /// <param name="input">批量锁定输入参数</param>
  668 + /// <returns>锁定结果</returns>
  669 + [HttpPost("lock-by-month")]
  670 + public async Task<dynamic> LockSalaryByMonth([FromBody] SalaryLockByMonthInput input)
  671 + {
  672 + try
  673 + {
  674 + if (input == null)
  675 + throw NCCException.Oh("参数不能为空");
  676 +
  677 + if (input.Year <= 0 || input.Month <= 0 || input.Month > 12)
  678 + throw NCCException.Oh("年份和月份参数不正确");
  679 +
  680 + var monthStr = $"{input.Year}{input.Month:D2}";
  681 +
  682 + var salaries = await _db.Queryable<LqMajorProjectTeacherSalaryStatisticsEntity>()
  683 + .Where(s => s.StatisticsMonth == monthStr)
  684 + .ToListAsync();
  685 +
  686 + if (!salaries.Any())
  687 + throw NCCException.Oh($"未找到{input.Year}年{input.Month}月的工资记录");
  688 +
  689 + var lockedCount = 0;
  690 + var unlockedCount = 0;
  691 + var skippedCount = 0;
  692 + var alreadyLockedCount = 0;
  693 +
  694 + foreach (var salary in salaries)
  695 + {
  696 + if (salary.EmployeeConfirmStatus == 1 && !input.IsLocked)
  697 + {
  698 + skippedCount++;
  699 + continue;
  700 + }
  701 +
  702 + if (salary.IsLocked == 1 && input.IsLocked)
  703 + {
  704 + alreadyLockedCount++;
  705 + continue;
  706 + }
  707 +
  708 + if (salary.IsLocked == 0 && !input.IsLocked)
  709 + {
  710 + alreadyLockedCount++;
  711 + continue;
  712 + }
  713 +
  714 + salary.IsLocked = input.IsLocked ? 1 : 0;
  715 + salary.UpdateTime = DateTime.Now;
  716 +
  717 + if (input.IsLocked)
  718 + lockedCount++;
  719 + else
  720 + unlockedCount++;
  721 + }
  722 +
  723 + if (lockedCount > 0 || unlockedCount > 0)
  724 + {
  725 + var salariesToUpdate = salaries.Where(s =>
  726 + (input.IsLocked && s.IsLocked == 0) ||
  727 + (!input.IsLocked && s.IsLocked == 1 && s.EmployeeConfirmStatus != 1)
  728 + ).ToList();
  729 +
  730 + if (salariesToUpdate.Any())
  731 + {
  732 + await _db.Updateable(salariesToUpdate)
  733 + .UpdateColumns(s => new { s.IsLocked, s.UpdateTime })
  734 + .ExecuteCommandAsync();
  735 + }
  736 + }
  737 +
  738 + var action = input.IsLocked ? "锁定" : "解锁";
  739 + var count = input.IsLocked ? lockedCount : unlockedCount;
  740 + var message = $"{action}成功:{count}条";
  741 +
  742 + if (alreadyLockedCount > 0)
  743 + message += $",跳过{alreadyLockedCount}条(已是{action}状态)";
  744 +
  745 + if (skippedCount > 0)
  746 + message += $",跳过{skippedCount}条(已确认的记录不能解锁)";
  747 +
  748 + return new
  749 + {
  750 + success = true,
  751 + message = message,
  752 + total = salaries.Count,
  753 + locked = lockedCount,
  754 + unlocked = unlockedCount,
  755 + skipped = skippedCount,
  756 + alreadyLocked = alreadyLockedCount
  757 + };
  758 + }
  759 + catch (Exception ex)
  760 + {
  761 + _logger.LogError(ex, "批量锁定当月工资失败");
  762 + var action = input?.IsLocked == true ? "锁定" : "解锁";
  763 + throw NCCException.Oh($"批量{action}当月工资失败: {ex.Message}");
  764 + }
  765 + }
  766 +
  767 + #endregion
  768 +
  769 + #region 导入工资
  770 +
  771 + /// <summary>
  772 + /// 从Excel导入大项目老师工资数据
  773 + /// </summary>
  774 + /// <param name="file">Excel文件</param>
  775 + /// <returns>导入结果</returns>
  776 + [HttpPost("import")]
  777 + public async Task<dynamic> ImportSalaryFromExcel(IFormFile file)
  778 + {
  779 + try
  780 + {
  781 + if (file == null || file.Length == 0)
  782 + throw NCCException.Oh("请选择要上传的Excel文件");
  783 +
  784 + var allowedExtensions = new[] { ".xlsx", ".xls" };
  785 + var fileExtension = Path.GetExtension(file.FileName).ToLowerInvariant();
  786 + if (!allowedExtensions.Contains(fileExtension))
  787 + throw NCCException.Oh("只支持.xlsx和.xls格式的Excel文件");
  788 +
  789 + var recordsToInsert = new List<LqMajorProjectTeacherSalaryStatisticsEntity>();
  790 + var recordsToUpdate = new List<LqMajorProjectTeacherSalaryStatisticsEntity>();
  791 + var errorMessages = new List<string>();
  792 + var successCount = 0;
  793 + var failCount = 0;
  794 + var skippedCount = 0;
  795 +
  796 + var tempFilePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString() + Path.GetExtension(file.FileName));
  797 + try
  798 + {
  799 + using (var stream = new FileStream(tempFilePath, FileMode.Create))
  800 + {
  801 + await file.CopyToAsync(stream);
  802 + }
  803 +
  804 + var dataTable = ExcelImportHelper.ToDataTable(tempFilePath, 0, 0);
  805 + if (dataTable.Rows.Count == 0)
  806 + throw NCCException.Oh("Excel文件中没有数据行");
  807 +
  808 + Func<string, decimal> ParseDecimal = (str) =>
  809 + {
  810 + if (string.IsNullOrWhiteSpace(str)) return 0;
  811 + var cleaned = str.Trim().Replace(",", "").Replace(",", "").Replace("¥", "").Replace("$", "").Replace("元", "").Replace("%", "").Replace(" ", "");
  812 + return decimal.TryParse(cleaned, out decimal result) ? result : 0;
  813 + };
  814 +
  815 + Func<string, int> ParseInt = (str) =>
  816 + {
  817 + if (string.IsNullOrWhiteSpace(str)) return 0;
  818 + var cleaned = str.Trim().Replace(",", "").Replace(",", "").Replace(" ", "");
  819 + return int.TryParse(cleaned, out int result) ? result : 0;
  820 + };
  821 +
  822 + for (int i = 1; i < dataTable.Rows.Count; i++)
  823 + {
  824 + try
  825 + {
  826 + var row = dataTable.Rows[i];
  827 + Func<int, string> GetColumnValue = (colIndex) => colIndex < row.ItemArray.Length && row[colIndex] != null ? row[colIndex].ToString().Trim() : "";
  828 +
  829 + var firstColumnValue = GetColumnValue(0);
  830 + bool isOldFormat = !string.IsNullOrWhiteSpace(firstColumnValue) && (firstColumnValue == "门店名称" || (!long.TryParse(firstColumnValue, out _) && firstColumnValue.Length > 20));
  831 +
  832 + int storeNameIndex = isOldFormat ? 0 : 1;
  833 + int employeeNameIndex = isOldFormat ? 1 : 2;
  834 + int offset = isOldFormat ? 0 : 1;
  835 +
  836 + var id = isOldFormat ? "" : GetColumnValue(0);
  837 + var employeeName = GetColumnValue(employeeNameIndex);
  838 + var storeName = GetColumnValue(storeNameIndex);
  839 +
  840 + if (string.IsNullOrWhiteSpace(id) && string.IsNullOrWhiteSpace(employeeName))
  841 + continue;
  842 +
  843 + if (string.IsNullOrWhiteSpace(id) && !string.IsNullOrWhiteSpace(employeeName))
  844 + {
  845 + var matchedRecord = await _db.Queryable<LqMajorProjectTeacherSalaryStatisticsEntity>()
  846 + .Where(x => x.EmployeeName == employeeName)
  847 + .WhereIF(!string.IsNullOrWhiteSpace(storeName), x => x.StoreName == storeName)
  848 + .OrderBy(x => x.CreateTime, OrderByType.Desc)
  849 + .FirstAsync();
  850 + if (matchedRecord != null) id = matchedRecord.Id;
  851 + }
  852 +
  853 + if (string.IsNullOrWhiteSpace(employeeName))
  854 + {
  855 + errorMessages.Add($"第{i + 1}行:员工姓名不能为空");
  856 + failCount++;
  857 + continue;
  858 + }
  859 +
  860 + LqMajorProjectTeacherSalaryStatisticsEntity existing = null;
  861 + if (!string.IsNullOrWhiteSpace(id))
  862 + {
  863 + existing = await _db.Queryable<LqMajorProjectTeacherSalaryStatisticsEntity>()
  864 + .Where(x => x.Id == id).FirstAsync();
  865 +
  866 + if (existing != null && (existing.IsLocked == 1 || existing.EmployeeConfirmStatus == 1))
  867 + {
  868 + skippedCount++;
  869 + failCount++;
  870 + continue;
  871 + }
  872 + }
  873 +
  874 + var entity = existing ?? new LqMajorProjectTeacherSalaryStatisticsEntity
  875 + {
  876 + Id = string.IsNullOrWhiteSpace(id) ? YitIdHelper.NextId().ToString() : id,
  877 + EmployeeConfirmStatus = 0,
  878 + IsLocked = 0,
  879 + CreateTime = DateTime.Now,
  880 + CreateUser = ""
  881 + };
  882 +
  883 + // Excel字段映射(大项目老师工资49列:门店名称,员工姓名,岗位,员工账号,开单业绩,消耗业绩,退卡业绩,总业绩,业绩提成比例,业绩提成金额,提成合计,手工费,底薪...)
  884 + entity.StoreName = storeName;
  885 + entity.EmployeeName = employeeName;
  886 + entity.Position = GetColumnValue(2 + offset);
  887 + entity.EmployeeAccount = GetColumnValue(3 + offset);
  888 + entity.OrderAchievement = ParseDecimal(GetColumnValue(4 + offset));
  889 + entity.ConsumeAchievement = ParseDecimal(GetColumnValue(5 + offset));
  890 + entity.RefundAchievement = ParseDecimal(GetColumnValue(6 + offset));
  891 + entity.TotalPerformance = ParseDecimal(GetColumnValue(7 + offset));
  892 + entity.PerformanceCommissionRate = ParseDecimal(GetColumnValue(8 + offset));
  893 + entity.PerformanceCommissionAmount = ParseDecimal(GetColumnValue(9 + offset));
  894 + entity.TotalCommission = ParseDecimal(GetColumnValue(10 + offset));
  895 + entity.HandworkFee = ParseDecimal(GetColumnValue(11 + offset));
  896 + entity.BaseSalary = ParseDecimal(GetColumnValue(12 + offset));
  897 + entity.WorkingDays = ParseDecimal(GetColumnValue(13 + offset));
  898 + entity.LeaveDays = ParseDecimal(GetColumnValue(14 + offset));
  899 + entity.TransportationAllowance = ParseDecimal(GetColumnValue(15 + offset));
  900 + entity.LessRest = ParseDecimal(GetColumnValue(16 + offset));
  901 + entity.FullAttendance = ParseDecimal(GetColumnValue(17 + offset));
  902 + entity.CalculatedGrossSalary = ParseDecimal(GetColumnValue(18 + offset));
  903 + entity.GuaranteedSalary = ParseDecimal(GetColumnValue(19 + offset));
  904 + entity.GuaranteedLeaveDeduction = ParseDecimal(GetColumnValue(20 + offset));
  905 + entity.GuaranteedBaseSalary = ParseDecimal(GetColumnValue(21 + offset));
  906 + entity.GuaranteedSupplement = ParseDecimal(GetColumnValue(22 + offset));
  907 + entity.FinalGrossSalary = ParseDecimal(GetColumnValue(23 + offset));
  908 + entity.MonthlyTrainingSubsidy = ParseDecimal(GetColumnValue(24 + offset));
  909 + entity.MonthlyTransportSubsidy = ParseDecimal(GetColumnValue(25 + offset));
  910 + entity.LastMonthTrainingSubsidy = ParseDecimal(GetColumnValue(26 + offset));
  911 + entity.LastMonthTransportSubsidy = ParseDecimal(GetColumnValue(27 + offset));
  912 + entity.TotalSubsidy = ParseDecimal(GetColumnValue(28 + offset));
  913 + entity.MissingCard = ParseDecimal(GetColumnValue(29 + offset));
  914 + entity.LateArrival = ParseDecimal(GetColumnValue(30 + offset));
  915 + entity.LeaveDeduction = ParseDecimal(GetColumnValue(31 + offset));
  916 + entity.SocialInsuranceDeduction = ParseDecimal(GetColumnValue(32 + offset));
  917 + entity.RewardDeduction = ParseDecimal(GetColumnValue(33 + offset));
  918 + entity.AccommodationDeduction = ParseDecimal(GetColumnValue(34 + offset));
  919 + entity.StudyPeriodDeduction = ParseDecimal(GetColumnValue(35 + offset));
  920 + entity.WorkClothesDeduction = ParseDecimal(GetColumnValue(36 + offset));
  921 + entity.TotalDeduction = ParseDecimal(GetColumnValue(37 + offset));
  922 + entity.Bonus = ParseDecimal(GetColumnValue(38 + offset));
  923 + entity.ReturnPhoneDeposit = ParseDecimal(GetColumnValue(39 + offset));
  924 + entity.ReturnAccommodationDeposit = ParseDecimal(GetColumnValue(40 + offset));
  925 + entity.LastMonthSupplement = ParseDecimal(GetColumnValue(41 + offset));
  926 + entity.ActualSalary = ParseDecimal(GetColumnValue(42 + offset));
  927 + entity.MonthlyPaymentStatus = GetColumnValue(43 + offset);
  928 + entity.PaidAmount = ParseDecimal(GetColumnValue(44 + offset));
  929 + entity.PendingAmount = ParseDecimal(GetColumnValue(45 + offset));
  930 + entity.MonthlyTotalPayment = ParseDecimal(GetColumnValue(46 + offset));
  931 + entity.IsNewStore = GetColumnValue(47 + offset) == "是" ? "是" : "否";
  932 + entity.NewStoreProtectionStage = ParseInt(GetColumnValue(48 + offset));
  933 +
  934 + if (existing != null)
  935 + {
  936 + entity.StoreId = existing.StoreId;
  937 + entity.EmployeeId = existing.EmployeeId;
  938 + entity.StatisticsMonth = existing.StatisticsMonth;
  939 + }
  940 + else
  941 + {
  942 + if (!string.IsNullOrWhiteSpace(employeeName))
  943 + {
  944 + var user = await _db.Queryable<UserEntity>()
  945 + .Where(u => u.RealName == employeeName).FirstAsync();
  946 + if (user != null)
  947 + {
  948 + entity.EmployeeId = user.Id;
  949 + if (!string.IsNullOrEmpty(user.Mdid) && string.IsNullOrWhiteSpace(storeName))
  950 + entity.StoreId = user.Mdid;
  951 + }
  952 +
  953 + if (!string.IsNullOrWhiteSpace(storeName) && string.IsNullOrEmpty(entity.StoreId))
  954 + {
  955 + var store = await _db.Queryable<LqMdxxEntity>()
  956 + .Where(s => s.Dm == storeName).FirstAsync();
  957 + if (store != null) entity.StoreId = store.Id;
  958 + }
  959 + }
  960 + }
  961 +
  962 + entity.UpdateTime = DateTime.Now;
  963 + if (existing != null) recordsToUpdate.Add(entity);
  964 + else recordsToInsert.Add(entity);
  965 + successCount++;
  966 + }
  967 + catch (Exception ex)
  968 + {
  969 + errorMessages.Add($"第{i + 1}行数据处理失败: {ex.Message}");
  970 + failCount++;
  971 + }
  972 + }
  973 + }
  974 + finally
  975 + {
  976 + if (File.Exists(tempFilePath)) File.Delete(tempFilePath);
  977 + }
  978 +
  979 + if (recordsToInsert.Any()) await _db.Insertable(recordsToInsert).ExecuteCommandAsync();
  980 + if (recordsToUpdate.Any()) await _db.Updateable(recordsToUpdate).ExecuteCommandAsync();
  981 +
  982 + return new
  983 + {
  984 + success = true,
  985 + message = $"导入完成:成功 {successCount} 条,失败 {failCount} 条,跳过 {skippedCount} 条(已锁定或已确认)",
  986 + successCount,
  987 + failCount,
  988 + skippedCount,
  989 + errors = errorMessages
  990 + };
  991 + }
  992 + catch (Exception ex)
  993 + {
  994 + throw NCCException.Oh($"导入大项目老师工资数据失败: {ex.Message}");
  995 + }
  996 + }
  997 +
  998 + #endregion
453 999 }
454 1000 }
455 1001  
... ...
netcore/src/Modularity/Extend/NCC.Extend/LqSalaryService.cs
1 1 using Microsoft.AspNetCore.Authorization;
  2 +using Microsoft.AspNetCore.Http;
2 3 using Microsoft.AspNetCore.Mvc;
  4 +using Microsoft.Extensions.Logging;
3 5 using NCC.Common.Enum;
4 6 using NCC.Common.Filter;
5 7 using NCC.Common.Helper;
6 8 using NCC.Dependency;
7 9 using NCC.DynamicApiController;
8 10 using NCC.Extend.Entitys.Dto.LqSalary;
  11 +using NCC.FriendlyException;
  12 +using System.IO;
9 13 using Yitter.IdGenerator;
10 14 using NCC.Extend.Entitys.lq_attendance_summary;
11 15 using NCC.Extend.Entitys.lq_jinsanjiao_user;
... ... @@ -38,13 +42,15 @@ namespace NCC.Extend
38 42 public class LqSalaryService : IDynamicApiController, ITransient
39 43 {
40 44 private readonly ISqlSugarClient _db;
  45 + private readonly ILogger<LqSalaryService> _logger;
41 46  
42 47 /// <summary>
43 48 /// 初始化一个<see cref="LqSalaryService"/>类型的新实例
44 49 /// </summary>
45   - public LqSalaryService(ISqlSugarClient db)
  50 + public LqSalaryService(ISqlSugarClient db, ILogger<LqSalaryService> logger)
46 51 {
47 52 _db = db;
  53 + _logger = logger;
48 54 }
49 55  
50 56 /// <summary>
... ... @@ -57,17 +63,7 @@ namespace NCC.Extend
57 63 {
58 64 var monthStr = $"{input.Year}{input.Month:D2}";
59 65  
60   - // 1. 检查当月是否已生成工资数据
61   - var exists = await _db.Queryable<LqSalaryStatisticsEntity>()
62   - .AnyAsync(x => x.StatisticsMonth == monthStr);
63   -
64   - // 2. 如果没有数据,则进行计算
65   - if (!exists)
66   - {
67   - await CalculateHealthCoachSalary(input.Year, input.Month);
68   - }
69   -
70   - // 3. 查询数据
  66 + // 查询数据
71 67 var query = _db.Queryable<LqSalaryStatisticsEntity>()
72 68 .Where(x => x.StatisticsMonth == monthStr);
73 69  
... ... @@ -178,6 +174,146 @@ namespace NCC.Extend
178 174 }
179 175  
180 176 /// <summary>
  177 + /// 通过月份和员工ID查询工资
  178 + /// </summary>
  179 + /// <remarks>
  180 + /// 根据年份、月份和员工ID查询对应员工的工资记录
  181 + ///
  182 + /// 示例请求:
  183 + /// ```
  184 + /// GET /api/Extend/lqsalary/query-by-employee?Year=2026&amp;Month=1&amp;EmployeeId=员工ID
  185 + /// ```
  186 + ///
  187 + /// 参数说明:
  188 + /// - Year: 年份(必填)
  189 + /// - Month: 月份(必填)
  190 + /// - EmployeeId: 员工ID(必填)
  191 + /// </remarks>
  192 + /// <param name="input">查询参数</param>
  193 + /// <returns>工资记录详情</returns>
  194 + /// <response code="200">查询成功,返回工资记录</response>
  195 + /// <response code="404">未找到对应的工资记录</response>
  196 + [HttpGet("query-by-employee")]
  197 + public async Task<HealthCoachSalaryOutput> GetSalaryByEmployee([FromQuery] SalaryQueryByEmployeeInput input)
  198 + {
  199 + if (input.Year <= 0 || input.Month <= 0 || input.Month > 12)
  200 + {
  201 + throw NCCException.Oh("年份和月份参数不正确");
  202 + }
  203 +
  204 + if (string.IsNullOrWhiteSpace(input.EmployeeId))
  205 + {
  206 + throw NCCException.Oh("员工ID不能为空");
  207 + }
  208 +
  209 + var monthStr = $"{input.Year}{input.Month:D2}";
  210 +
  211 + var salary = await _db.Queryable<LqSalaryStatisticsEntity>()
  212 + .Where(x => x.StatisticsMonth == monthStr && x.EmployeeId == input.EmployeeId)
  213 + .Select(x => new HealthCoachSalaryOutput
  214 + {
  215 + Id = x.Id,
  216 + StoreId = x.StoreId,
  217 + StoreName = x.StoreName,
  218 + EmployeeId = x.EmployeeId,
  219 + EmployeeName = x.EmployeeName,
  220 + Position = x.Position,
  221 + GoldTriangleId = x.GoldTriangleId,
  222 + GoldTriangleTeam = x.GoldTriangleTeam,
  223 + TotalPerformance = x.TotalPerformance,
  224 + BasePerformance = x.BasePerformance,
  225 + CooperationPerformance = x.CooperationPerformance,
  226 + BaseRewardPerformance = x.BaseRewardPerformance,
  227 + CooperationRewardPerformance = x.CooperationRewardPerformance,
  228 + ActualBasePerformance = x.ActualBasePerformance,
  229 + ActualCooperationPerformance = x.ActualCooperationPerformance,
  230 + RewardPerformance = x.RewardPerformance,
  231 + StoreTotalPerformance = x.StoreTotalPerformance,
  232 + TeamPerformance = x.TeamPerformance,
  233 + Percentage = x.Percentage,
  234 + NewCustomerPerformance = x.NewCustomerPerformance,
  235 + NewCustomerConversionRate = x.NewCustomerConversionRate,
  236 + NewCustomerPoint = x.NewCustomerPoint,
  237 + UpgradeCustomerCount = x.UpgradeCustomerCount,
  238 + UpgradePerformance = x.UpgradePerformance,
  239 + UpgradePoint = x.UpgradePoint,
  240 + NewCustomerCommission = x.NewCustomerPerformanceCommission,
  241 + UpgradeCommission = x.UpgradePerformanceCommission,
  242 + OtherPerformanceAdd = x.OtherPerformanceAdd,
  243 + OtherPerformanceSubtract = x.OtherPerformanceSubtract,
  244 + Consumption = x.Consumption,
  245 + ProjectCount = x.ProjectCount,
  246 + CustomerCount = x.CustomerCount,
  247 + WorkingDays = x.WorkingDays,
  248 + LeaveDays = x.LeaveDays,
  249 + CommissionPoint = x.CommissionPoint,
  250 + BasePerformanceCommission = x.BasePerformanceCommission,
  251 + CooperationPerformanceCommission = x.CooperationPerformanceCommission,
  252 + ConsultantCommission = x.ConsultantCommission,
  253 + StoreTZoneCommission = x.StoreTZoneCommission,
  254 + TotalCommission = x.TotalCommission,
  255 + HealthCoachBaseSalary = x.HealthCoachBaseSalary,
  256 + HandworkFee = x.HandworkFee,
  257 + OutherHandworkFee = x.OutherHandworkFee,
  258 + TransportationAllowance = x.TransportationAllowance,
  259 + LessRest = x.LessRest,
  260 + FullAttendance = x.FullAttendance,
  261 + CalculatedGrossSalary = x.CalculatedGrossSalary,
  262 + GuaranteedSalary = x.GuaranteedSalary,
  263 + GuaranteedLeaveDeduction = x.GuaranteedLeaveDeduction,
  264 + GuaranteedBaseSalary = x.GuaranteedBaseSalary,
  265 + GuaranteedSupplement = x.GuaranteedSupplement,
  266 + FinalGrossSalary = x.FinalGrossSalary,
  267 + MonthlyTrainingSubsidy = x.MonthlyTrainingSubsidy,
  268 + MonthlyTransportSubsidy = x.MonthlyTransportSubsidy,
  269 + LastMonthTrainingSubsidy = x.LastMonthTrainingSubsidy,
  270 + LastMonthTransportSubsidy = x.LastMonthTransportSubsidy,
  271 + TotalSubsidy = x.TotalSubsidy,
  272 + MissingCard = x.MissingCard,
  273 + LateArrival = x.LateArrival,
  274 + LeaveDeduction = x.LeaveDeduction,
  275 + SocialInsuranceDeduction = x.SocialInsuranceDeduction,
  276 + RewardDeduction = x.RewardDeduction,
  277 + AccommodationDeduction = x.AccommodationDeduction,
  278 + StudyPeriodDeduction = x.StudyPeriodDeduction,
  279 + WorkClothesDeduction = x.WorkClothesDeduction,
  280 + TotalDeduction = x.TotalDeduction,
  281 + Bonus = x.Bonus,
  282 + ReturnPhoneDeposit = x.ReturnPhoneDeposit,
  283 + ReturnAccommodationDeposit = x.ReturnAccommodationDeposit,
  284 + ActualSalary = x.ActualSalary,
  285 + MonthlyPaymentStatus = x.MonthlyPaymentStatus,
  286 + PaidAmount = x.PaidAmount,
  287 + PendingAmount = x.PendingAmount,
  288 + LastMonthSupplement = x.LastMonthSupplement,
  289 + MonthlyTotalPayment = x.MonthlyTotalPayment,
  290 + StatisticsMonth = x.StatisticsMonth,
  291 + IsLocked = x.IsLocked,
  292 + CreateTime = x.CreateTime,
  293 + CreateUser = x.CreateUser,
  294 + UpdateTime = x.UpdateTime,
  295 + UpdateUser = x.UpdateUser,
  296 + IsNewStore = x.IsNewStore,
  297 + NewStoreProtectionStage = x.NewStoreProtectionStage,
  298 + StoreType = x.StoreType,
  299 + StoreCategory = x.StoreCategory,
  300 + DailyAverageConsumption = x.DailyAverageConsumption,
  301 + DailyAverageProjectCount = x.DailyAverageProjectCount,
  302 + TeamTotalConsumption = x.TeamTotalConsumption
  303 + })
  304 + .FirstAsync();
  305 +
  306 + if (salary == null)
  307 + {
  308 + throw NCCException.Oh($"未找到员工{input.EmployeeId}在{input.Year}年{input.Month}月的工资记录");
  309 + }
  310 +
  311 + return salary;
  312 + }
  313 +
  314 + #region 计算工资
  315 +
  316 + /// <summary>
181 317 /// 计算健康师工资
182 318 /// </summary>
183 319 /// <param name="year">年份</param>
... ... @@ -710,7 +846,7 @@ namespace NCC.Extend
710 846 // 获取战队人数
711 847 var teamMemberList = employeeStats.Values.Where(x => x.GoldTriangleId == salary.GoldTriangleId).ToList();
712 848 var teamMemberCount = teamMemberList.Count;
713   -
  849 +
714 850 // 单人或者一个人的战队就没有顾问
715 851 if (teamMemberCount > 1)
716 852 {
... ... @@ -769,13 +905,789 @@ namespace NCC.Extend
769 905 // 5. 保存数据
770 906 if (employeeStats.Any())
771 907 {
772   - // 先删除当月旧数据 (防止重复)
773   - await _db.Deleteable<LqSalaryStatisticsEntity>().Where(x => x.StatisticsMonth == monthStr).ExecuteCommandAsync();
774   - await _db.Insertable(employeeStats.Values.ToList()).ExecuteCommandAsync();
  908 + // 查询当月已存在的记录(用于检查是否已锁定或已确认)
  909 + var existingRecords = await _db.Queryable<LqSalaryStatisticsEntity>()
  910 + .Where(x => x.StatisticsMonth == monthStr)
  911 + .ToListAsync();
  912 +
  913 + var existingDict = existingRecords
  914 + .Where(x => !string.IsNullOrEmpty(x.EmployeeId))
  915 + .GroupBy(x => x.EmployeeId)
  916 + .ToDictionary(g => g.Key, g => g.First());
  917 +
  918 + // 分离需要插入的新记录和需要更新的记录,以及需要跳过的记录
  919 + var recordsToInsert = new List<LqSalaryStatisticsEntity>();
  920 + var recordsToUpdate = new List<LqSalaryStatisticsEntity>();
  921 + var skippedCount = 0;
  922 +
  923 + foreach (var salary in employeeStats.Values)
  924 + {
  925 + if (existingDict.ContainsKey(salary.EmployeeId))
  926 + {
  927 + var existing = existingDict[salary.EmployeeId];
  928 + // 如果已锁定或已确认,则跳过,不更新
  929 + if (existing.IsLocked == 1 || existing.EmployeeConfirmStatus == 1)
  930 + {
  931 + skippedCount++;
  932 + continue;
  933 + }
  934 +
  935 + // 更新现有记录(保留确认状态相关字段)
  936 + salary.Id = existing.Id;
  937 + salary.EmployeeConfirmStatus = existing.EmployeeConfirmStatus;
  938 + salary.EmployeeConfirmTime = existing.EmployeeConfirmTime;
  939 + salary.EmployeeConfirmRemark = existing.EmployeeConfirmRemark;
  940 + salary.IsLocked = existing.IsLocked; // 保留锁定状态
  941 + salary.CreateTime = existing.CreateTime;
  942 + salary.CreateUser = existing.CreateUser;
  943 + recordsToUpdate.Add(salary);
  944 + }
  945 + else
  946 + {
  947 + // 新记录
  948 + salary.Id = YitIdHelper.NextId().ToString();
  949 + salary.EmployeeConfirmStatus = 0;
  950 + salary.IsLocked = 0;
  951 + salary.CreateTime = DateTime.Now;
  952 + salary.CreateUser = ""; // 新记录,创建人为空或系统
  953 + recordsToInsert.Add(salary);
  954 + }
  955 + }
  956 +
  957 + // 批量插入新记录
  958 + if (recordsToInsert.Any())
  959 + {
  960 + await _db.Insertable(recordsToInsert).ExecuteCommandAsync();
  961 + }
  962 +
  963 + // 批量更新现有记录
  964 + if (recordsToUpdate.Any())
  965 + {
  966 + await _db.Updateable(recordsToUpdate).ExecuteCommandAsync();
  967 + }
  968 +
  969 + if (skippedCount > 0)
  970 + {
  971 + _logger.LogWarning($"计算工资时跳过了 {skippedCount} 条已锁定或已确认的记录(月份:{monthStr})");
  972 + }
  973 + }
  974 + }
  975 +
  976 + #endregion
  977 +
  978 + #region 员工工资确认
  979 +
  980 + /// <summary>
  981 + /// 员工确认工资条
  982 + /// </summary>
  983 + /// <remarks>
  984 + /// 员工确认自己的工资条,确认后工资数据不可再修改
  985 + ///
  986 + /// 示例请求:
  987 + /// <code>
  988 + /// {
  989 + /// "id": "工资记录ID",
  990 + /// "employeeId": "员工ID",
  991 + /// "remark": "确认备注(可选)"
  992 + /// }
  993 + /// </code>
  994 + ///
  995 + /// 参数说明:
  996 + /// - id: 工资记录ID(必填)
  997 + /// - employeeId: 员工ID(必填)
  998 + /// - remark: 确认备注(可选)
  999 + ///
  1000 + /// 注意事项:
  1001 + /// - 只能确认自己的工资条
  1002 + /// - 只能确认已锁定的工资条(IsLocked = 1)
  1003 + /// - 已确认的工资条不能重复确认
  1004 + /// </remarks>
  1005 + /// <param name="input">确认参数</param>
  1006 + /// <returns>操作结果</returns>
  1007 + /// <response code="200">确认成功</response>
  1008 + /// <response code="400">参数错误或记录不存在</response>
  1009 + [HttpPost("confirm")]
  1010 + public async Task<string> ConfirmSalary([FromBody] SalaryConfirmInput input)
  1011 + {
  1012 + try
  1013 + {
  1014 + if (string.IsNullOrWhiteSpace(input.Id))
  1015 + {
  1016 + throw NCCException.Oh("工资记录ID不能为空");
  1017 + }
  1018 +
  1019 + if (string.IsNullOrWhiteSpace(input.EmployeeId))
  1020 + {
  1021 + throw NCCException.Oh("员工ID不能为空");
  1022 + }
  1023 +
  1024 + // 查询工资记录
  1025 + var salary = await _db.Queryable<LqSalaryStatisticsEntity>()
  1026 + .Where(s => s.Id == input.Id && s.EmployeeId == input.EmployeeId)
  1027 + .FirstAsync();
  1028 +
  1029 + if (salary == null)
  1030 + {
  1031 + throw NCCException.Oh("工资记录不存在或不属于该员工");
  1032 + }
  1033 +
  1034 + // 检查是否已确认
  1035 + if (salary.EmployeeConfirmStatus == 1)
  1036 + {
  1037 + throw NCCException.Oh("该工资条已确认,不能重复确认");
  1038 + }
  1039 +
  1040 + // 检查是否已锁定(员工只能确认已锁定的工资条)
  1041 + if (salary.IsLocked != 1)
  1042 + {
  1043 + throw NCCException.Oh("该工资条尚未锁定,请等待管理员锁定后再确认");
  1044 + }
  1045 +
  1046 + // 更新确认状态
  1047 + salary.EmployeeConfirmStatus = 1;
  1048 + salary.EmployeeConfirmTime = DateTime.Now;
  1049 + salary.EmployeeConfirmRemark = input.Remark;
  1050 + // 注意:IsLocked 保持为 1(因为本来就是管理员锁定的)
  1051 + salary.UpdateTime = DateTime.Now;
  1052 +
  1053 + await _db.Updateable(salary).ExecuteCommandAsync();
  1054 +
  1055 + return "确认成功";
  1056 + }
  1057 + catch (Exception ex)
  1058 + {
  1059 + throw NCCException.Oh($"确认工资条失败: {ex.Message}");
  1060 + }
  1061 + }
  1062 +
  1063 + #endregion
  1064 +
  1065 + #region 工资锁定/解锁
  1066 +
  1067 + /// <summary>
  1068 + /// 批量锁定/解锁工资条
  1069 + /// </summary>
  1070 + /// <remarks>
  1071 + /// 管理员批量锁定或解锁工资条
  1072 + ///
  1073 + /// 示例请求:
  1074 + /// ```json
  1075 + /// {
  1076 + /// "ids": ["工资记录ID1", "工资记录ID2"],
  1077 + /// "isLocked": true
  1078 + /// }
  1079 + /// ```
  1080 + ///
  1081 + /// 参数说明:
  1082 + /// - ids: 工资记录ID列表(必填)
  1083 + /// - isLocked: 是否锁定(true=锁定,false=解锁)
  1084 + /// </remarks>
  1085 + /// <param name="input">锁定参数</param>
  1086 + /// <returns>操作结果</returns>
  1087 + /// <response code="200">锁定/解锁成功</response>
  1088 + /// <response code="400">参数错误或记录不存在</response>
  1089 + [HttpPost("lock")]
  1090 + public async Task<string> LockSalary([FromBody] SalaryLockInput input)
  1091 + {
  1092 + try
  1093 + {
  1094 + if (input == null || input.Ids == null || !input.Ids.Any())
  1095 + {
  1096 + throw NCCException.Oh("工资记录ID列表不能为空");
  1097 + }
  1098 +
  1099 + // 查询工资记录
  1100 + var salaries = await _db.Queryable<LqSalaryStatisticsEntity>()
  1101 + .Where(s => input.Ids.Contains(s.Id))
  1102 + .ToListAsync();
  1103 +
  1104 + if (!salaries.Any())
  1105 + {
  1106 + throw NCCException.Oh("未找到指定的工资记录");
  1107 + }
  1108 +
  1109 + var lockedCount = 0;
  1110 + var unlockedCount = 0;
  1111 + var skippedCount = 0;
  1112 +
  1113 + foreach (var salary in salaries)
  1114 + {
  1115 + // 如果已确认,不能解锁
  1116 + if (salary.EmployeeConfirmStatus == 1 && !input.IsLocked)
  1117 + {
  1118 + skippedCount++;
  1119 + continue;
  1120 + }
  1121 +
  1122 + salary.IsLocked = input.IsLocked ? 1 : 0;
  1123 + salary.UpdateTime = DateTime.Now;
  1124 +
  1125 + if (input.IsLocked)
  1126 + {
  1127 + lockedCount++;
  1128 + }
  1129 + else
  1130 + {
  1131 + unlockedCount++;
  1132 + }
  1133 + }
  1134 +
  1135 + // 批量更新
  1136 + await _db.Updateable(salaries).ExecuteCommandAsync();
  1137 +
  1138 + var action = input.IsLocked ? "锁定" : "解锁";
  1139 + var count = input.IsLocked ? lockedCount : unlockedCount;
  1140 + var message = $"{action}成功:{count}条";
  1141 + if (skippedCount > 0)
  1142 + {
  1143 + message += $",跳过{skippedCount}条(已确认的记录不能解锁)";
  1144 + }
  1145 +
  1146 + return message;
  1147 + }
  1148 + catch (Exception ex)
  1149 + {
  1150 + throw NCCException.Oh($"锁定/解锁工资条失败: {ex.Message}");
775 1151 }
776 1152 }
777 1153  
778 1154 /// <summary>
  1155 + /// 批量锁定当月所有工资
  1156 + /// </summary>
  1157 + /// <remarks>
  1158 + /// 批量锁定指定月份的所有健康师工资记录
  1159 + ///
  1160 + /// 示例请求:
  1161 + /// ```json
  1162 + /// {
  1163 + /// "year": 2025,
  1164 + /// "month": 12,
  1165 + /// "isLocked": true
  1166 + /// }
  1167 + /// ```
  1168 + /// </remarks>
  1169 + /// <param name="input">批量锁定输入参数</param>
  1170 + /// <returns>锁定结果</returns>
  1171 + [HttpPost("lock-by-month")]
  1172 + public async Task<dynamic> LockSalaryByMonth([FromBody] SalaryLockByMonthInput input)
  1173 + {
  1174 + try
  1175 + {
  1176 + if (input == null)
  1177 + throw NCCException.Oh("参数不能为空");
  1178 +
  1179 + if (input.Year <= 0 || input.Month <= 0 || input.Month > 12)
  1180 + throw NCCException.Oh("年份和月份参数不正确");
  1181 +
  1182 + var monthStr = $"{input.Year}{input.Month:D2}";
  1183 +
  1184 + var salaries = await _db.Queryable<LqSalaryStatisticsEntity>()
  1185 + .Where(s => s.StatisticsMonth == monthStr)
  1186 + .ToListAsync();
  1187 +
  1188 + if (!salaries.Any())
  1189 + throw NCCException.Oh($"未找到{input.Year}年{input.Month}月的工资记录");
  1190 +
  1191 + var lockedCount = 0;
  1192 + var unlockedCount = 0;
  1193 + var skippedCount = 0;
  1194 + var alreadyLockedCount = 0;
  1195 +
  1196 + foreach (var salary in salaries)
  1197 + {
  1198 + if (salary.EmployeeConfirmStatus == 1 && !input.IsLocked)
  1199 + {
  1200 + skippedCount++;
  1201 + continue;
  1202 + }
  1203 +
  1204 + if (salary.IsLocked == 1 && input.IsLocked)
  1205 + {
  1206 + alreadyLockedCount++;
  1207 + continue;
  1208 + }
  1209 +
  1210 + if (salary.IsLocked == 0 && !input.IsLocked)
  1211 + {
  1212 + alreadyLockedCount++;
  1213 + continue;
  1214 + }
  1215 +
  1216 + salary.IsLocked = input.IsLocked ? 1 : 0;
  1217 + salary.UpdateTime = DateTime.Now;
  1218 +
  1219 + if (input.IsLocked)
  1220 + lockedCount++;
  1221 + else
  1222 + unlockedCount++;
  1223 + }
  1224 +
  1225 + if (lockedCount > 0 || unlockedCount > 0)
  1226 + {
  1227 + var salariesToUpdate = salaries.Where(s =>
  1228 + (input.IsLocked && s.IsLocked == 0) ||
  1229 + (!input.IsLocked && s.IsLocked == 1 && s.EmployeeConfirmStatus != 1)
  1230 + ).ToList();
  1231 +
  1232 + if (salariesToUpdate.Any())
  1233 + {
  1234 + await _db.Updateable(salariesToUpdate)
  1235 + .UpdateColumns(s => new { s.IsLocked, s.UpdateTime })
  1236 + .ExecuteCommandAsync();
  1237 + }
  1238 + }
  1239 +
  1240 + var action = input.IsLocked ? "锁定" : "解锁";
  1241 + var count = input.IsLocked ? lockedCount : unlockedCount;
  1242 + var message = $"{action}成功:{count}条";
  1243 +
  1244 + if (alreadyLockedCount > 0)
  1245 + message += $",跳过{alreadyLockedCount}条(已是{action}状态)";
  1246 +
  1247 + if (skippedCount > 0)
  1248 + message += $",跳过{skippedCount}条(已确认的记录不能解锁)";
  1249 +
  1250 + return new
  1251 + {
  1252 + success = true,
  1253 + message = message,
  1254 + total = salaries.Count,
  1255 + locked = lockedCount,
  1256 + unlocked = unlockedCount,
  1257 + skipped = skippedCount,
  1258 + alreadyLocked = alreadyLockedCount
  1259 + };
  1260 + }
  1261 + catch (Exception ex)
  1262 + {
  1263 + _logger.LogError(ex, "批量锁定当月工资失败");
  1264 + var action = input?.IsLocked == true ? "锁定" : "解锁";
  1265 + throw NCCException.Oh($"批量{action}当月工资失败: {ex.Message}");
  1266 + }
  1267 + }
  1268 +
  1269 + #endregion
  1270 +
  1271 + #region 导入工资
  1272 +
  1273 + /// <summary>
  1274 + /// 从Excel导入健康师工资数据
  1275 + /// </summary>
  1276 + /// <remarks>
  1277 + /// 从Excel文件导入健康师工资数据,Excel第一列必须是ID(主键)
  1278 + ///
  1279 + /// 导入规则:
  1280 + /// 1. Excel第一列是ID(主键),如果为空则自动生成新ID
  1281 + /// 2. 如果ID在数据库中存在,则更新记录(需检查是否已锁定或已确认)
  1282 + /// 3. 如果ID在数据库中不存在,则新增记录
  1283 + /// 4. 已锁定(IsLocked=1)或已确认(EmployeeConfirmStatus=1)的记录不能导入覆盖
  1284 + ///
  1285 + /// Excel字段顺序(第一列为ID):
  1286 + /// ID, 门店名称, 员工姓名, 岗位, 金三角战队, ...(共77列)
  1287 + /// </remarks>
  1288 + /// <param name="file">Excel文件</param>
  1289 + /// <returns>导入结果</returns>
  1290 + /// <response code="200">导入成功</response>
  1291 + /// <response code="400">文件格式错误或数据验证失败</response>
  1292 + [HttpPost("import")]
  1293 + public async Task<dynamic> ImportSalaryFromExcel(IFormFile file)
  1294 + {
  1295 + try
  1296 + {
  1297 + if (file == null || file.Length == 0)
  1298 + {
  1299 + throw NCCException.Oh("请选择要上传的Excel文件");
  1300 + }
  1301 +
  1302 + // 检查文件格式
  1303 + var allowedExtensions = new[] { ".xlsx", ".xls" };
  1304 + var fileExtension = Path.GetExtension(file.FileName).ToLowerInvariant();
  1305 + if (!allowedExtensions.Contains(fileExtension))
  1306 + {
  1307 + throw NCCException.Oh("只支持.xlsx和.xls格式的Excel文件");
  1308 + }
  1309 +
  1310 + var recordsToInsert = new List<LqSalaryStatisticsEntity>();
  1311 + var recordsToUpdate = new List<LqSalaryStatisticsEntity>();
  1312 + var errorMessages = new List<string>();
  1313 + var successCount = 0;
  1314 + var failCount = 0;
  1315 + var skippedCount = 0;
  1316 +
  1317 + // 保存临时文件
  1318 + var tempFilePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString() + Path.GetExtension(file.FileName));
  1319 + try
  1320 + {
  1321 + using (var stream = new FileStream(tempFilePath, FileMode.Create))
  1322 + {
  1323 + await file.CopyToAsync(stream);
  1324 + }
  1325 +
  1326 + // 使用ExcelImportHelper读取Excel文件
  1327 + // 参数说明:0表示第一个工作表,0表示第一行是标题行
  1328 + var dataTable = ExcelImportHelper.ToDataTable(tempFilePath, 0, 0);
  1329 +
  1330 + if (dataTable.Rows.Count == 0)
  1331 + {
  1332 + throw NCCException.Oh("Excel文件中没有数据行");
  1333 + }
  1334 +
  1335 + // Excel第一列是ID,第二列开始是业务字段
  1336 + // 从第1行开始读取数据(跳过标题行)
  1337 + for (int i = 1; i < dataTable.Rows.Count; i++)
  1338 + {
  1339 + try
  1340 + {
  1341 + var row = dataTable.Rows[i];
  1342 +
  1343 + // 安全获取列值
  1344 + Func<int, string> GetColumnValue = (colIndex) =>
  1345 + {
  1346 + if (colIndex < row.ItemArray.Length)
  1347 + {
  1348 + return row[colIndex]?.ToString()?.Trim() ?? "";
  1349 + }
  1350 + return "";
  1351 + };
  1352 +
  1353 + // 第一列是ID(Excel第一列应该是ID)
  1354 + var id = GetColumnValue(0);
  1355 +
  1356 + // 判断Excel格式:如果第一列是"门店名称"等中文,说明是旧格式(没有ID列)
  1357 + var firstColumnValue = GetColumnValue(0);
  1358 + bool isOldFormat = false;
  1359 + if (!string.IsNullOrWhiteSpace(firstColumnValue) &&
  1360 + (firstColumnValue == "门店名称" || firstColumnValue.Contains("门店") || (!string.IsNullOrWhiteSpace(firstColumnValue) && !long.TryParse(firstColumnValue, out _) && firstColumnValue.Length > 20)))
  1361 + {
  1362 + // 旧格式:第一列是"门店名称"标题行或数据,不是ID
  1363 + isOldFormat = true;
  1364 + id = ""; // ID为空,需要根据员工姓名和门店名称匹配
  1365 + }
  1366 +
  1367 + // 根据Excel格式确定字段索引
  1368 + int storeNameIndex = isOldFormat ? 0 : 1; // 门店名称索引
  1369 + int employeeNameIndex = isOldFormat ? 1 : 2; // 员工姓名索引
  1370 +
  1371 + // 跳过空行(ID、员工姓名都为空)
  1372 + var employeeName = GetColumnValue(employeeNameIndex);
  1373 + var storeName = GetColumnValue(storeNameIndex);
  1374 +
  1375 + if (string.IsNullOrWhiteSpace(id) && string.IsNullOrWhiteSpace(employeeName))
  1376 + {
  1377 + continue;
  1378 + }
  1379 +
  1380 + // 如果ID为空,尝试根据员工姓名和门店名称匹配现有记录的ID
  1381 + if (string.IsNullOrWhiteSpace(id) && !string.IsNullOrWhiteSpace(employeeName))
  1382 + {
  1383 + var matchedRecord = await _db.Queryable<LqSalaryStatisticsEntity>()
  1384 + .Where(x => x.EmployeeName == employeeName)
  1385 + .WhereIF(!string.IsNullOrWhiteSpace(storeName), x => x.StoreName == storeName)
  1386 + .OrderBy(x => x.CreateTime, OrderByType.Desc)
  1387 + .FirstAsync();
  1388 +
  1389 + if (matchedRecord != null)
  1390 + {
  1391 + id = matchedRecord.Id;
  1392 + }
  1393 + }
  1394 +
  1395 + // 验证必填字段
  1396 + if (string.IsNullOrWhiteSpace(employeeName))
  1397 + {
  1398 + errorMessages.Add($"第{i + 1}行:员工姓名不能为空");
  1399 + failCount++;
  1400 + continue;
  1401 + }
  1402 +
  1403 + // 辅助方法:清理数值字符串
  1404 + Func<string, decimal> ParseDecimal = (str) =>
  1405 + {
  1406 + if (string.IsNullOrWhiteSpace(str))
  1407 + return 0;
  1408 + var cleaned = str.Trim()
  1409 + .Replace(",", "")
  1410 + .Replace(",", "")
  1411 + .Replace("¥", "")
  1412 + .Replace("$", "")
  1413 + .Replace("元", "")
  1414 + .Replace("%", "")
  1415 + .Replace(" ", "");
  1416 + if (decimal.TryParse(cleaned, out decimal result))
  1417 + return result;
  1418 + return 0;
  1419 + };
  1420 +
  1421 + Func<string, int> ParseInt = (str) =>
  1422 + {
  1423 + if (string.IsNullOrWhiteSpace(str))
  1424 + return 0;
  1425 + var cleaned = str.Trim().Replace(",", "").Replace(",", "").Replace(" ", "");
  1426 + if (int.TryParse(cleaned, out int result))
  1427 + return result;
  1428 + return 0;
  1429 + };
  1430 +
  1431 + // 如果Excel中有ID,查找现有记录
  1432 + LqSalaryStatisticsEntity existing = null;
  1433 + if (!string.IsNullOrWhiteSpace(id))
  1434 + {
  1435 + existing = await _db.Queryable<LqSalaryStatisticsEntity>()
  1436 + .Where(x => x.Id == id)
  1437 + .FirstAsync();
  1438 +
  1439 + if (existing != null)
  1440 + {
  1441 + // 检查是否已锁定或已确认
  1442 + if (existing.IsLocked == 1)
  1443 + {
  1444 + errorMessages.Add($"第{i + 1}行:员工 {existing.EmployeeName} (ID: {id}) 的工资已锁定,不能导入覆盖");
  1445 + skippedCount++;
  1446 + failCount++;
  1447 + continue;
  1448 + }
  1449 +
  1450 + if (existing.EmployeeConfirmStatus == 1)
  1451 + {
  1452 + errorMessages.Add($"第{i + 1}行:员工 {existing.EmployeeName} (ID: {id}) 的工资已确认,不能导入覆盖");
  1453 + skippedCount++;
  1454 + failCount++;
  1455 + continue;
  1456 + }
  1457 + }
  1458 + }
  1459 +
  1460 + // 创建或更新实体
  1461 + LqSalaryStatisticsEntity entity;
  1462 + if (existing != null)
  1463 + {
  1464 + // 更新现有记录
  1465 + entity = existing;
  1466 +
  1467 + // 导入后重置确认状态(如果被覆盖)
  1468 + entity.EmployeeConfirmStatus = 0;
  1469 + entity.EmployeeConfirmTime = null;
  1470 + entity.EmployeeConfirmRemark = null;
  1471 + }
  1472 + else
  1473 + {
  1474 + // 新增记录
  1475 + entity = new LqSalaryStatisticsEntity
  1476 + {
  1477 + Id = string.IsNullOrWhiteSpace(id) ? YitIdHelper.NextId().ToString() : id,
  1478 + EmployeeConfirmStatus = 0,
  1479 + IsLocked = 0,
  1480 + CreateTime = DateTime.Now,
  1481 + UpdateTime = DateTime.Now
  1482 + };
  1483 + }
  1484 +
  1485 + // 映射Excel字段到实体属性(根据Excel列顺序)
  1486 + // 注意:Excel第一列是ID(索引0),第二列是门店名称(索引1),第三列是员工姓名(索引2)...
  1487 + entity.StoreName = storeName;
  1488 + entity.EmployeeName = employeeName;
  1489 + entity.Position = GetColumnValue(2); // Excel第3列(索引2)
  1490 + entity.GoldTriangleTeam = GetColumnValue(3); // Excel第4列(索引3)
  1491 + // 根据Excel格式计算字段索引偏移量
  1492 + int offset = isOldFormat ? 0 : 1; // 如果是新格式(第一列是ID),所有业务字段索引+1
  1493 +
  1494 + // 映射Excel字段到实体属性
  1495 + // Excel列顺序(新格式):ID, 门店名称, 员工姓名, 岗位, 金三角战队, 总业绩(5+offset), ...
  1496 + entity.Position = GetColumnValue(2 + offset); // 岗位
  1497 + entity.GoldTriangleTeam = GetColumnValue(3 + offset); // 金三角战队
  1498 + entity.TotalPerformance = ParseDecimal(GetColumnValue(4 + offset)); // 总业绩
  1499 + entity.BasePerformance = ParseDecimal(GetColumnValue(5 + offset));
  1500 + entity.CooperationPerformance = ParseDecimal(GetColumnValue(6 + offset));
  1501 + // 跳过索引7+offset(奖励业绩),实际基础业绩和实际合作业绩在索引8+offset和9+offset
  1502 + entity.ActualBasePerformance = ParseDecimal(GetColumnValue(8 + offset));
  1503 + entity.ActualCooperationPerformance = ParseDecimal(GetColumnValue(9 + offset));
  1504 + entity.NewCustomerPerformance = ParseDecimal(GetColumnValue(10 + offset));
  1505 + entity.UpgradePerformance = ParseDecimal(GetColumnValue(11 + offset));
  1506 + entity.BaseRewardPerformance = ParseDecimal(GetColumnValue(12 + offset));
  1507 + entity.CooperationRewardPerformance = ParseDecimal(GetColumnValue(13 + offset));
  1508 + entity.OtherPerformanceAdd = ParseDecimal(GetColumnValue(14 + offset));
  1509 + entity.OtherPerformanceSubtract = ParseDecimal(GetColumnValue(15 + offset));
  1510 + entity.StoreTotalPerformance = ParseDecimal(GetColumnValue(16 + offset));
  1511 + entity.TeamPerformance = ParseDecimal(GetColumnValue(17 + offset));
  1512 + entity.Percentage = ParseDecimal(GetColumnValue(18 + offset));
  1513 + entity.Consumption = ParseDecimal(GetColumnValue(19 + offset));
  1514 + entity.ProjectCount = ParseInt(GetColumnValue(20 + offset));
  1515 + entity.CustomerCount = ParseInt(GetColumnValue(21 + offset));
  1516 + entity.WorkingDays = ParseDecimal(GetColumnValue(22 + offset));
  1517 + entity.LeaveDays = ParseDecimal(GetColumnValue(23 + offset));
  1518 + entity.DailyAverageConsumption = ParseDecimal(GetColumnValue(24 + offset));
  1519 + entity.DailyAverageProjectCount = ParseDecimal(GetColumnValue(25 + offset));
  1520 + entity.TeamTotalConsumption = ParseDecimal(GetColumnValue(26 + offset));
  1521 + entity.NewCustomerConversionRate = ParseDecimal(GetColumnValue(27 + offset));
  1522 + entity.NewCustomerPoint = ParseDecimal(GetColumnValue(28 + offset));
  1523 + entity.NewCustomerPerformanceCommission = ParseDecimal(GetColumnValue(29 + offset));
  1524 + entity.UpgradeCustomerCount = ParseInt(GetColumnValue(30 + offset));
  1525 + entity.UpgradePoint = ParseDecimal(GetColumnValue(31 + offset));
  1526 + entity.UpgradePerformanceCommission = ParseDecimal(GetColumnValue(32 + offset));
  1527 + entity.CommissionPoint = ParseDecimal(GetColumnValue(33 + offset));
  1528 + entity.BasePerformanceCommission = ParseDecimal(GetColumnValue(34 + offset));
  1529 + entity.CooperationPerformanceCommission = ParseDecimal(GetColumnValue(35 + offset));
  1530 + entity.ConsultantCommission = ParseDecimal(GetColumnValue(36 + offset));
  1531 + entity.StoreTZoneCommission = ParseDecimal(GetColumnValue(37 + offset));
  1532 + entity.TotalCommission = ParseDecimal(GetColumnValue(38 + offset));
  1533 + entity.HealthCoachBaseSalary = ParseDecimal(GetColumnValue(39 + offset));
  1534 + entity.HandworkFee = ParseDecimal(GetColumnValue(40 + offset));
  1535 + entity.OutherHandworkFee = ParseDecimal(GetColumnValue(41 + offset));
  1536 + entity.CalculatedGrossSalary = ParseDecimal(GetColumnValue(42 + offset));
  1537 + entity.GuaranteedSalary = ParseDecimal(GetColumnValue(43 + offset));
  1538 + entity.GuaranteedLeaveDeduction = ParseDecimal(GetColumnValue(44 + offset));
  1539 + entity.GuaranteedBaseSalary = ParseDecimal(GetColumnValue(45 + offset));
  1540 + entity.GuaranteedSupplement = ParseDecimal(GetColumnValue(46 + offset));
  1541 + entity.FinalGrossSalary = ParseDecimal(GetColumnValue(47 + offset));
  1542 + entity.TransportationAllowance = ParseDecimal(GetColumnValue(48 + offset));
  1543 + entity.LessRest = ParseDecimal(GetColumnValue(49 + offset));
  1544 + entity.FullAttendance = ParseDecimal(GetColumnValue(50 + offset));
  1545 + entity.MonthlyTrainingSubsidy = ParseDecimal(GetColumnValue(51 + offset));
  1546 + entity.MonthlyTransportSubsidy = ParseDecimal(GetColumnValue(52 + offset));
  1547 + entity.LastMonthTrainingSubsidy = ParseDecimal(GetColumnValue(53 + offset));
  1548 + entity.LastMonthTransportSubsidy = ParseDecimal(GetColumnValue(54 + offset));
  1549 + entity.TotalSubsidy = ParseDecimal(GetColumnValue(55 + offset));
  1550 + entity.MissingCard = ParseDecimal(GetColumnValue(56 + offset));
  1551 + entity.LateArrival = ParseDecimal(GetColumnValue(57 + offset));
  1552 + entity.LeaveDeduction = ParseDecimal(GetColumnValue(58 + offset));
  1553 + entity.SocialInsuranceDeduction = ParseDecimal(GetColumnValue(59 + offset));
  1554 + entity.RewardDeduction = ParseDecimal(GetColumnValue(60 + offset));
  1555 + entity.AccommodationDeduction = ParseDecimal(GetColumnValue(61 + offset));
  1556 + entity.StudyPeriodDeduction = ParseDecimal(GetColumnValue(62 + offset));
  1557 + entity.WorkClothesDeduction = ParseDecimal(GetColumnValue(63 + offset));
  1558 + entity.TotalDeduction = ParseDecimal(GetColumnValue(64 + offset));
  1559 + entity.Bonus = ParseDecimal(GetColumnValue(65 + offset));
  1560 + entity.ReturnPhoneDeposit = ParseDecimal(GetColumnValue(66 + offset));
  1561 + entity.ReturnAccommodationDeposit = ParseDecimal(GetColumnValue(67 + offset));
  1562 + entity.LastMonthSupplement = ParseDecimal(GetColumnValue(68 + offset));
  1563 + entity.ActualSalary = ParseDecimal(GetColumnValue(69 + offset));
  1564 + entity.MonthlyPaymentStatus = GetColumnValue(70 + offset);
  1565 + entity.PaidAmount = ParseDecimal(GetColumnValue(71 + offset));
  1566 + entity.PendingAmount = ParseDecimal(GetColumnValue(72 + offset));
  1567 + entity.MonthlyTotalPayment = ParseDecimal(GetColumnValue(73 + offset));
  1568 +
  1569 + // 处理"是否新店"字段
  1570 + var isNewStoreStr = GetColumnValue(74 + offset);
  1571 + entity.IsNewStore = (isNewStoreStr == "是" || isNewStoreStr == "1" || isNewStoreStr.ToLower() == "true") ? "是" : "否";
  1572 +
  1573 + // 处理"新店保护阶段"字段
  1574 + var newStoreProtectionStageStr = GetColumnValue(75 + offset);
  1575 + if (int.TryParse(newStoreProtectionStageStr, out int protectionStage))
  1576 + {
  1577 + entity.NewStoreProtectionStage = protectionStage;
  1578 + }
  1579 +
  1580 + // 注意:Excel中的"锁定状态"字段不应该从Excel导入,因为锁定状态应该由管理员操作
  1581 + // 导入时只读取,但不更新到数据库
  1582 +
  1583 + // 处理Excel中没有的字段:StoreId、EmployeeId、StatisticsMonth等
  1584 + if (existing != null)
  1585 + {
  1586 + // 更新记录:保留原有的StoreId、EmployeeId、StatisticsMonth等
  1587 + entity.StoreId = existing.StoreId;
  1588 + entity.EmployeeId = existing.EmployeeId;
  1589 + entity.StatisticsMonth = existing.StatisticsMonth;
  1590 + }
  1591 + else
  1592 + {
  1593 + // 新增记录:尝试根据员工姓名和门店名称查找EmployeeId和StoreId
  1594 + // 如果找不到,这些字段会保持为空,需要后续通过计算工资时填充
  1595 + if (!string.IsNullOrWhiteSpace(employeeName))
  1596 + {
  1597 + // 尝试查找用户(通过员工姓名)
  1598 + var user = await _db.Queryable<UserEntity>()
  1599 + .Where(u => u.RealName == employeeName)
  1600 + .FirstAsync();
  1601 +
  1602 + if (user != null)
  1603 + {
  1604 + entity.EmployeeId = user.Id;
  1605 +
  1606 + // 尝试从用户的门店ID获取
  1607 + if (!string.IsNullOrEmpty(user.Mdid) && string.IsNullOrWhiteSpace(storeName))
  1608 + {
  1609 + entity.StoreId = user.Mdid;
  1610 + }
  1611 + }
  1612 +
  1613 + // 如果门店名称不为空,尝试查找门店ID
  1614 + if (!string.IsNullOrWhiteSpace(storeName) && string.IsNullOrEmpty(entity.StoreId))
  1615 + {
  1616 + var store = await _db.Queryable<LqMdxxEntity>()
  1617 + .Where(s => s.Dm == storeName)
  1618 + .FirstAsync();
  1619 +
  1620 + if (store != null)
  1621 + {
  1622 + entity.StoreId = store.Id;
  1623 + }
  1624 + }
  1625 + }
  1626 +
  1627 + // StatisticsMonth需要从Excel文件名或其他地方获取
  1628 + // 这里暂时留空,后续可以根据业务需求补充(例如从Excel文件名中提取月份)
  1629 + }
  1630 +
  1631 + entity.UpdateTime = DateTime.Now;
  1632 +
  1633 + if (existing != null)
  1634 + {
  1635 + recordsToUpdate.Add(entity);
  1636 + }
  1637 + else
  1638 + {
  1639 + recordsToInsert.Add(entity);
  1640 + }
  1641 +
  1642 + successCount++;
  1643 + }
  1644 + catch (Exception ex)
  1645 + {
  1646 + errorMessages.Add($"第{i + 1}行数据处理失败: {ex.Message}");
  1647 + failCount++;
  1648 + }
  1649 + }
  1650 + }
  1651 + finally
  1652 + {
  1653 + // 清理临时文件
  1654 + if (File.Exists(tempFilePath))
  1655 + {
  1656 + File.Delete(tempFilePath);
  1657 + }
  1658 + }
  1659 +
  1660 + // 批量插入新记录
  1661 + if (recordsToInsert.Any())
  1662 + {
  1663 + await _db.Insertable(recordsToInsert).ExecuteCommandAsync();
  1664 + }
  1665 +
  1666 + // 批量更新现有记录
  1667 + if (recordsToUpdate.Any())
  1668 + {
  1669 + await _db.Updateable(recordsToUpdate).ExecuteCommandAsync();
  1670 + }
  1671 +
  1672 + return new
  1673 + {
  1674 + success = true,
  1675 + message = $"导入完成:成功 {successCount} 条,失败 {failCount} 条,跳过 {skippedCount} 条(已锁定或已确认)",
  1676 + successCount = successCount,
  1677 + failCount = failCount,
  1678 + skippedCount = skippedCount,
  1679 + errors = errorMessages
  1680 + };
  1681 + }
  1682 + catch (Exception ex)
  1683 + {
  1684 + throw NCCException.Oh($"导入健康师工资数据失败: {ex.Message}");
  1685 + }
  1686 + }
  1687 +
  1688 + #endregion
  1689 +
  1690 + /// <summary>
779 1691 /// 计算底薪
780 1692 /// </summary>
781 1693 private decimal CalculateBaseSalary(decimal dailyAvgConsumption, decimal dailyAvgProjectCount, int daysInMonth, decimal workingDays, bool isNewStore)
... ...
netcore/src/Modularity/Extend/NCC.Extend/LqStoreManagerSalaryService.cs
1 1 using Microsoft.AspNetCore.Authorization;
  2 +using Microsoft.AspNetCore.Http;
2 3 using Microsoft.AspNetCore.Mvc;
  4 +using Microsoft.Extensions.Logging;
3 5 using NCC.Common.Filter;
4 6 using NCC.Common.Helper;
5 7 using NCC.Dependency;
6 8 using NCC.DynamicApiController;
  9 +using NCC.Extend.Entitys.Dto.LqSalary;
7 10 using NCC.Extend.Entitys.Dto.LqStoreManagerSalary;
8 11 using NCC.Extend.Entitys.Enum;
9 12 using NCC.Extend.Entitys.lq_attendance_summary;
... ... @@ -19,10 +22,13 @@ using NCC.Extend.Entitys.lq_store_expense;
19 22 using NCC.Extend.Entitys.lq_store_manager_salary_statistics;
20 23 using NCC.Extend.Entitys.lq_xh_hyhk;
21 24 using NCC.Extend.Entitys.lq_xh_jksyj;
  25 +using NCC.FriendlyException;
22 26 using NCC.System.Entitys.Permission;
23 27 using SqlSugar;
24 28 using System;
25 29 using System.Collections.Generic;
  30 +using System.Data;
  31 +using System.IO;
26 32 using System.Linq;
27 33 using System.Threading.Tasks;
28 34 using Yitter.IdGenerator;
... ... @@ -37,13 +43,15 @@ namespace NCC.Extend
37 43 public class LqStoreManagerSalaryService : IDynamicApiController, ITransient
38 44 {
39 45 private readonly ISqlSugarClient _db;
  46 + private readonly ILogger<LqStoreManagerSalaryService> _logger;
40 47  
41 48 /// <summary>
42 49 /// 初始化一个<see cref="LqStoreManagerSalaryService"/>类型的新实例
43 50 /// </summary>
44   - public LqStoreManagerSalaryService(ISqlSugarClient db)
  51 + public LqStoreManagerSalaryService(ISqlSugarClient db, ILogger<LqStoreManagerSalaryService> logger)
45 52 {
46 53 _db = db;
  54 + _logger = logger;
47 55 }
48 56  
49 57 /// <summary>
... ... @@ -56,17 +64,7 @@ namespace NCC.Extend
56 64 {
57 65 var monthStr = $"{input.Year}{input.Month:D2}";
58 66  
59   - // 1. 检查当月是否已生成工资数据
60   - var exists = await _db.Queryable<LqStoreManagerSalaryStatisticsEntity>()
61   - .AnyAsync(x => x.StatisticsMonth == monthStr);
62   -
63   - // 2. 如果没有数据,则进行计算
64   - if (!exists)
65   - {
66   - await CalculateStoreManagerSalary(input.Year, input.Month);
67   - }
68   -
69   - // 3. 查询数据
  67 + // 查询数据
70 68 var query = _db.Queryable<LqStoreManagerSalaryStatisticsEntity>()
71 69 .Where(x => x.StatisticsMonth == monthStr);
72 70  
... ... @@ -134,6 +132,73 @@ namespace NCC.Extend
134 132 }
135 133  
136 134 /// <summary>
  135 + /// 通过月份和员工ID查询工资
  136 + /// </summary>
  137 + [HttpGet("query-by-employee")]
  138 + public async Task<StoreManagerSalaryOutput> GetSalaryByEmployee([FromQuery] SalaryQueryByEmployeeInput input)
  139 + {
  140 + if (input.Year <= 0 || input.Month <= 0 || input.Month > 12)
  141 + throw NCCException.Oh("年份和月份参数不正确");
  142 + if (string.IsNullOrWhiteSpace(input.EmployeeId))
  143 + throw NCCException.Oh("员工ID不能为空");
  144 + var monthStr = $"{input.Year}{input.Month:D2}";
  145 + var salary = await _db.Queryable<LqStoreManagerSalaryStatisticsEntity>()
  146 + .Where(x => x.StatisticsMonth == monthStr && x.EmployeeId == input.EmployeeId)
  147 + .Select(x => new StoreManagerSalaryOutput
  148 + {
  149 + Id = x.Id,
  150 + StoreName = x.StoreName,
  151 + EmployeeName = x.EmployeeName,
  152 + Position = x.Position,
  153 + StoreTotalPerformance = x.StoreTotalPerformance,
  154 + StoreBillingPerformance = x.StoreBillingPerformance,
  155 + StoreRefundPerformance = x.StoreRefundPerformance,
  156 + StoreLifeline = x.StoreLifeline,
  157 + PerformanceCompletionRate = x.PerformanceCompletionRate,
  158 + PerformanceReached = x.PerformanceReached,
  159 + HeadCountReached = x.HeadCountReached,
  160 + ConsumeReached = x.ConsumeReached,
  161 + AssessmentDeduction = x.AssessmentDeduction,
  162 + UnreachedIndicatorCount = x.UnreachedIndicatorCount,
  163 + HeadCount = x.HeadCount,
  164 + TargetHeadCount = x.TargetHeadCount,
  165 + StoreConsume = x.StoreConsume,
  166 + TargetConsume = x.TargetConsume,
  167 + SalesPerformance = x.SalesPerformance,
  168 + ProductMaterial = x.ProductMaterial,
  169 + CooperationCost = x.CooperationCost,
  170 + StoreExpense = x.StoreExpense,
  171 + LaundryCost = x.LaundryCost,
  172 + GrossProfit = x.GrossProfit,
  173 + CommissionRate = x.CommissionRate,
  174 + CommissionAmount = x.CommissionAmount,
  175 + BaseSalary = x.BaseSalary,
  176 + FlagshipStoreDeduction = x.FlagshipStoreDeduction,
  177 + ActualBaseSalary = x.ActualBaseSalary,
  178 + WorkingDays = x.WorkingDays,
  179 + LeaveDays = x.LeaveDays,
  180 + GrossSalary = x.GrossSalary,
  181 + ActualSalary = x.ActualSalary,
  182 + TotalDeduction = x.TotalDeduction,
  183 + TotalSubsidy = x.TotalSubsidy,
  184 + Bonus = x.Bonus,
  185 + MonthlyPaymentStatus = x.MonthlyPaymentStatus,
  186 + PaidAmount = x.PaidAmount,
  187 + PendingAmount = x.PendingAmount,
  188 + IsLocked = x.IsLocked,
  189 + StoreType = x.StoreType,
  190 + StoreCategory = x.StoreCategory,
  191 + IsNewStore = x.IsNewStore,
  192 + NewStoreProtectionStage = x.NewStoreProtectionStage,
  193 + UpdateTime = x.UpdateTime
  194 + })
  195 + .FirstAsync();
  196 + if (salary == null)
  197 + throw NCCException.Oh($"未找到员工{input.EmployeeId}在{input.Year}年{input.Month}月的工资记录");
  198 + return salary;
  199 + }
  200 +
  201 + /// <summary>
137 202 /// 计算店长工资
138 203 /// </summary>
139 204 /// <param name="year">年份</param>
... ... @@ -546,12 +611,61 @@ namespace NCC.Extend
546 611 // 3. 保存数据
547 612 if (storeManagerSalaryList.Any())
548 613 {
549   - // 先删除当月旧数据 (防止重复)
550   - await _db.Deleteable<LqStoreManagerSalaryStatisticsEntity>()
  614 + var existingRecords = await _db.Queryable<LqStoreManagerSalaryStatisticsEntity>()
551 615 .Where(x => x.StatisticsMonth == monthStr)
552   - .ExecuteCommandAsync();
  616 + .ToListAsync();
  617 +
  618 + var existingDict = existingRecords
  619 + .Where(x => !string.IsNullOrEmpty(x.EmployeeId))
  620 + .GroupBy(x => x.EmployeeId)
  621 + .ToDictionary(g => g.Key, g => g.First());
  622 +
  623 + var recordsToInsert = new List<LqStoreManagerSalaryStatisticsEntity>();
  624 + var recordsToUpdate = new List<LqStoreManagerSalaryStatisticsEntity>();
  625 + var skippedCount = 0;
  626 +
  627 + foreach (var salary in storeManagerSalaryList)
  628 + {
  629 + if (existingDict.ContainsKey(salary.EmployeeId))
  630 + {
  631 + var existing = existingDict[salary.EmployeeId];
  632 + if (existing.IsLocked == 1 || existing.EmployeeConfirmStatus == 1)
  633 + {
  634 + skippedCount++;
  635 + continue;
  636 + }
  637 + salary.Id = existing.Id;
  638 + salary.EmployeeConfirmStatus = existing.EmployeeConfirmStatus;
  639 + salary.EmployeeConfirmTime = existing.EmployeeConfirmTime;
  640 + salary.EmployeeConfirmRemark = existing.EmployeeConfirmRemark;
  641 + salary.IsLocked = existing.IsLocked;
  642 + salary.CreateTime = existing.CreateTime;
  643 + salary.CreateUser = existing.CreateUser;
  644 + recordsToUpdate.Add(salary);
  645 + }
  646 + else
  647 + {
  648 + salary.Id = YitIdHelper.NextId().ToString();
  649 + salary.EmployeeConfirmStatus = 0;
  650 + salary.IsLocked = 0;
  651 + salary.CreateTime = DateTime.Now;
  652 + salary.CreateUser = "";
  653 + recordsToInsert.Add(salary);
  654 + }
  655 + }
553 656  
554   - await _db.Insertable(storeManagerSalaryList).ExecuteCommandAsync();
  657 + if (recordsToInsert.Any())
  658 + {
  659 + await _db.Insertable(recordsToInsert).ExecuteCommandAsync();
  660 + }
  661 + if (recordsToUpdate.Any())
  662 + {
  663 + await _db.Updateable(recordsToUpdate).ExecuteCommandAsync();
  664 + }
  665 + if (skippedCount > 0)
  666 + {
  667 + _logger.LogWarning($"计算工资时跳过了 {skippedCount} 条已锁定或已确认的记录(月份:{monthStr})");
  668 + }
555 669 }
556 670 }
557 671  
... ... @@ -628,6 +742,496 @@ namespace NCC.Extend
628 742 salary.CommissionRate = commissionRate;
629 743 salary.CommissionAmount = grossProfit * commissionRate;
630 744 }
  745 +
  746 + #region 员工工资确认
  747 +
  748 + /// <summary>
  749 + /// 员工确认工资条
  750 + /// </summary>
  751 + /// <remarks>
  752 + /// 员工确认自己的工资条,确认后工资数据不可再修改
  753 + ///
  754 + /// 示例请求:
  755 + /// <code>
  756 + /// {
  757 + /// "id": "工资记录ID",
  758 + /// "employeeId": "员工ID",
  759 + /// "remark": "确认备注(可选)"
  760 + /// }
  761 + /// </code>
  762 + ///
  763 + /// 参数说明:
  764 + /// - id: 工资记录ID(必填)
  765 + /// - employeeId: 员工ID(必填)
  766 + /// - remark: 确认备注(可选)
  767 + ///
  768 + /// 注意事项:
  769 + /// - 只能确认自己的工资条
  770 + /// - 只能确认已锁定的工资条(IsLocked = 1)
  771 + /// - 已确认的工资条不能重复确认
  772 + /// </remarks>
  773 + /// <param name="input">确认参数</param>
  774 + /// <returns>操作结果</returns>
  775 + /// <response code="200">确认成功</response>
  776 + /// <response code="400">参数错误或记录不存在</response>
  777 + [HttpPost("confirm")]
  778 + public async Task<string> ConfirmSalary([FromBody] SalaryConfirmInput input)
  779 + {
  780 + try
  781 + {
  782 + if (string.IsNullOrWhiteSpace(input.Id) || string.IsNullOrWhiteSpace(input.EmployeeId))
  783 + {
  784 + throw NCCException.Oh("工资记录ID和员工ID不能为空");
  785 + }
  786 +
  787 + var salary = await _db.Queryable<LqStoreManagerSalaryStatisticsEntity>()
  788 + .Where(s => s.Id == input.Id && s.EmployeeId == input.EmployeeId)
  789 + .FirstAsync();
  790 +
  791 + if (salary == null)
  792 + throw NCCException.Oh("工资记录不存在或不属于该员工");
  793 + if (salary.EmployeeConfirmStatus == 1)
  794 + throw NCCException.Oh("该工资条已确认,不能重复确认");
  795 + if (salary.IsLocked != 1)
  796 + throw NCCException.Oh("该工资条尚未锁定,请等待管理员锁定后再确认");
  797 +
  798 + salary.EmployeeConfirmStatus = 1;
  799 + salary.EmployeeConfirmTime = DateTime.Now;
  800 + salary.EmployeeConfirmRemark = input.Remark;
  801 + salary.UpdateTime = DateTime.Now;
  802 + await _db.Updateable(salary).ExecuteCommandAsync();
  803 + return "确认成功";
  804 + }
  805 + catch (Exception ex)
  806 + {
  807 + throw NCCException.Oh($"确认工资条失败: {ex.Message}");
  808 + }
  809 + }
  810 +
  811 + #endregion
  812 +
  813 + #region 工资锁定/解锁
  814 +
  815 + /// <summary>
  816 + /// 批量锁定/解锁工资条
  817 + /// </summary>
  818 + [HttpPost("lock")]
  819 + public async Task<string> LockSalary([FromBody] SalaryLockInput input)
  820 + {
  821 + try
  822 + {
  823 + if (input == null || input.Ids == null || !input.Ids.Any())
  824 + throw NCCException.Oh("工资记录ID列表不能为空");
  825 + var salaries = await _db.Queryable<LqStoreManagerSalaryStatisticsEntity>()
  826 + .Where(s => input.Ids.Contains(s.Id))
  827 + .ToListAsync();
  828 + if (!salaries.Any())
  829 + throw NCCException.Oh("未找到指定的工资记录");
  830 + var lockedCount = 0;
  831 + var unlockedCount = 0;
  832 + var skippedCount = 0;
  833 + foreach (var salary in salaries)
  834 + {
  835 + if (salary.EmployeeConfirmStatus == 1 && !input.IsLocked)
  836 + {
  837 + skippedCount++;
  838 + continue;
  839 + }
  840 + salary.IsLocked = input.IsLocked ? 1 : 0;
  841 + salary.UpdateTime = DateTime.Now;
  842 + if (input.IsLocked) lockedCount++; else unlockedCount++;
  843 + }
  844 + await _db.Updateable(salaries).ExecuteCommandAsync();
  845 + var action = input.IsLocked ? "锁定" : "解锁";
  846 + var count = input.IsLocked ? lockedCount : unlockedCount;
  847 + var message = $"{action}成功:{count}条";
  848 + if (skippedCount > 0)
  849 + message += $",跳过{skippedCount}条(已确认的记录不能解锁)";
  850 + return message;
  851 + }
  852 + catch (Exception ex)
  853 + {
  854 + throw NCCException.Oh($"锁定/解锁工资条失败: {ex.Message}");
  855 + }
  856 + }
  857 +
  858 + /// <summary>
  859 + /// 批量锁定当月所有工资
  860 + /// </summary>
  861 + /// <remarks>
  862 + /// 批量锁定指定月份的所有店长工资记录
  863 + ///
  864 + /// 示例请求:
  865 + /// ```json
  866 + /// {
  867 + /// "year": 2025,
  868 + /// "month": 12,
  869 + /// "isLocked": true
  870 + /// }
  871 + /// ```
  872 + ///
  873 + /// 参数说明:
  874 + /// - year: 年份(必填)
  875 + /// - month: 月份(1-12,必填)
  876 + /// - isLocked: 是否锁定(true=锁定,false=解锁,默认true)
  877 + ///
  878 + /// 注意事项:
  879 + /// - 已确认的记录不能解锁
  880 + /// - 已锁定的记录再次锁定会跳过
  881 + /// </remarks>
  882 + /// <param name="input">批量锁定输入参数</param>
  883 + /// <returns>锁定结果</returns>
  884 + /// <response code="200">锁定成功</response>
  885 + /// <response code="400">参数错误</response>
  886 + [HttpPost("lock-by-month")]
  887 + public async Task<dynamic> LockSalaryByMonth([FromBody] SalaryLockByMonthInput input)
  888 + {
  889 + try
  890 + {
  891 + if (input == null)
  892 + throw NCCException.Oh("参数不能为空");
  893 +
  894 + if (input.Year <= 0 || input.Month <= 0 || input.Month > 12)
  895 + throw NCCException.Oh("年份和月份参数不正确");
  896 +
  897 + var monthStr = $"{input.Year}{input.Month:D2}";
  898 +
  899 + // 查询当月所有工资记录
  900 + var salaries = await _db.Queryable<LqStoreManagerSalaryStatisticsEntity>()
  901 + .Where(s => s.StatisticsMonth == monthStr)
  902 + .ToListAsync();
  903 +
  904 + if (!salaries.Any())
  905 + throw NCCException.Oh($"未找到{input.Year}年{input.Month}月的工资记录");
  906 +
  907 + var lockedCount = 0;
  908 + var unlockedCount = 0;
  909 + var skippedCount = 0;
  910 + var alreadyLockedCount = 0;
  911 +
  912 + foreach (var salary in salaries)
  913 + {
  914 + // 如果已确认,不能解锁
  915 + if (salary.EmployeeConfirmStatus == 1 && !input.IsLocked)
  916 + {
  917 + skippedCount++;
  918 + continue;
  919 + }
  920 +
  921 + // 如果已经是锁定状态,再次锁定时跳过
  922 + if (salary.IsLocked == 1 && input.IsLocked)
  923 + {
  924 + alreadyLockedCount++;
  925 + continue;
  926 + }
  927 +
  928 + // 如果已经是解锁状态,再次解锁时跳过
  929 + if (salary.IsLocked == 0 && !input.IsLocked)
  930 + {
  931 + alreadyLockedCount++;
  932 + continue;
  933 + }
  934 +
  935 + salary.IsLocked = input.IsLocked ? 1 : 0;
  936 + salary.UpdateTime = DateTime.Now;
  937 +
  938 + if (input.IsLocked)
  939 + lockedCount++;
  940 + else
  941 + unlockedCount++;
  942 + }
  943 +
  944 + // 批量更新
  945 + if (lockedCount > 0 || unlockedCount > 0)
  946 + {
  947 + var salariesToUpdate = salaries.Where(s =>
  948 + (input.IsLocked && s.IsLocked == 0) ||
  949 + (!input.IsLocked && s.IsLocked == 1 && s.EmployeeConfirmStatus != 1)
  950 + ).ToList();
  951 +
  952 + if (salariesToUpdate.Any())
  953 + {
  954 + await _db.Updateable(salariesToUpdate)
  955 + .UpdateColumns(s => new { s.IsLocked, s.UpdateTime })
  956 + .ExecuteCommandAsync();
  957 + }
  958 + }
  959 +
  960 + var action = input.IsLocked ? "锁定" : "解锁";
  961 + var count = input.IsLocked ? lockedCount : unlockedCount;
  962 + var message = $"{action}成功:{count}条";
  963 +
  964 + if (alreadyLockedCount > 0)
  965 + message += $",跳过{alreadyLockedCount}条(已是{action}状态)";
  966 +
  967 + if (skippedCount > 0)
  968 + message += $",跳过{skippedCount}条(已确认的记录不能解锁)";
  969 +
  970 + return new
  971 + {
  972 + success = true,
  973 + message = message,
  974 + total = salaries.Count,
  975 + locked = lockedCount,
  976 + unlocked = unlockedCount,
  977 + skipped = skippedCount,
  978 + alreadyLocked = alreadyLockedCount
  979 + };
  980 + }
  981 + catch (Exception ex)
  982 + {
  983 + _logger.LogError(ex, "批量锁定当月工资失败");
  984 + var action = input?.IsLocked == true ? "锁定" : "解锁";
  985 + throw NCCException.Oh($"批量{action}当月工资失败: {ex.Message}");
  986 + }
  987 + }
  988 +
  989 + #endregion
  990 +
  991 + #region 导入工资
  992 +
  993 + /// <summary>
  994 + /// 从Excel导入店长工资数据
  995 + /// </summary>
  996 + /// <param name="file">Excel文件</param>
  997 + /// <returns>导入结果</returns>
  998 + [HttpPost("import")]
  999 + public async Task<dynamic> ImportSalaryFromExcel(IFormFile file)
  1000 + {
  1001 + try
  1002 + {
  1003 + if (file == null || file.Length == 0)
  1004 + throw NCCException.Oh("请选择要上传的Excel文件");
  1005 +
  1006 + var allowedExtensions = new[] { ".xlsx", ".xls" };
  1007 + var fileExtension = Path.GetExtension(file.FileName).ToLowerInvariant();
  1008 + if (!allowedExtensions.Contains(fileExtension))
  1009 + throw NCCException.Oh("只支持.xlsx和.xls格式的Excel文件");
  1010 +
  1011 + var recordsToInsert = new List<LqStoreManagerSalaryStatisticsEntity>();
  1012 + var recordsToUpdate = new List<LqStoreManagerSalaryStatisticsEntity>();
  1013 + var errorMessages = new List<string>();
  1014 + var successCount = 0;
  1015 + var failCount = 0;
  1016 + var skippedCount = 0;
  1017 +
  1018 + var tempFilePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString() + Path.GetExtension(file.FileName));
  1019 + try
  1020 + {
  1021 + using (var stream = new FileStream(tempFilePath, FileMode.Create))
  1022 + {
  1023 + await file.CopyToAsync(stream);
  1024 + }
  1025 +
  1026 + var dataTable = ExcelImportHelper.ToDataTable(tempFilePath, 0, 0);
  1027 + if (dataTable.Rows.Count == 0)
  1028 + throw NCCException.Oh("Excel文件中没有数据行");
  1029 +
  1030 + Func<string, decimal> ParseDecimal = (str) =>
  1031 + {
  1032 + if (string.IsNullOrWhiteSpace(str)) return 0;
  1033 + var cleaned = str.Trim().Replace(",", "").Replace(",", "").Replace("¥", "").Replace("$", "").Replace("元", "").Replace("%", "").Replace(" ", "");
  1034 + return decimal.TryParse(cleaned, out decimal result) ? result : 0;
  1035 + };
  1036 +
  1037 + Func<string, int> ParseInt = (str) =>
  1038 + {
  1039 + if (string.IsNullOrWhiteSpace(str)) return 0;
  1040 + var cleaned = str.Trim().Replace(",", "").Replace(",", "").Replace(" ", "");
  1041 + return int.TryParse(cleaned, out int result) ? result : 0;
  1042 + };
  1043 +
  1044 + for (int i = 1; i < dataTable.Rows.Count; i++)
  1045 + {
  1046 + try
  1047 + {
  1048 + var row = dataTable.Rows[i];
  1049 + Func<int, string> GetColumnValue = (colIndex) => colIndex < row.ItemArray.Length && row[colIndex] != null ? row[colIndex].ToString().Trim() : "";
  1050 +
  1051 + var firstColumnValue = GetColumnValue(0);
  1052 + bool isOldFormat = !string.IsNullOrWhiteSpace(firstColumnValue) && (firstColumnValue == "门店名称" || (!long.TryParse(firstColumnValue, out _) && firstColumnValue.Length > 20));
  1053 +
  1054 + int storeNameIndex = isOldFormat ? 0 : 1;
  1055 + int employeeNameIndex = isOldFormat ? 1 : 2;
  1056 + int offset = isOldFormat ? 0 : 1;
  1057 +
  1058 + var id = isOldFormat ? "" : GetColumnValue(0);
  1059 + var employeeName = GetColumnValue(employeeNameIndex);
  1060 + var storeName = GetColumnValue(storeNameIndex);
  1061 +
  1062 + if (string.IsNullOrWhiteSpace(id) && string.IsNullOrWhiteSpace(employeeName))
  1063 + continue;
  1064 +
  1065 + if (string.IsNullOrWhiteSpace(id) && !string.IsNullOrWhiteSpace(employeeName))
  1066 + {
  1067 + var matchedRecord = await _db.Queryable<LqStoreManagerSalaryStatisticsEntity>()
  1068 + .Where(x => x.EmployeeName == employeeName)
  1069 + .WhereIF(!string.IsNullOrWhiteSpace(storeName), x => x.StoreName == storeName)
  1070 + .OrderBy(x => x.CreateTime, OrderByType.Desc)
  1071 + .FirstAsync();
  1072 + if (matchedRecord != null) id = matchedRecord.Id;
  1073 + }
  1074 +
  1075 + if (string.IsNullOrWhiteSpace(employeeName))
  1076 + {
  1077 + errorMessages.Add($"第{i + 1}行:员工姓名不能为空");
  1078 + failCount++;
  1079 + continue;
  1080 + }
  1081 +
  1082 + LqStoreManagerSalaryStatisticsEntity existing = null;
  1083 + if (!string.IsNullOrWhiteSpace(id))
  1084 + {
  1085 + existing = await _db.Queryable<LqStoreManagerSalaryStatisticsEntity>()
  1086 + .Where(x => x.Id == id).FirstAsync();
  1087 +
  1088 + if (existing != null && (existing.IsLocked == 1 || existing.EmployeeConfirmStatus == 1))
  1089 + {
  1090 + skippedCount++;
  1091 + failCount++;
  1092 + continue;
  1093 + }
  1094 + }
  1095 +
  1096 + var entity = existing ?? new LqStoreManagerSalaryStatisticsEntity
  1097 + {
  1098 + Id = string.IsNullOrWhiteSpace(id) ? YitIdHelper.NextId().ToString() : id,
  1099 + EmployeeConfirmStatus = 0,
  1100 + IsLocked = 0,
  1101 + CreateTime = DateTime.Now,
  1102 + CreateUser = ""
  1103 + };
  1104 +
  1105 + // Excel字段映射(店长工资55列,第一列为ID)
  1106 + entity.StoreName = storeName;
  1107 + entity.EmployeeName = employeeName;
  1108 + entity.Position = GetColumnValue(2 + offset);
  1109 + entity.StoreBillingPerformance = ParseDecimal(GetColumnValue(3 + offset));
  1110 + entity.StoreRefundPerformance = ParseDecimal(GetColumnValue(4 + offset));
  1111 + entity.StoreTotalPerformance = ParseDecimal(GetColumnValue(5 + offset));
  1112 + entity.StoreLifeline = ParseDecimal(GetColumnValue(6 + offset));
  1113 + entity.PerformanceCompletionRate = ParseDecimal(GetColumnValue(7 + offset));
  1114 + entity.PerformanceReached = GetColumnValue(8 + offset);
  1115 + entity.HeadCount = ParseInt(GetColumnValue(9 + offset));
  1116 + entity.TargetHeadCount = ParseDecimal(GetColumnValue(10 + offset));
  1117 + entity.HeadCountReached = GetColumnValue(11 + offset);
  1118 + entity.StoreConsume = ParseDecimal(GetColumnValue(12 + offset));
  1119 + entity.TargetConsume = ParseDecimal(GetColumnValue(13 + offset));
  1120 + entity.ConsumeReached = GetColumnValue(14 + offset);
  1121 + entity.UnreachedIndicatorCount = ParseInt(GetColumnValue(15 + offset));
  1122 + entity.AssessmentDeduction = ParseDecimal(GetColumnValue(16 + offset));
  1123 + entity.GrossProfit = ParseDecimal(GetColumnValue(17 + offset));
  1124 + entity.CommissionRate = ParseDecimal(GetColumnValue(18 + offset));
  1125 + entity.CommissionAmount = ParseDecimal(GetColumnValue(19 + offset));
  1126 + entity.BaseSalary = ParseDecimal(GetColumnValue(20 + offset));
  1127 + entity.ActualBaseSalary = ParseDecimal(GetColumnValue(21 + offset));
  1128 + entity.GrossSalary = ParseDecimal(GetColumnValue(22 + offset));
  1129 + entity.WorkingDays = ParseInt(GetColumnValue(23 + offset));
  1130 + entity.LeaveDays = ParseInt(GetColumnValue(24 + offset));
  1131 + // Excel中的车补(25)、少休费(26)、全勤奖(27)字段在实体类中没有对应字段,跳过
  1132 + entity.MonthlyTrainingSubsidy = ParseDecimal(GetColumnValue(28 + offset));
  1133 + entity.MonthlyTransportSubsidy = ParseDecimal(GetColumnValue(29 + offset));
  1134 + entity.LastMonthTrainingSubsidy = ParseDecimal(GetColumnValue(30 + offset));
  1135 + entity.LastMonthTransportSubsidy = ParseDecimal(GetColumnValue(31 + offset));
  1136 + entity.TotalSubsidy = ParseDecimal(GetColumnValue(32 + offset));
  1137 + entity.MissingCard = ParseDecimal(GetColumnValue(33 + offset));
  1138 + entity.LateArrival = ParseDecimal(GetColumnValue(34 + offset));
  1139 + entity.LeaveDeduction = ParseDecimal(GetColumnValue(35 + offset));
  1140 + entity.SocialInsuranceDeduction = ParseDecimal(GetColumnValue(36 + offset));
  1141 + entity.RewardDeduction = ParseDecimal(GetColumnValue(37 + offset));
  1142 + entity.AccommodationDeduction = ParseDecimal(GetColumnValue(38 + offset));
  1143 + entity.StudyPeriodDeduction = ParseDecimal(GetColumnValue(39 + offset));
  1144 + entity.WorkClothesDeduction = ParseDecimal(GetColumnValue(40 + offset));
  1145 + entity.TotalDeduction = ParseDecimal(GetColumnValue(41 + offset));
  1146 + entity.Bonus = ParseDecimal(GetColumnValue(42 + offset));
  1147 + entity.ReturnPhoneDeposit = ParseDecimal(GetColumnValue(43 + offset));
  1148 + entity.ReturnAccommodationDeposit = ParseDecimal(GetColumnValue(44 + offset));
  1149 + entity.LastMonthSupplement = ParseDecimal(GetColumnValue(45 + offset));
  1150 + entity.ActualSalary = ParseDecimal(GetColumnValue(46 + offset));
  1151 + entity.MonthlyPaymentStatus = GetColumnValue(47 + offset);
  1152 + entity.PaidAmount = ParseDecimal(GetColumnValue(48 + offset));
  1153 + entity.PendingAmount = ParseDecimal(GetColumnValue(49 + offset));
  1154 + entity.MonthlyTotalPayment = ParseDecimal(GetColumnValue(50 + offset));
  1155 + // 处理门店分类(Excel中可能是"A类"、"B类"、"C类")
  1156 + var storeCategoryStr = GetColumnValue(51 + offset);
  1157 + if (!string.IsNullOrWhiteSpace(storeCategoryStr))
  1158 + {
  1159 + if (storeCategoryStr.Contains("A") || storeCategoryStr == "1") entity.StoreCategory = 1;
  1160 + else if (storeCategoryStr.Contains("B") || storeCategoryStr == "2") entity.StoreCategory = 2;
  1161 + else if (storeCategoryStr.Contains("C") || storeCategoryStr == "3") entity.StoreCategory = 3;
  1162 + }
  1163 + entity.IsNewStore = GetColumnValue(52 + offset) == "是" ? "是" : "否";
  1164 + entity.NewStoreProtectionStage = ParseInt(GetColumnValue(53 + offset));
  1165 + // 处理锁定状态(第55列,索引54)
  1166 + var isLockedStr = GetColumnValue(54 + offset);
  1167 + if (isLockedStr == "已锁定" || isLockedStr == "1") entity.IsLocked = 1;
  1168 + else entity.IsLocked = 0;
  1169 +
  1170 + if (existing != null)
  1171 + {
  1172 + entity.StoreId = existing.StoreId;
  1173 + entity.EmployeeId = existing.EmployeeId;
  1174 + entity.StatisticsMonth = existing.StatisticsMonth;
  1175 + }
  1176 + else
  1177 + {
  1178 + if (!string.IsNullOrWhiteSpace(employeeName))
  1179 + {
  1180 + var user = await _db.Queryable<UserEntity>()
  1181 + .Where(u => u.RealName == employeeName).FirstAsync();
  1182 + if (user != null)
  1183 + {
  1184 + entity.EmployeeId = user.Id;
  1185 + if (!string.IsNullOrEmpty(user.Mdid) && string.IsNullOrWhiteSpace(storeName))
  1186 + entity.StoreId = user.Mdid;
  1187 + }
  1188 +
  1189 + if (!string.IsNullOrWhiteSpace(storeName) && string.IsNullOrEmpty(entity.StoreId))
  1190 + {
  1191 + var store = await _db.Queryable<LqMdxxEntity>()
  1192 + .Where(s => s.Dm == storeName).FirstAsync();
  1193 + if (store != null) entity.StoreId = store.Id;
  1194 + }
  1195 + }
  1196 + }
  1197 +
  1198 + entity.UpdateTime = DateTime.Now;
  1199 + if (existing != null) recordsToUpdate.Add(entity);
  1200 + else recordsToInsert.Add(entity);
  1201 + successCount++;
  1202 + }
  1203 + catch (Exception ex)
  1204 + {
  1205 + errorMessages.Add($"第{i + 1}行数据处理失败: {ex.Message}");
  1206 + failCount++;
  1207 + }
  1208 + }
  1209 + }
  1210 + finally
  1211 + {
  1212 + if (File.Exists(tempFilePath)) File.Delete(tempFilePath);
  1213 + }
  1214 +
  1215 + if (recordsToInsert.Any()) await _db.Insertable(recordsToInsert).ExecuteCommandAsync();
  1216 + if (recordsToUpdate.Any()) await _db.Updateable(recordsToUpdate).ExecuteCommandAsync();
  1217 +
  1218 + return new
  1219 + {
  1220 + success = true,
  1221 + message = $"导入完成:成功 {successCount} 条,失败 {failCount} 条,跳过 {skippedCount} 条(已锁定或已确认)",
  1222 + successCount,
  1223 + failCount,
  1224 + skippedCount,
  1225 + errors = errorMessages
  1226 + };
  1227 + }
  1228 + catch (Exception ex)
  1229 + {
  1230 + throw NCCException.Oh($"导入店长工资数据失败: {ex.Message}");
  1231 + }
  1232 + }
  1233 +
  1234 + #endregion
631 1235 }
632 1236 }
633 1237  
... ...
netcore/src/Modularity/Extend/NCC.Extend/LqTechGeneralManagerSalaryService.cs
1 1 using Microsoft.AspNetCore.Authorization;
2 2 using Microsoft.AspNetCore.Mvc;
  3 +using Microsoft.Extensions.Logging;
3 4 using NCC.Common.Filter;
4 5 using NCC.Common.Helper;
5 6 using NCC.Dependency;
6 7 using NCC.DynamicApiController;
  8 +using NCC.Extend.Entitys.Dto.LqSalary;
7 9 using NCC.Extend.Entitys.Dto.LqTechGeneralManagerSalary;
8 10 using NCC.Extend.Entitys.lq_attendance_summary;
9 11 using NCC.Extend.Entitys.lq_hytk_hytk;
... ... @@ -16,14 +18,18 @@ using NCC.Extend.Entitys.lq_md_general_manager_lifeline;
16 18 using NCC.Extend.Entitys.lq_mdxx;
17 19 using NCC.Extend.Entitys.lq_tech_general_manager_salary_statistics;
18 20 using NCC.Extend.Entitys.lq_xmzl;
  21 +using NCC.FriendlyException;
19 22 using NCC.System.Entitys.Permission;
20 23 using SqlSugar;
21 24 using System;
22 25 using System.Collections.Generic;
  26 +using System.Data;
  27 +using System.IO;
23 28 using System.Linq;
24 29 using System.Threading.Tasks;
25 30 using Yitter.IdGenerator;
26 31 using Newtonsoft.Json;
  32 +using Microsoft.AspNetCore.Http;
27 33  
28 34 namespace NCC.Extend
29 35 {
... ... @@ -35,13 +41,15 @@ namespace NCC.Extend
35 41 public class LqTechGeneralManagerSalaryService : IDynamicApiController, ITransient
36 42 {
37 43 private readonly ISqlSugarClient _db;
  44 + private readonly ILogger<LqTechGeneralManagerSalaryService> _logger;
38 45  
39 46 /// <summary>
40 47 /// 初始化一个<see cref="LqTechGeneralManagerSalaryService"/>类型的新实例
41 48 /// </summary>
42   - public LqTechGeneralManagerSalaryService(ISqlSugarClient db)
  49 + public LqTechGeneralManagerSalaryService(ISqlSugarClient db, ILogger<LqTechGeneralManagerSalaryService> logger)
43 50 {
44 51 _db = db;
  52 + _logger = logger;
45 53 }
46 54  
47 55 /// <summary>
... ... @@ -54,17 +62,7 @@ namespace NCC.Extend
54 62 {
55 63 var monthStr = $"{input.Year}{input.Month:D2}";
56 64  
57   - // 1. 检查当月是否已生成工资数据
58   - var exists = await _db.Queryable<LqTechGeneralManagerSalaryStatisticsEntity>()
59   - .AnyAsync(x => x.StatisticsMonth == monthStr);
60   -
61   - // 2. 如果没有数据,则进行计算
62   - if (!exists)
63   - {
64   - await CalculateTechGeneralManagerSalary(input.Year, input.Month);
65   - }
66   -
67   - // 3. 查询数据
  65 + // 查询数据
68 66 var query = _db.Queryable<LqTechGeneralManagerSalaryStatisticsEntity>()
69 67 .Where(x => x.StatisticsMonth == monthStr);
70 68  
... ... @@ -132,6 +130,73 @@ namespace NCC.Extend
132 130 }
133 131  
134 132 /// <summary>
  133 + /// 通过月份和员工ID查询工资
  134 + /// </summary>
  135 + [HttpGet("query-by-employee")]
  136 + public async Task<TechGeneralManagerSalaryOutput> GetSalaryByEmployee([FromQuery] SalaryQueryByEmployeeInput input)
  137 + {
  138 + if (input.Year <= 0 || input.Month <= 0 || input.Month > 12)
  139 + throw NCCException.Oh("年份和月份参数不正确");
  140 + if (string.IsNullOrWhiteSpace(input.EmployeeId))
  141 + throw NCCException.Oh("员工ID不能为空");
  142 + var monthStr = $"{input.Year}{input.Month:D2}";
  143 + var salary = await _db.Queryable<LqTechGeneralManagerSalaryStatisticsEntity>()
  144 + .Where(x => x.StatisticsMonth == monthStr && x.EmployeeId == input.EmployeeId)
  145 + .Select(x => new TechGeneralManagerSalaryOutput
  146 + {
  147 + Id = x.Id,
  148 + StatisticsMonth = x.StatisticsMonth,
  149 + Position = x.Position,
  150 + EmployeeName = x.EmployeeName,
  151 + EmployeeId = x.EmployeeId,
  152 + EmployeeAccount = x.EmployeeAccount,
  153 + IsTerminated = x.IsTerminated,
  154 + StoreDetail = x.StoreDetail,
  155 + TraceabilityAmount = x.TraceabilityAmount,
  156 + CellAmount = x.CellAmount,
  157 + BaseSalary = x.BaseSalary,
  158 + TraceabilityCommissionRate = x.TraceabilityCommissionRate,
  159 + TraceabilityCommissionAmount = x.TraceabilityCommissionAmount,
  160 + CellCommissionRate = x.CellCommissionRate,
  161 + CellCommissionAmount = x.CellCommissionAmount,
  162 + TotalCommission = x.TotalCommission,
  163 + WorkingDays = x.WorkingDays,
  164 + LeaveDays = x.LeaveDays,
  165 + CalculatedGrossSalary = x.CalculatedGrossSalary,
  166 + FinalGrossSalary = x.FinalGrossSalary,
  167 + MonthlyTrainingSubsidy = x.MonthlyTrainingSubsidy,
  168 + MonthlyTransportSubsidy = x.MonthlyTransportSubsidy,
  169 + LastMonthTrainingSubsidy = x.LastMonthTrainingSubsidy,
  170 + LastMonthTransportSubsidy = x.LastMonthTransportSubsidy,
  171 + TotalSubsidy = x.TotalSubsidy,
  172 + MissingCard = x.MissingCard,
  173 + LateArrival = x.LateArrival,
  174 + LeaveDeduction = x.LeaveDeduction,
  175 + SocialInsuranceDeduction = x.SocialInsuranceDeduction,
  176 + RewardDeduction = x.RewardDeduction,
  177 + AccommodationDeduction = x.AccommodationDeduction,
  178 + StudyPeriodDeduction = x.StudyPeriodDeduction,
  179 + WorkClothesDeduction = x.WorkClothesDeduction,
  180 + TotalDeduction = x.TotalDeduction,
  181 + Bonus = x.Bonus,
  182 + ReturnPhoneDeposit = x.ReturnPhoneDeposit,
  183 + ReturnAccommodationDeposit = x.ReturnAccommodationDeposit,
  184 + ActualSalary = x.ActualSalary,
  185 + MonthlyPaymentStatus = x.MonthlyPaymentStatus,
  186 + PaidAmount = x.PaidAmount,
  187 + PendingAmount = x.PendingAmount,
  188 + LastMonthSupplement = x.LastMonthSupplement,
  189 + MonthlyTotalPayment = x.MonthlyTotalPayment,
  190 + IsLocked = x.IsLocked,
  191 + UpdateTime = x.UpdateTime
  192 + })
  193 + .FirstAsync();
  194 + if (salary == null)
  195 + throw NCCException.Oh($"未找到员工{input.EmployeeId}在{input.Year}年{input.Month}月的工资记录");
  196 + return salary;
  197 + }
  198 +
  199 + /// <summary>
135 200 /// 计算科技部总经理工资
136 201 /// </summary>
137 202 /// <param name="year">年份</param>
... ... @@ -429,12 +494,41 @@ namespace NCC.Extend
429 494 // 3. 保存数据
430 495 if (managerStats.Any())
431 496 {
432   - // 先删除当月旧数据 (防止重复)
433   - await _db.Deleteable<LqTechGeneralManagerSalaryStatisticsEntity>()
434   - .Where(x => x.StatisticsMonth == monthStr)
435   - .ExecuteCommandAsync();
436   -
437   - await _db.Insertable(managerStats.Values.ToList()).ExecuteCommandAsync();
  497 + var existingRecords = await _db.Queryable<LqTechGeneralManagerSalaryStatisticsEntity>()
  498 + .Where(x => x.StatisticsMonth == monthStr).ToListAsync();
  499 + var existingDict = existingRecords.Where(x => !string.IsNullOrEmpty(x.EmployeeId))
  500 + .GroupBy(x => x.EmployeeId).ToDictionary(g => g.Key, g => g.First());
  501 + var recordsToInsert = new List<LqTechGeneralManagerSalaryStatisticsEntity>();
  502 + var recordsToUpdate = new List<LqTechGeneralManagerSalaryStatisticsEntity>();
  503 + var skippedCount = 0;
  504 + foreach (var salary in managerStats.Values)
  505 + {
  506 + if (existingDict.ContainsKey(salary.EmployeeId))
  507 + {
  508 + var existing = existingDict[salary.EmployeeId];
  509 + if (existing.IsLocked == 1 || existing.EmployeeConfirmStatus == 1) { skippedCount++; continue; }
  510 + salary.Id = existing.Id;
  511 + salary.EmployeeConfirmStatus = existing.EmployeeConfirmStatus;
  512 + salary.EmployeeConfirmTime = existing.EmployeeConfirmTime;
  513 + salary.EmployeeConfirmRemark = existing.EmployeeConfirmRemark;
  514 + salary.IsLocked = existing.IsLocked;
  515 + salary.CreateTime = existing.CreateTime;
  516 + salary.CreateUser = existing.CreateUser;
  517 + recordsToUpdate.Add(salary);
  518 + }
  519 + else
  520 + {
  521 + salary.Id = YitIdHelper.NextId().ToString();
  522 + salary.EmployeeConfirmStatus = 0;
  523 + salary.IsLocked = 0;
  524 + salary.CreateTime = DateTime.Now;
  525 + salary.CreateUser = "";
  526 + recordsToInsert.Add(salary);
  527 + }
  528 + }
  529 + if (recordsToInsert.Any()) await _db.Insertable(recordsToInsert).ExecuteCommandAsync();
  530 + if (recordsToUpdate.Any()) await _db.Updateable(recordsToUpdate).ExecuteCommandAsync();
  531 + if (skippedCount > 0) _logger.LogWarning($"计算工资时跳过了 {skippedCount} 条已锁定或已确认的记录(月份:{monthStr})");
438 532 }
439 533 }
440 534  
... ... @@ -530,8 +624,424 @@ namespace NCC.Extend
530 624 public decimal TraceabilityAmount { get; set; }
531 625 public decimal CellBillingAmount { get; set; }
532 626 public decimal CellRefundAmount { get; set; }
533   - public decimal CellAmount { get; set; }
  627 + public decimal CellAmount { get; set; }
  628 + }
  629 +
  630 + #region 员工工资确认
  631 +
  632 + /// <summary>
  633 + /// 员工确认工资条
  634 + /// </summary>
  635 + /// <remarks>
  636 + /// 员工确认自己的工资条,确认后工资数据不可再修改
  637 + ///
  638 + /// 示例请求:
  639 + /// <code>
  640 + /// {
  641 + /// "id": "工资记录ID",
  642 + /// "employeeId": "员工ID",
  643 + /// "remark": "确认备注(可选)"
  644 + /// }
  645 + /// </code>
  646 + ///
  647 + /// 参数说明:
  648 + /// - id: 工资记录ID(必填)
  649 + /// - employeeId: 员工ID(必填)
  650 + /// - remark: 确认备注(可选)
  651 + ///
  652 + /// 注意事项:
  653 + /// - 只能确认自己的工资条
  654 + /// - 只能确认已锁定的工资条(IsLocked = 1)
  655 + /// - 已确认的工资条不能重复确认
  656 + /// </remarks>
  657 + /// <param name="input">确认参数</param>
  658 + /// <returns>操作结果</returns>
  659 + /// <response code="200">确认成功</response>
  660 + /// <response code="400">参数错误或记录不存在</response>
  661 + [HttpPost("confirm")]
  662 + public async Task<string> ConfirmSalary([FromBody] SalaryConfirmInput input)
  663 + {
  664 + try
  665 + {
  666 + if (string.IsNullOrWhiteSpace(input.Id) || string.IsNullOrWhiteSpace(input.EmployeeId))
  667 + throw NCCException.Oh("工资记录ID和员工ID不能为空");
  668 + var salary = await _db.Queryable<LqTechGeneralManagerSalaryStatisticsEntity>()
  669 + .Where(s => s.Id == input.Id && s.EmployeeId == input.EmployeeId).FirstAsync();
  670 + if (salary == null) throw NCCException.Oh("工资记录不存在或不属于该员工");
  671 + if (salary.EmployeeConfirmStatus == 1) throw NCCException.Oh("该工资条已确认,不能重复确认");
  672 + if (salary.IsLocked != 1) throw NCCException.Oh("该工资条尚未锁定,请等待管理员锁定后再确认");
  673 + salary.EmployeeConfirmStatus = 1;
  674 + salary.EmployeeConfirmTime = DateTime.Now;
  675 + salary.EmployeeConfirmRemark = input.Remark;
  676 + salary.UpdateTime = DateTime.Now;
  677 + await _db.Updateable(salary).ExecuteCommandAsync();
  678 + return "确认成功";
  679 + }
  680 + catch (Exception ex)
  681 + {
  682 + throw NCCException.Oh($"确认工资条失败: {ex.Message}");
  683 + }
  684 + }
  685 +
  686 + #endregion
  687 +
  688 + #region 工资锁定/解锁
  689 +
  690 + /// <summary>
  691 + /// 批量锁定/解锁工资条
  692 + /// </summary>
  693 + [HttpPost("lock")]
  694 + public async Task<string> LockSalary([FromBody] SalaryLockInput input)
  695 + {
  696 + try
  697 + {
  698 + if (input == null || input.Ids == null || !input.Ids.Any())
  699 + throw NCCException.Oh("工资记录ID列表不能为空");
  700 + var salaries = await _db.Queryable<LqTechGeneralManagerSalaryStatisticsEntity>()
  701 + .Where(s => input.Ids.Contains(s.Id))
  702 + .ToListAsync();
  703 + if (!salaries.Any())
  704 + throw NCCException.Oh("未找到指定的工资记录");
  705 + var lockedCount = 0;
  706 + var unlockedCount = 0;
  707 + var skippedCount = 0;
  708 + foreach (var salary in salaries)
  709 + {
  710 + if (salary.EmployeeConfirmStatus == 1 && !input.IsLocked)
  711 + {
  712 + skippedCount++;
  713 + continue;
  714 + }
  715 + salary.IsLocked = input.IsLocked ? 1 : 0;
  716 + salary.UpdateTime = DateTime.Now;
  717 + if (input.IsLocked) lockedCount++; else unlockedCount++;
  718 + }
  719 + await _db.Updateable(salaries).ExecuteCommandAsync();
  720 + var action = input.IsLocked ? "锁定" : "解锁";
  721 + var count = input.IsLocked ? lockedCount : unlockedCount;
  722 + var message = $"{action}成功:{count}条";
  723 + if (skippedCount > 0)
  724 + message += $",跳过{skippedCount}条(已确认的记录不能解锁)";
  725 + return message;
  726 + }
  727 + catch (Exception ex)
  728 + {
  729 + throw NCCException.Oh($"锁定/解锁工资条失败: {ex.Message}");
  730 + }
  731 + }
  732 +
  733 + /// <summary>
  734 + /// 批量锁定当月所有工资
  735 + /// </summary>
  736 + /// <param name="input">批量锁定输入参数</param>
  737 + /// <returns>锁定结果</returns>
  738 + [HttpPost("lock-by-month")]
  739 + public async Task<dynamic> LockSalaryByMonth([FromBody] SalaryLockByMonthInput input)
  740 + {
  741 + try
  742 + {
  743 + if (input == null)
  744 + throw NCCException.Oh("参数不能为空");
  745 +
  746 + if (input.Year <= 0 || input.Month <= 0 || input.Month > 12)
  747 + throw NCCException.Oh("年份和月份参数不正确");
  748 +
  749 + var monthStr = $"{input.Year}{input.Month:D2}";
  750 +
  751 + var salaries = await _db.Queryable<LqTechGeneralManagerSalaryStatisticsEntity>()
  752 + .Where(s => s.StatisticsMonth == monthStr)
  753 + .ToListAsync();
  754 +
  755 + if (!salaries.Any())
  756 + throw NCCException.Oh($"未找到{input.Year}年{input.Month}月的工资记录");
  757 +
  758 + var lockedCount = 0;
  759 + var unlockedCount = 0;
  760 + var skippedCount = 0;
  761 + var alreadyLockedCount = 0;
  762 +
  763 + foreach (var salary in salaries)
  764 + {
  765 + if (salary.EmployeeConfirmStatus == 1 && !input.IsLocked)
  766 + {
  767 + skippedCount++;
  768 + continue;
  769 + }
  770 +
  771 + if (salary.IsLocked == 1 && input.IsLocked)
  772 + {
  773 + alreadyLockedCount++;
  774 + continue;
  775 + }
  776 +
  777 + if (salary.IsLocked == 0 && !input.IsLocked)
  778 + {
  779 + alreadyLockedCount++;
  780 + continue;
  781 + }
  782 +
  783 + salary.IsLocked = input.IsLocked ? 1 : 0;
  784 + salary.UpdateTime = DateTime.Now;
  785 +
  786 + if (input.IsLocked)
  787 + lockedCount++;
  788 + else
  789 + unlockedCount++;
  790 + }
  791 +
  792 + if (lockedCount > 0 || unlockedCount > 0)
  793 + {
  794 + var salariesToUpdate = salaries.Where(s =>
  795 + (input.IsLocked && s.IsLocked == 0) ||
  796 + (!input.IsLocked && s.IsLocked == 1 && s.EmployeeConfirmStatus != 1)
  797 + ).ToList();
  798 +
  799 + if (salariesToUpdate.Any())
  800 + {
  801 + await _db.Updateable(salariesToUpdate)
  802 + .UpdateColumns(s => new { s.IsLocked, s.UpdateTime })
  803 + .ExecuteCommandAsync();
  804 + }
  805 + }
  806 +
  807 + var action = input.IsLocked ? "锁定" : "解锁";
  808 + var count = input.IsLocked ? lockedCount : unlockedCount;
  809 + var message = $"{action}成功:{count}条";
  810 +
  811 + if (alreadyLockedCount > 0)
  812 + message += $",跳过{alreadyLockedCount}条(已是{action}状态)";
  813 +
  814 + if (skippedCount > 0)
  815 + message += $",跳过{skippedCount}条(已确认的记录不能解锁)";
  816 +
  817 + return new
  818 + {
  819 + success = true,
  820 + message = message,
  821 + total = salaries.Count,
  822 + locked = lockedCount,
  823 + unlocked = unlockedCount,
  824 + skipped = skippedCount,
  825 + alreadyLocked = alreadyLockedCount
  826 + };
  827 + }
  828 + catch (Exception ex)
  829 + {
  830 + _logger.LogError(ex, "批量锁定当月工资失败");
  831 + var action = input?.IsLocked == true ? "锁定" : "解锁";
  832 + throw NCCException.Oh($"批量{action}当月工资失败: {ex.Message}");
  833 + }
534 834 }
  835 +
  836 + #endregion
  837 +
  838 + #region 导入工资
  839 +
  840 + /// <summary>
  841 + /// 从Excel导入科技部总经理工资数据
  842 + /// </summary>
  843 + /// <param name="file">Excel文件</param>
  844 + /// <returns>导入结果</returns>
  845 + [HttpPost("import")]
  846 + public async Task<dynamic> ImportSalaryFromExcel(IFormFile file)
  847 + {
  848 + try
  849 + {
  850 + if (file == null || file.Length == 0)
  851 + throw NCCException.Oh("请选择要上传的Excel文件");
  852 +
  853 + var allowedExtensions = new[] { ".xlsx", ".xls" };
  854 + var fileExtension = Path.GetExtension(file.FileName).ToLowerInvariant();
  855 + if (!allowedExtensions.Contains(fileExtension))
  856 + throw NCCException.Oh("只支持.xlsx和.xls格式的Excel文件");
  857 +
  858 + var recordsToInsert = new List<LqTechGeneralManagerSalaryStatisticsEntity>();
  859 + var recordsToUpdate = new List<LqTechGeneralManagerSalaryStatisticsEntity>();
  860 + var errorMessages = new List<string>();
  861 + var successCount = 0;
  862 + var failCount = 0;
  863 + var skippedCount = 0;
  864 +
  865 + var tempFilePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString() + Path.GetExtension(file.FileName));
  866 + try
  867 + {
  868 + using (var stream = new FileStream(tempFilePath, FileMode.Create))
  869 + {
  870 + await file.CopyToAsync(stream);
  871 + }
  872 +
  873 + var dataTable = ExcelImportHelper.ToDataTable(tempFilePath, 0, 0);
  874 + if (dataTable.Rows.Count == 0)
  875 + throw NCCException.Oh("Excel文件中没有数据行");
  876 +
  877 + Func<string, decimal> ParseDecimal = (str) =>
  878 + {
  879 + if (string.IsNullOrWhiteSpace(str)) return 0;
  880 + var cleaned = str.Trim().Replace(",", "").Replace(",", "").Replace("¥", "").Replace("$", "").Replace("元", "").Replace("%", "").Replace(" ", "");
  881 + return decimal.TryParse(cleaned, out decimal result) ? result : 0;
  882 + };
  883 +
  884 + Func<string, int> ParseInt = (str) =>
  885 + {
  886 + if (string.IsNullOrWhiteSpace(str)) return 0;
  887 + var cleaned = str.Trim().Replace(",", "").Replace(",", "").Replace(" ", "");
  888 + return int.TryParse(cleaned, out int result) ? result : 0;
  889 + };
  890 +
  891 + for (int i = 1; i < dataTable.Rows.Count; i++)
  892 + {
  893 + try
  894 + {
  895 + var row = dataTable.Rows[i];
  896 + Func<int, string> GetColumnValue = (colIndex) => colIndex < row.ItemArray.Length && row[colIndex] != null ? row[colIndex].ToString().Trim() : "";
  897 +
  898 + var firstColumnValue = GetColumnValue(0);
  899 + bool isOldFormat = !string.IsNullOrWhiteSpace(firstColumnValue) && (firstColumnValue == "员工姓名" || (!long.TryParse(firstColumnValue, out _) && firstColumnValue.Length > 20));
  900 +
  901 + int employeeNameIndex = isOldFormat ? 0 : 1;
  902 + int offset = isOldFormat ? 0 : 1;
  903 +
  904 + var id = isOldFormat ? "" : GetColumnValue(0);
  905 + var employeeName = GetColumnValue(employeeNameIndex);
  906 +
  907 + if (string.IsNullOrWhiteSpace(id) && string.IsNullOrWhiteSpace(employeeName))
  908 + continue;
  909 +
  910 + if (string.IsNullOrWhiteSpace(id) && !string.IsNullOrWhiteSpace(employeeName))
  911 + {
  912 + var matchedRecord = await _db.Queryable<LqTechGeneralManagerSalaryStatisticsEntity>()
  913 + .Where(x => x.EmployeeName == employeeName)
  914 + .OrderBy(x => x.CreateTime, OrderByType.Desc)
  915 + .FirstAsync();
  916 + if (matchedRecord != null) id = matchedRecord.Id;
  917 + }
  918 +
  919 + if (string.IsNullOrWhiteSpace(employeeName))
  920 + {
  921 + errorMessages.Add($"第{i + 1}行:员工姓名不能为空");
  922 + failCount++;
  923 + continue;
  924 + }
  925 +
  926 + LqTechGeneralManagerSalaryStatisticsEntity existing = null;
  927 + if (!string.IsNullOrWhiteSpace(id))
  928 + {
  929 + existing = await _db.Queryable<LqTechGeneralManagerSalaryStatisticsEntity>()
  930 + .Where(x => x.Id == id).FirstAsync();
  931 +
  932 + if (existing != null && (existing.IsLocked == 1 || existing.EmployeeConfirmStatus == 1))
  933 + {
  934 + skippedCount++;
  935 + failCount++;
  936 + continue;
  937 + }
  938 + }
  939 +
  940 + var entity = existing ?? new LqTechGeneralManagerSalaryStatisticsEntity
  941 + {
  942 + Id = string.IsNullOrWhiteSpace(id) ? YitIdHelper.NextId().ToString() : id,
  943 + EmployeeConfirmStatus = 0,
  944 + IsLocked = 0,
  945 + CreateTime = DateTime.Now,
  946 + CreateUser = ""
  947 + };
  948 +
  949 + // Excel字段映射(科技部总经理工资41列:员工姓名,员工账号,核算岗位,统计月份,是否离职,溯源金额,Cell金额,底薪,溯源金额提成比例,溯源金额提成金额,Cell金额提成比例,Cell金额提成金额,提成合计...)
  950 + entity.EmployeeName = employeeName;
  951 + entity.EmployeeAccount = GetColumnValue(1 + offset);
  952 + entity.Position = GetColumnValue(2 + offset);
  953 + entity.StatisticsMonth = GetColumnValue(3 + offset);
  954 + entity.IsTerminated = GetColumnValue(4 + offset) == "离职" || GetColumnValue(4 + offset) == "1" ? 1 : 0;
  955 + entity.TraceabilityAmount = ParseDecimal(GetColumnValue(5 + offset));
  956 + entity.CellAmount = ParseDecimal(GetColumnValue(6 + offset));
  957 + entity.BaseSalary = ParseDecimal(GetColumnValue(7 + offset));
  958 + entity.TraceabilityCommissionRate = ParseDecimal(GetColumnValue(8 + offset));
  959 + entity.TraceabilityCommissionAmount = ParseDecimal(GetColumnValue(9 + offset));
  960 + entity.CellCommissionRate = ParseDecimal(GetColumnValue(10 + offset));
  961 + entity.CellCommissionAmount = ParseDecimal(GetColumnValue(11 + offset));
  962 + entity.TotalCommission = ParseDecimal(GetColumnValue(12 + offset));
  963 + entity.WorkingDays = ParseDecimal(GetColumnValue(13 + offset));
  964 + entity.LeaveDays = ParseDecimal(GetColumnValue(14 + offset));
  965 + entity.CalculatedGrossSalary = ParseDecimal(GetColumnValue(15 + offset));
  966 + entity.FinalGrossSalary = ParseDecimal(GetColumnValue(16 + offset));
  967 + entity.MonthlyTrainingSubsidy = ParseDecimal(GetColumnValue(17 + offset));
  968 + entity.MonthlyTransportSubsidy = ParseDecimal(GetColumnValue(18 + offset));
  969 + entity.LastMonthTrainingSubsidy = ParseDecimal(GetColumnValue(19 + offset));
  970 + entity.LastMonthTransportSubsidy = ParseDecimal(GetColumnValue(20 + offset));
  971 + entity.TotalSubsidy = ParseDecimal(GetColumnValue(21 + offset));
  972 + entity.MissingCard = ParseDecimal(GetColumnValue(22 + offset));
  973 + entity.LateArrival = ParseDecimal(GetColumnValue(23 + offset));
  974 + entity.LeaveDeduction = ParseDecimal(GetColumnValue(24 + offset));
  975 + entity.SocialInsuranceDeduction = ParseDecimal(GetColumnValue(25 + offset));
  976 + entity.RewardDeduction = ParseDecimal(GetColumnValue(26 + offset));
  977 + entity.AccommodationDeduction = ParseDecimal(GetColumnValue(27 + offset));
  978 + entity.StudyPeriodDeduction = ParseDecimal(GetColumnValue(28 + offset));
  979 + entity.WorkClothesDeduction = ParseDecimal(GetColumnValue(29 + offset));
  980 + entity.TotalDeduction = ParseDecimal(GetColumnValue(30 + offset));
  981 + entity.Bonus = ParseDecimal(GetColumnValue(31 + offset));
  982 + entity.ReturnPhoneDeposit = ParseDecimal(GetColumnValue(32 + offset));
  983 + entity.ReturnAccommodationDeposit = ParseDecimal(GetColumnValue(33 + offset));
  984 + entity.LastMonthSupplement = ParseDecimal(GetColumnValue(34 + offset));
  985 + entity.ActualSalary = ParseDecimal(GetColumnValue(35 + offset));
  986 + entity.MonthlyPaymentStatus = GetColumnValue(36 + offset);
  987 + entity.PaidAmount = ParseDecimal(GetColumnValue(37 + offset));
  988 + entity.PendingAmount = ParseDecimal(GetColumnValue(38 + offset));
  989 + entity.MonthlyTotalPayment = ParseDecimal(GetColumnValue(39 + offset));
  990 + var isLockedStr = GetColumnValue(40 + offset);
  991 + entity.IsLocked = isLockedStr == "已锁定" || isLockedStr == "1" || isLockedStr == "锁定" ? 1 : 0;
  992 +
  993 + if (existing != null)
  994 + {
  995 + entity.EmployeeId = existing.EmployeeId;
  996 + entity.StoreDetail = existing.StoreDetail;
  997 + }
  998 + else
  999 + {
  1000 + if (!string.IsNullOrWhiteSpace(employeeName))
  1001 + {
  1002 + var user = await _db.Queryable<UserEntity>()
  1003 + .Where(u => u.RealName == employeeName).FirstAsync();
  1004 + if (user != null) entity.EmployeeId = user.Id;
  1005 + }
  1006 + }
  1007 +
  1008 + entity.UpdateTime = DateTime.Now;
  1009 + if (existing != null) recordsToUpdate.Add(entity);
  1010 + else recordsToInsert.Add(entity);
  1011 + successCount++;
  1012 + }
  1013 + catch (Exception ex)
  1014 + {
  1015 + errorMessages.Add($"第{i + 1}行数据处理失败: {ex.Message}");
  1016 + failCount++;
  1017 + }
  1018 + }
  1019 + }
  1020 + finally
  1021 + {
  1022 + if (File.Exists(tempFilePath)) File.Delete(tempFilePath);
  1023 + }
  1024 +
  1025 + if (recordsToInsert.Any()) await _db.Insertable(recordsToInsert).ExecuteCommandAsync();
  1026 + if (recordsToUpdate.Any()) await _db.Updateable(recordsToUpdate).ExecuteCommandAsync();
  1027 +
  1028 + return new
  1029 + {
  1030 + success = true,
  1031 + message = $"导入完成:成功 {successCount} 条,失败 {failCount} 条,跳过 {skippedCount} 条(已锁定或已确认)",
  1032 + successCount,
  1033 + failCount,
  1034 + skippedCount,
  1035 + errors = errorMessages
  1036 + };
  1037 + }
  1038 + catch (Exception ex)
  1039 + {
  1040 + throw NCCException.Oh($"导入科技部总经理工资数据失败: {ex.Message}");
  1041 + }
  1042 + }
  1043 +
  1044 + #endregion
535 1045 }
536 1046 }
537 1047  
... ...
netcore/src/Modularity/Extend/NCC.Extend/LqTechTeacherSalaryService.cs
1 1 using Microsoft.AspNetCore.Authorization;
  2 +using Microsoft.AspNetCore.Http;
2 3 using Microsoft.AspNetCore.Mvc;
  4 +using Microsoft.Extensions.Logging;
3 5 using NCC.Common.Filter;
4 6 using NCC.Common.Helper;
5 7 using NCC.Dependency;
6 8 using NCC.DynamicApiController;
  9 +using NCC.Extend.Entitys.Dto.LqSalary;
7 10 using NCC.Extend.Entitys.Dto.LqTechTeacherSalary;
8 11 using NCC.Extend.Entitys.lq_hytk_kjbsyj;
9 12 using NCC.Extend.Entitys.lq_kd_kjbsyj;
... ... @@ -14,10 +17,13 @@ using NCC.Extend.Entitys.lq_tech_teacher_salary_statistics;
14 17 using NCC.Extend.Entitys.lq_xh_hyhk;
15 18 using NCC.Extend.Entitys.lq_xh_kjbsyj;
16 19 using NCC.Extend.Entitys.lq_person_times_record;
  20 +using NCC.FriendlyException;
17 21 using NCC.System.Entitys.Permission;
18 22 using SqlSugar;
19 23 using System;
20 24 using System.Collections.Generic;
  25 +using System.Data;
  26 +using System.IO;
21 27 using System.Linq;
22 28 using System.Threading.Tasks;
23 29 using Yitter.IdGenerator;
... ... @@ -32,13 +38,15 @@ namespace NCC.Extend
32 38 public class LqTechTeacherSalaryService : IDynamicApiController, ITransient
33 39 {
34 40 private readonly ISqlSugarClient _db;
  41 + private readonly ILogger<LqTechTeacherSalaryService> _logger;
35 42  
36 43 /// <summary>
37 44 /// 初始化一个<see cref="LqTechTeacherSalaryService"/>类型的新实例
38 45 /// </summary>
39   - public LqTechTeacherSalaryService(ISqlSugarClient db)
  46 + public LqTechTeacherSalaryService(ISqlSugarClient db, ILogger<LqTechTeacherSalaryService> logger)
40 47 {
41 48 _db = db;
  49 + _logger = logger;
42 50 }
43 51  
44 52 /// <summary>
... ... @@ -51,17 +59,7 @@ namespace NCC.Extend
51 59 {
52 60 var monthStr = $"{input.Year}{input.Month:D2}";
53 61  
54   - // 1. 检查当月是否已生成工资数据
55   - var exists = await _db.Queryable<LqTechTeacherSalaryStatisticsEntity>()
56   - .AnyAsync(x => x.StatisticsMonth == monthStr);
57   -
58   - // 2. 如果没有数据,则进行计算
59   - if (!exists)
60   - {
61   - await CalculateTechTeacherSalary(input.Year, input.Month);
62   - }
63   -
64   - // 3. 查询数据
  62 + // 查询数据
65 63 var query = _db.Queryable<LqTechTeacherSalaryStatisticsEntity>()
66 64 .Where(x => x.StatisticsMonth == monthStr);
67 65  
... ... @@ -146,6 +144,90 @@ namespace NCC.Extend
146 144 }
147 145  
148 146 /// <summary>
  147 + /// 通过月份和员工ID查询工资
  148 + /// </summary>
  149 + [HttpGet("query-by-employee")]
  150 + public async Task<TechTeacherSalaryOutput> GetSalaryByEmployee([FromQuery] SalaryQueryByEmployeeInput input)
  151 + {
  152 + if (input.Year <= 0 || input.Month <= 0 || input.Month > 12)
  153 + throw NCCException.Oh("年份和月份参数不正确");
  154 + if (string.IsNullOrWhiteSpace(input.EmployeeId))
  155 + throw NCCException.Oh("员工ID不能为空");
  156 + var monthStr = $"{input.Year}{input.Month:D2}";
  157 + var salary = await _db.Queryable<LqTechTeacherSalaryStatisticsEntity>()
  158 + .Where(x => x.StatisticsMonth == monthStr && x.EmployeeId == input.EmployeeId)
  159 + .Select(x => new TechTeacherSalaryOutput
  160 + {
  161 + Id = x.Id,
  162 + StatisticsMonth = x.StatisticsMonth,
  163 + StoreId = x.StoreId,
  164 + StoreName = x.StoreName,
  165 + Position = x.Position,
  166 + EmployeeName = x.EmployeeName,
  167 + EmployeeId = x.EmployeeId,
  168 + EmployeeAccount = x.EmployeeAccount,
  169 + OrderAchievement = x.OrderAchievement,
  170 + ConsumeAchievement = x.ConsumeAchievement,
  171 + RefundAchievement = x.RefundAchievement,
  172 + TotalPerformance = x.TotalPerformance,
  173 + ProjectCount = x.ProjectCount,
  174 + BaseSalaryLevel = x.BaseSalaryLevel,
  175 + BaseSalary = x.BaseSalary,
  176 + PerformanceCommissionRate = x.PerformanceCommissionRate,
  177 + PerformanceCommissionAmount = x.PerformanceCommissionAmount,
  178 + ConsumeCommissionRate = x.ConsumeCommissionRate,
  179 + ConsumeCommissionAmount = x.ConsumeCommissionAmount,
  180 + HandworkFee = x.HandworkFee,
  181 + WorkingDays = x.WorkingDays,
  182 + LeaveDays = x.LeaveDays,
  183 + TotalCommission = x.TotalCommission,
  184 + TransportationAllowance = x.TransportationAllowance,
  185 + LessRest = x.LessRest,
  186 + FullAttendance = x.FullAttendance,
  187 + CalculatedGrossSalary = x.CalculatedGrossSalary,
  188 + GuaranteedSalary = x.GuaranteedSalary,
  189 + GuaranteedLeaveDeduction = x.GuaranteedLeaveDeduction,
  190 + GuaranteedBaseSalary = x.GuaranteedBaseSalary,
  191 + GuaranteedSupplement = x.GuaranteedSupplement,
  192 + FinalGrossSalary = x.FinalGrossSalary,
  193 + MonthlyTrainingSubsidy = x.MonthlyTrainingSubsidy,
  194 + MonthlyTransportSubsidy = x.MonthlyTransportSubsidy,
  195 + LastMonthTrainingSubsidy = x.LastMonthTrainingSubsidy,
  196 + LastMonthTransportSubsidy = x.LastMonthTransportSubsidy,
  197 + TotalSubsidy = x.TotalSubsidy,
  198 + MissingCard = x.MissingCard,
  199 + LateArrival = x.LateArrival,
  200 + LeaveDeduction = x.LeaveDeduction,
  201 + SocialInsuranceDeduction = x.SocialInsuranceDeduction,
  202 + RewardDeduction = x.RewardDeduction,
  203 + AccommodationDeduction = x.AccommodationDeduction,
  204 + StudyPeriodDeduction = x.StudyPeriodDeduction,
  205 + WorkClothesDeduction = x.WorkClothesDeduction,
  206 + TotalDeduction = x.TotalDeduction,
  207 + Bonus = x.Bonus,
  208 + ReturnPhoneDeposit = x.ReturnPhoneDeposit,
  209 + ReturnAccommodationDeposit = x.ReturnAccommodationDeposit,
  210 + ActualSalary = x.ActualSalary,
  211 + MonthlyPaymentStatus = x.MonthlyPaymentStatus,
  212 + PaidAmount = x.PaidAmount,
  213 + PendingAmount = x.PendingAmount,
  214 + LastMonthSupplement = x.LastMonthSupplement,
  215 + MonthlyTotalPayment = x.MonthlyTotalPayment,
  216 + IsLocked = x.IsLocked,
  217 + IsTerminated = x.IsTerminated,
  218 + UpdateTime = x.UpdateTime,
  219 + StoreType = x.StoreType,
  220 + StoreCategory = x.StoreCategory,
  221 + IsNewStore = x.IsNewStore,
  222 + NewStoreProtectionStage = x.NewStoreProtectionStage
  223 + })
  224 + .FirstAsync();
  225 + if (salary == null)
  226 + throw NCCException.Oh($"未找到员工{input.EmployeeId}在{input.Year}年{input.Month}月的工资记录");
  227 + return salary;
  228 + }
  229 +
  230 + /// <summary>
149 231 /// 计算科技老师工资
150 232 /// </summary>
151 233 /// <param name="year">年份</param>
... ... @@ -412,12 +494,71 @@ namespace NCC.Extend
412 494 // 4. 保存数据
413 495 if (techTeacherStats.Any())
414 496 {
415   - // 先删除当月旧数据 (防止重复)
416   - await _db.Deleteable<LqTechTeacherSalaryStatisticsEntity>()
  497 + // 查询当月已存在的记录(用于检查是否已锁定或已确认)
  498 + var existingRecords = await _db.Queryable<LqTechTeacherSalaryStatisticsEntity>()
417 499 .Where(x => x.StatisticsMonth == monthStr)
418   - .ExecuteCommandAsync();
  500 + .ToListAsync();
  501 +
  502 + var existingDict = existingRecords
  503 + .Where(x => !string.IsNullOrEmpty(x.EmployeeId))
  504 + .GroupBy(x => x.EmployeeId)
  505 + .ToDictionary(g => g.Key, g => g.First());
  506 +
  507 + // 分离需要插入的新记录和需要更新的记录,以及需要跳过的记录
  508 + var recordsToInsert = new List<LqTechTeacherSalaryStatisticsEntity>();
  509 + var recordsToUpdate = new List<LqTechTeacherSalaryStatisticsEntity>();
  510 + var skippedCount = 0;
  511 +
  512 + foreach (var salary in techTeacherStats.Values)
  513 + {
  514 + if (existingDict.ContainsKey(salary.EmployeeId))
  515 + {
  516 + var existing = existingDict[salary.EmployeeId];
  517 + // 如果已锁定或已确认,则跳过,不更新
  518 + if (existing.IsLocked == 1 || existing.EmployeeConfirmStatus == 1)
  519 + {
  520 + skippedCount++;
  521 + continue; // 跳过,不更新
  522 + }
  523 +
  524 + // 更新现有记录(保留确认状态相关字段)
  525 + salary.Id = existing.Id;
  526 + salary.EmployeeConfirmStatus = existing.EmployeeConfirmStatus;
  527 + salary.EmployeeConfirmTime = existing.EmployeeConfirmTime;
  528 + salary.EmployeeConfirmRemark = existing.EmployeeConfirmRemark;
  529 + salary.IsLocked = existing.IsLocked; // 保留锁定状态
  530 + salary.CreateTime = existing.CreateTime;
  531 + salary.CreateUser = existing.CreateUser;
  532 + recordsToUpdate.Add(salary);
  533 + }
  534 + else
  535 + {
  536 + // 新记录
  537 + salary.Id = YitIdHelper.NextId().ToString();
  538 + salary.EmployeeConfirmStatus = 0;
  539 + salary.IsLocked = 0;
  540 + salary.CreateTime = DateTime.Now;
  541 + salary.CreateUser = ""; // 新记录,创建人为空或系统
  542 + recordsToInsert.Add(salary);
  543 + }
  544 + }
419 545  
420   - await _db.Insertable(techTeacherStats.Values.ToList()).ExecuteCommandAsync();
  546 + // 批量插入新记录
  547 + if (recordsToInsert.Any())
  548 + {
  549 + await _db.Insertable(recordsToInsert).ExecuteCommandAsync();
  550 + }
  551 +
  552 + // 批量更新现有记录
  553 + if (recordsToUpdate.Any())
  554 + {
  555 + await _db.Updateable(recordsToUpdate).ExecuteCommandAsync();
  556 + }
  557 +
  558 + if (skippedCount > 0)
  559 + {
  560 + _logger.LogWarning($"计算工资时跳过了 {skippedCount} 条已锁定或已确认的记录(月份:{monthStr})");
  561 + }
421 562 }
422 563 }
423 564  
... ... @@ -770,14 +911,632 @@ namespace NCC.Extend
770 911 PersonTimes = personTimesStat?.PersonTimes ?? 0m,
771 912 LaborCost = consumeStat?.LaborCost ?? 0m, // 手工费只统计耗卡中的手工费
772 913 DepartmentId = teacher.OrganizeId,
773   - DepartmentName = !string.IsNullOrEmpty(teacher.DepartmentName)
774   - ? teacher.DepartmentName
  914 + DepartmentName = !string.IsNullOrEmpty(teacher.DepartmentName)
  915 + ? teacher.DepartmentName
775 916 : (techOrganizeDict.ContainsKey(teacher.OrganizeId) ? techOrganizeDict[teacher.OrganizeId] : "")
776 917 });
777 918 }
778 919  
779 920 return result;
780 921 }
  922 +
  923 + #region 员工工资确认
  924 +
  925 + /// <summary>
  926 + /// 员工确认工资条
  927 + /// </summary>
  928 + /// <remarks>
  929 + /// 员工确认自己的工资条,确认后工资数据不可再修改
  930 + ///
  931 + /// 示例请求:
  932 + /// <code>
  933 + /// {
  934 + /// "id": "工资记录ID",
  935 + /// "employeeId": "员工ID",
  936 + /// "remark": "确认备注(可选)"
  937 + /// }
  938 + /// </code>
  939 + ///
  940 + /// 参数说明:
  941 + /// - id: 工资记录ID(必填)
  942 + /// - employeeId: 员工ID(必填)
  943 + /// - remark: 确认备注(可选)
  944 + ///
  945 + /// 注意事项:
  946 + /// - 只能确认自己的工资条
  947 + /// - 只能确认已锁定的工资条(IsLocked = 1)
  948 + /// - 已确认的工资条不能重复确认
  949 + /// </remarks>
  950 + /// <param name="input">确认参数</param>
  951 + /// <returns>操作结果</returns>
  952 + /// <response code="200">确认成功</response>
  953 + /// <response code="400">参数错误或记录不存在</response>
  954 + [HttpPost("confirm")]
  955 + public async Task<string> ConfirmSalary([FromBody] SalaryConfirmInput input)
  956 + {
  957 + try
  958 + {
  959 + if (string.IsNullOrWhiteSpace(input.Id))
  960 + {
  961 + throw NCCException.Oh("工资记录ID不能为空");
  962 + }
  963 +
  964 + if (string.IsNullOrWhiteSpace(input.EmployeeId))
  965 + {
  966 + throw NCCException.Oh("员工ID不能为空");
  967 + }
  968 +
  969 + // 查询工资记录
  970 + var salary = await _db.Queryable<LqTechTeacherSalaryStatisticsEntity>()
  971 + .Where(s => s.Id == input.Id && s.EmployeeId == input.EmployeeId)
  972 + .FirstAsync();
  973 +
  974 + if (salary == null)
  975 + {
  976 + throw NCCException.Oh("工资记录不存在或不属于该员工");
  977 + }
  978 +
  979 + // 检查是否已确认
  980 + if (salary.EmployeeConfirmStatus == 1)
  981 + {
  982 + throw NCCException.Oh("该工资条已确认,不能重复确认");
  983 + }
  984 +
  985 + // 检查是否已锁定(员工只能确认已锁定的工资条)
  986 + if (salary.IsLocked != 1)
  987 + {
  988 + throw NCCException.Oh("该工资条尚未锁定,请等待管理员锁定后再确认");
  989 + }
  990 +
  991 + // 更新确认状态
  992 + salary.EmployeeConfirmStatus = 1;
  993 + salary.EmployeeConfirmTime = DateTime.Now;
  994 + salary.EmployeeConfirmRemark = input.Remark;
  995 + // 注意:IsLocked 保持为 1(因为本来就是管理员锁定的)
  996 + salary.UpdateTime = DateTime.Now;
  997 +
  998 + await _db.Updateable(salary).ExecuteCommandAsync();
  999 +
  1000 + return "确认成功";
  1001 + }
  1002 + catch (Exception ex)
  1003 + {
  1004 + throw NCCException.Oh($"确认工资条失败: {ex.Message}");
  1005 + }
  1006 + }
  1007 +
  1008 + #endregion
  1009 +
  1010 + #region 工资锁定/解锁
  1011 +
  1012 + /// <summary>
  1013 + /// 批量锁定/解锁工资条
  1014 + /// </summary>
  1015 + [HttpPost("lock")]
  1016 + public async Task<string> LockSalary([FromBody] SalaryLockInput input)
  1017 + {
  1018 + try
  1019 + {
  1020 + if (input == null || input.Ids == null || !input.Ids.Any())
  1021 + throw NCCException.Oh("工资记录ID列表不能为空");
  1022 + var salaries = await _db.Queryable<LqTechTeacherSalaryStatisticsEntity>()
  1023 + .Where(s => input.Ids.Contains(s.Id))
  1024 + .ToListAsync();
  1025 + if (!salaries.Any())
  1026 + throw NCCException.Oh("未找到指定的工资记录");
  1027 + var lockedCount = 0;
  1028 + var unlockedCount = 0;
  1029 + var skippedCount = 0;
  1030 + foreach (var salary in salaries)
  1031 + {
  1032 + if (salary.EmployeeConfirmStatus == 1 && !input.IsLocked)
  1033 + {
  1034 + skippedCount++;
  1035 + continue;
  1036 + }
  1037 + salary.IsLocked = input.IsLocked ? 1 : 0;
  1038 + salary.UpdateTime = DateTime.Now;
  1039 + if (input.IsLocked) lockedCount++; else unlockedCount++;
  1040 + }
  1041 + await _db.Updateable(salaries).ExecuteCommandAsync();
  1042 + var action = input.IsLocked ? "锁定" : "解锁";
  1043 + var count = input.IsLocked ? lockedCount : unlockedCount;
  1044 + var message = $"{action}成功:{count}条";
  1045 + if (skippedCount > 0)
  1046 + message += $",跳过{skippedCount}条(已确认的记录不能解锁)";
  1047 + return message;
  1048 + }
  1049 + catch (Exception ex)
  1050 + {
  1051 + throw NCCException.Oh($"锁定/解锁工资条失败: {ex.Message}");
  1052 + }
  1053 + }
  1054 +
  1055 + /// <summary>
  1056 + /// 批量锁定当月所有工资
  1057 + /// </summary>
  1058 + /// <remarks>
  1059 + /// 批量锁定指定月份的所有科技部老师工资记录
  1060 + /// </remarks>
  1061 + /// <param name="input">批量锁定输入参数</param>
  1062 + /// <returns>锁定结果</returns>
  1063 + [HttpPost("lock-by-month")]
  1064 + public async Task<dynamic> LockSalaryByMonth([FromBody] SalaryLockByMonthInput input)
  1065 + {
  1066 + try
  1067 + {
  1068 + if (input == null)
  1069 + throw NCCException.Oh("参数不能为空");
  1070 +
  1071 + if (input.Year <= 0 || input.Month <= 0 || input.Month > 12)
  1072 + throw NCCException.Oh("年份和月份参数不正确");
  1073 +
  1074 + var monthStr = $"{input.Year}{input.Month:D2}";
  1075 +
  1076 + var salaries = await _db.Queryable<LqTechTeacherSalaryStatisticsEntity>()
  1077 + .Where(s => s.StatisticsMonth == monthStr)
  1078 + .ToListAsync();
  1079 +
  1080 + if (!salaries.Any())
  1081 + throw NCCException.Oh($"未找到{input.Year}年{input.Month}月的工资记录");
  1082 +
  1083 + var lockedCount = 0;
  1084 + var unlockedCount = 0;
  1085 + var skippedCount = 0;
  1086 + var alreadyLockedCount = 0;
  1087 +
  1088 + foreach (var salary in salaries)
  1089 + {
  1090 + if (salary.EmployeeConfirmStatus == 1 && !input.IsLocked)
  1091 + {
  1092 + skippedCount++;
  1093 + continue;
  1094 + }
  1095 +
  1096 + if (salary.IsLocked == 1 && input.IsLocked)
  1097 + {
  1098 + alreadyLockedCount++;
  1099 + continue;
  1100 + }
  1101 +
  1102 + if (salary.IsLocked == 0 && !input.IsLocked)
  1103 + {
  1104 + alreadyLockedCount++;
  1105 + continue;
  1106 + }
  1107 +
  1108 + salary.IsLocked = input.IsLocked ? 1 : 0;
  1109 + salary.UpdateTime = DateTime.Now;
  1110 +
  1111 + if (input.IsLocked)
  1112 + lockedCount++;
  1113 + else
  1114 + unlockedCount++;
  1115 + }
  1116 +
  1117 + if (lockedCount > 0 || unlockedCount > 0)
  1118 + {
  1119 + var salariesToUpdate = salaries.Where(s =>
  1120 + (input.IsLocked && s.IsLocked == 0) ||
  1121 + (!input.IsLocked && s.IsLocked == 1 && s.EmployeeConfirmStatus != 1)
  1122 + ).ToList();
  1123 +
  1124 + if (salariesToUpdate.Any())
  1125 + {
  1126 + await _db.Updateable(salariesToUpdate)
  1127 + .UpdateColumns(s => new { s.IsLocked, s.UpdateTime })
  1128 + .ExecuteCommandAsync();
  1129 + }
  1130 + }
  1131 +
  1132 + var action = input.IsLocked ? "锁定" : "解锁";
  1133 + var count = input.IsLocked ? lockedCount : unlockedCount;
  1134 + var message = $"{action}成功:{count}条";
  1135 +
  1136 + if (alreadyLockedCount > 0)
  1137 + message += $",跳过{alreadyLockedCount}条(已是{action}状态)";
  1138 +
  1139 + if (skippedCount > 0)
  1140 + message += $",跳过{skippedCount}条(已确认的记录不能解锁)";
  1141 +
  1142 + return new
  1143 + {
  1144 + success = true,
  1145 + message = message,
  1146 + total = salaries.Count,
  1147 + locked = lockedCount,
  1148 + unlocked = unlockedCount,
  1149 + skipped = skippedCount,
  1150 + alreadyLocked = alreadyLockedCount
  1151 + };
  1152 + }
  1153 + catch (Exception ex)
  1154 + {
  1155 + _logger.LogError(ex, "批量锁定当月工资失败");
  1156 + var action = input?.IsLocked == true ? "锁定" : "解锁";
  1157 + throw NCCException.Oh($"批量{action}当月工资失败: {ex.Message}");
  1158 + }
  1159 + }
  1160 +
  1161 + #endregion
  1162 +
  1163 + #region 导入工资
  1164 +
  1165 + /// <summary>
  1166 + /// 从Excel导入科技部老师工资数据
  1167 + /// </summary>
  1168 + /// <remarks>
  1169 + /// 从Excel文件导入科技部老师工资数据,Excel第一列必须是ID(主键)
  1170 + ///
  1171 + /// 导入规则:
  1172 + /// 1. Excel第一列是ID(主键),如果为空则自动生成新ID
  1173 + /// 2. 如果ID在数据库中存在,则更新记录(需检查是否已锁定或已确认)
  1174 + /// 3. 如果ID在数据库中不存在,则新增记录
  1175 + /// 4. 已锁定(IsLocked=1)或已确认(EmployeeConfirmStatus=1)的记录不能导入覆盖
  1176 + ///
  1177 + /// Excel字段顺序(第一列为ID):
  1178 + /// ID, 门店名称, 员工姓名, 岗位, 开单业绩, ...(共54列)
  1179 + /// </remarks>
  1180 + /// <param name="file">Excel文件</param>
  1181 + /// <returns>导入结果</returns>
  1182 + /// <response code="200">导入成功</response>
  1183 + /// <response code="400">文件格式错误或数据验证失败</response>
  1184 + [HttpPost("import")]
  1185 + public async Task<dynamic> ImportSalaryFromExcel(IFormFile file)
  1186 + {
  1187 + try
  1188 + {
  1189 + if (file == null || file.Length == 0)
  1190 + {
  1191 + throw NCCException.Oh("请选择要上传的Excel文件");
  1192 + }
  1193 +
  1194 + // 检查文件格式
  1195 + var allowedExtensions = new[] { ".xlsx", ".xls" };
  1196 + var fileExtension = Path.GetExtension(file.FileName).ToLowerInvariant();
  1197 + if (!allowedExtensions.Contains(fileExtension))
  1198 + {
  1199 + throw NCCException.Oh("只支持.xlsx和.xls格式的Excel文件");
  1200 + }
  1201 +
  1202 + var recordsToInsert = new List<LqTechTeacherSalaryStatisticsEntity>();
  1203 + var recordsToUpdate = new List<LqTechTeacherSalaryStatisticsEntity>();
  1204 + var errorMessages = new List<string>();
  1205 + var successCount = 0;
  1206 + var failCount = 0;
  1207 + var skippedCount = 0;
  1208 +
  1209 + // 保存临时文件
  1210 + var tempFilePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString() + Path.GetExtension(file.FileName));
  1211 + try
  1212 + {
  1213 + using (var stream = new FileStream(tempFilePath, FileMode.Create))
  1214 + {
  1215 + await file.CopyToAsync(stream);
  1216 + }
  1217 +
  1218 + // 使用ExcelImportHelper读取Excel文件
  1219 + var dataTable = ExcelImportHelper.ToDataTable(tempFilePath, 0, 0);
  1220 +
  1221 + if (dataTable.Rows.Count == 0)
  1222 + {
  1223 + throw NCCException.Oh("Excel文件中没有数据行");
  1224 + }
  1225 +
  1226 + // Excel第一列是ID,第二列开始是业务字段
  1227 + for (int i = 1; i < dataTable.Rows.Count; i++)
  1228 + {
  1229 + try
  1230 + {
  1231 + var row = dataTable.Rows[i];
  1232 +
  1233 + // 安全获取列值
  1234 + Func<int, string> GetColumnValue = (colIndex) =>
  1235 + {
  1236 + if (colIndex < row.ItemArray.Length)
  1237 + {
  1238 + return row[colIndex]?.ToString()?.Trim() ?? "";
  1239 + }
  1240 + return "";
  1241 + };
  1242 +
  1243 + // 第一列是ID
  1244 + var id = GetColumnValue(0);
  1245 +
  1246 + // 判断Excel格式:如果第一列是"门店名称"等中文,说明是旧格式(没有ID列)
  1247 + var firstColumnValue = GetColumnValue(0);
  1248 + bool isOldFormat = false;
  1249 + if (!string.IsNullOrWhiteSpace(firstColumnValue) &&
  1250 + (firstColumnValue == "门店名称" || firstColumnValue.Contains("门店") || (!string.IsNullOrWhiteSpace(firstColumnValue) && !long.TryParse(firstColumnValue, out _) && firstColumnValue.Length > 20)))
  1251 + {
  1252 + isOldFormat = true;
  1253 + id = "";
  1254 + }
  1255 +
  1256 + // 根据Excel格式确定字段索引
  1257 + int storeNameIndex = isOldFormat ? 0 : 1;
  1258 + int employeeNameIndex = isOldFormat ? 1 : 2;
  1259 +
  1260 + // 跳过空行
  1261 + var employeeName = GetColumnValue(employeeNameIndex);
  1262 + var storeName = GetColumnValue(storeNameIndex);
  1263 +
  1264 + if (string.IsNullOrWhiteSpace(id) && string.IsNullOrWhiteSpace(employeeName))
  1265 + {
  1266 + continue;
  1267 + }
  1268 +
  1269 + // 如果ID为空,尝试根据员工姓名和门店名称匹配现有记录的ID
  1270 + if (string.IsNullOrWhiteSpace(id) && !string.IsNullOrWhiteSpace(employeeName))
  1271 + {
  1272 + var matchedRecord = await _db.Queryable<LqTechTeacherSalaryStatisticsEntity>()
  1273 + .Where(x => x.EmployeeName == employeeName)
  1274 + .WhereIF(!string.IsNullOrWhiteSpace(storeName), x => x.StoreName == storeName)
  1275 + .OrderBy(x => x.CreateTime, OrderByType.Desc)
  1276 + .FirstAsync();
  1277 +
  1278 + if (matchedRecord != null)
  1279 + {
  1280 + id = matchedRecord.Id;
  1281 + }
  1282 + }
  1283 +
  1284 + // 验证必填字段
  1285 + if (string.IsNullOrWhiteSpace(employeeName))
  1286 + {
  1287 + errorMessages.Add($"第{i + 1}行:员工姓名不能为空");
  1288 + failCount++;
  1289 + continue;
  1290 + }
  1291 +
  1292 + // 辅助方法:清理数值字符串
  1293 + Func<string, decimal> ParseDecimal = (str) =>
  1294 + {
  1295 + if (string.IsNullOrWhiteSpace(str))
  1296 + return 0;
  1297 + var cleaned = str.Trim()
  1298 + .Replace(",", "")
  1299 + .Replace(",", "")
  1300 + .Replace("¥", "")
  1301 + .Replace("$", "")
  1302 + .Replace("元", "")
  1303 + .Replace("%", "")
  1304 + .Replace(" ", "");
  1305 + if (decimal.TryParse(cleaned, out decimal result))
  1306 + return result;
  1307 + return 0;
  1308 + };
  1309 +
  1310 + Func<string, int> ParseInt = (str) =>
  1311 + {
  1312 + if (string.IsNullOrWhiteSpace(str))
  1313 + return 0;
  1314 + var cleaned = str.Trim().Replace(",", "").Replace(",", "").Replace(" ", "");
  1315 + if (int.TryParse(cleaned, out int result))
  1316 + return result;
  1317 + return 0;
  1318 + };
  1319 +
  1320 + // 如果Excel中有ID,查找现有记录
  1321 + LqTechTeacherSalaryStatisticsEntity existing = null;
  1322 + if (!string.IsNullOrWhiteSpace(id))
  1323 + {
  1324 + existing = await _db.Queryable<LqTechTeacherSalaryStatisticsEntity>()
  1325 + .Where(x => x.Id == id)
  1326 + .FirstAsync();
  1327 +
  1328 + if (existing != null)
  1329 + {
  1330 + // 检查是否已锁定或已确认
  1331 + if (existing.IsLocked == 1)
  1332 + {
  1333 + errorMessages.Add($"第{i + 1}行:员工 {existing.EmployeeName} (ID: {id}) 的工资已锁定,不能导入覆盖");
  1334 + skippedCount++;
  1335 + failCount++;
  1336 + continue;
  1337 + }
  1338 +
  1339 + if (existing.EmployeeConfirmStatus == 1)
  1340 + {
  1341 + errorMessages.Add($"第{i + 1}行:员工 {existing.EmployeeName} (ID: {id}) 的工资已确认,不能导入覆盖");
  1342 + skippedCount++;
  1343 + failCount++;
  1344 + continue;
  1345 + }
  1346 + }
  1347 + }
  1348 +
  1349 + // 创建或更新实体
  1350 + LqTechTeacherSalaryStatisticsEntity entity;
  1351 + if (existing != null)
  1352 + {
  1353 + entity = existing;
  1354 + entity.EmployeeConfirmStatus = 0;
  1355 + entity.EmployeeConfirmTime = null;
  1356 + entity.EmployeeConfirmRemark = null;
  1357 + }
  1358 + else
  1359 + {
  1360 + entity = new LqTechTeacherSalaryStatisticsEntity
  1361 + {
  1362 + Id = string.IsNullOrWhiteSpace(id) ? YitIdHelper.NextId().ToString() : id,
  1363 + EmployeeConfirmStatus = 0,
  1364 + IsLocked = 0,
  1365 + CreateTime = DateTime.Now,
  1366 + UpdateTime = DateTime.Now
  1367 + };
  1368 + }
  1369 +
  1370 + // 根据Excel格式计算字段索引偏移量
  1371 + int offset = isOldFormat ? 0 : 1;
  1372 +
  1373 + // 映射Excel字段到实体属性(根据Excel列顺序,第一列是ID,所以业务字段从索引1开始)
  1374 + // Excel列顺序:ID, 门店名称, 员工姓名, 岗位, 开单业绩, 消耗业绩, 退卡业绩, 总业绩, 项目数, 底薪档位, 底薪, ...
  1375 + entity.StoreName = storeName;
  1376 + entity.EmployeeName = employeeName;
  1377 + entity.Position = GetColumnValue(2 + offset);
  1378 + entity.OrderAchievement = ParseDecimal(GetColumnValue(3 + offset));
  1379 + entity.ConsumeAchievement = ParseDecimal(GetColumnValue(4 + offset));
  1380 + entity.RefundAchievement = ParseDecimal(GetColumnValue(5 + offset));
  1381 + entity.TotalPerformance = ParseDecimal(GetColumnValue(6 + offset));
  1382 + entity.ProjectCount = ParseDecimal(GetColumnValue(7 + offset));
  1383 + entity.BaseSalaryLevel = ParseInt(GetColumnValue(8 + offset));
  1384 + entity.BaseSalary = ParseDecimal(GetColumnValue(9 + offset));
  1385 + entity.PerformanceCommissionRate = ParseDecimal(GetColumnValue(10 + offset));
  1386 + entity.PerformanceCommissionAmount = ParseDecimal(GetColumnValue(11 + offset));
  1387 + entity.ConsumeCommissionRate = ParseDecimal(GetColumnValue(12 + offset));
  1388 + entity.ConsumeCommissionAmount = ParseDecimal(GetColumnValue(13 + offset));
  1389 + entity.HandworkFee = ParseDecimal(GetColumnValue(14 + offset));
  1390 + entity.TotalCommission = ParseDecimal(GetColumnValue(15 + offset));
  1391 + entity.WorkingDays = ParseDecimal(GetColumnValue(16 + offset));
  1392 + entity.LeaveDays = ParseDecimal(GetColumnValue(17 + offset));
  1393 + entity.TransportationAllowance = ParseDecimal(GetColumnValue(18 + offset));
  1394 + entity.LessRest = ParseDecimal(GetColumnValue(19 + offset));
  1395 + entity.FullAttendance = ParseDecimal(GetColumnValue(20 + offset));
  1396 + entity.MonthlyTrainingSubsidy = ParseDecimal(GetColumnValue(21 + offset));
  1397 + entity.MonthlyTransportSubsidy = ParseDecimal(GetColumnValue(22 + offset));
  1398 + entity.LastMonthTrainingSubsidy = ParseDecimal(GetColumnValue(23 + offset));
  1399 + entity.LastMonthTransportSubsidy = ParseDecimal(GetColumnValue(24 + offset));
  1400 + entity.TotalSubsidy = ParseDecimal(GetColumnValue(25 + offset));
  1401 + entity.CalculatedGrossSalary = ParseDecimal(GetColumnValue(26 + offset));
  1402 + entity.GuaranteedSalary = ParseDecimal(GetColumnValue(27 + offset));
  1403 + entity.GuaranteedLeaveDeduction = ParseDecimal(GetColumnValue(28 + offset));
  1404 + entity.GuaranteedBaseSalary = ParseDecimal(GetColumnValue(29 + offset));
  1405 + entity.GuaranteedSupplement = ParseDecimal(GetColumnValue(30 + offset));
  1406 + entity.FinalGrossSalary = ParseDecimal(GetColumnValue(31 + offset));
  1407 + entity.MissingCard = ParseDecimal(GetColumnValue(32 + offset));
  1408 + entity.LateArrival = ParseDecimal(GetColumnValue(33 + offset));
  1409 + entity.LeaveDeduction = ParseDecimal(GetColumnValue(34 + offset));
  1410 + entity.SocialInsuranceDeduction = ParseDecimal(GetColumnValue(35 + offset));
  1411 + entity.RewardDeduction = ParseDecimal(GetColumnValue(36 + offset));
  1412 + entity.AccommodationDeduction = ParseDecimal(GetColumnValue(37 + offset));
  1413 + entity.StudyPeriodDeduction = ParseDecimal(GetColumnValue(38 + offset));
  1414 + entity.WorkClothesDeduction = ParseDecimal(GetColumnValue(39 + offset));
  1415 + entity.TotalDeduction = ParseDecimal(GetColumnValue(40 + offset));
  1416 + entity.Bonus = ParseDecimal(GetColumnValue(41 + offset));
  1417 + entity.ReturnPhoneDeposit = ParseDecimal(GetColumnValue(42 + offset));
  1418 + entity.ReturnAccommodationDeposit = ParseDecimal(GetColumnValue(43 + offset));
  1419 + entity.LastMonthSupplement = ParseDecimal(GetColumnValue(44 + offset));
  1420 + entity.ActualSalary = ParseDecimal(GetColumnValue(45 + offset));
  1421 + entity.MonthlyPaymentStatus = GetColumnValue(46 + offset);
  1422 + entity.PaidAmount = ParseDecimal(GetColumnValue(47 + offset));
  1423 + entity.PendingAmount = ParseDecimal(GetColumnValue(48 + offset));
  1424 + entity.MonthlyTotalPayment = ParseDecimal(GetColumnValue(49 + offset));
  1425 +
  1426 + // 处理"是否新店"字段
  1427 + var isNewStoreStr = GetColumnValue(50 + offset);
  1428 + entity.IsNewStore = (isNewStoreStr == "是" || isNewStoreStr == "1" || isNewStoreStr.ToLower() == "true") ? "是" : "否";
  1429 +
  1430 + // 处理"新店保护阶段"字段
  1431 + var newStoreProtectionStageStr = GetColumnValue(51 + offset);
  1432 + if (int.TryParse(newStoreProtectionStageStr, out int protectionStage))
  1433 + {
  1434 + entity.NewStoreProtectionStage = protectionStage;
  1435 + }
  1436 +
  1437 + // 处理"离职状态"字段(第54列,索引53+offset)
  1438 + var terminatedStatus = GetColumnValue(53 + offset);
  1439 + entity.IsTerminated = (terminatedStatus == "离职" || terminatedStatus == "1") ? 1 : 0;
  1440 +
  1441 + // 处理Excel中没有的字段
  1442 + if (existing != null)
  1443 + {
  1444 + entity.StoreId = existing.StoreId;
  1445 + entity.EmployeeId = existing.EmployeeId;
  1446 + entity.StatisticsMonth = existing.StatisticsMonth;
  1447 + }
  1448 + else
  1449 + {
  1450 + // 新增记录:尝试根据员工姓名和门店名称查找EmployeeId和StoreId
  1451 + if (!string.IsNullOrWhiteSpace(employeeName))
  1452 + {
  1453 + var user = await _db.Queryable<UserEntity>()
  1454 + .Where(u => u.RealName == employeeName)
  1455 + .FirstAsync();
  1456 +
  1457 + if (user != null)
  1458 + {
  1459 + entity.EmployeeId = user.Id;
  1460 + entity.EmployeeAccount = user.Account;
  1461 +
  1462 + if (!string.IsNullOrEmpty(user.Mdid) && string.IsNullOrWhiteSpace(storeName))
  1463 + {
  1464 + entity.StoreId = user.Mdid;
  1465 + }
  1466 + }
  1467 +
  1468 + if (!string.IsNullOrWhiteSpace(storeName) && string.IsNullOrEmpty(entity.StoreId))
  1469 + {
  1470 + var store = await _db.Queryable<LqMdxxEntity>()
  1471 + .Where(s => s.Dm == storeName)
  1472 + .FirstAsync();
  1473 +
  1474 + if (store != null)
  1475 + {
  1476 + entity.StoreId = store.Id;
  1477 + }
  1478 + }
  1479 + }
  1480 + }
  1481 +
  1482 + entity.UpdateTime = DateTime.Now;
  1483 +
  1484 + if (existing != null)
  1485 + {
  1486 + recordsToUpdate.Add(entity);
  1487 + }
  1488 + else
  1489 + {
  1490 + recordsToInsert.Add(entity);
  1491 + }
  1492 +
  1493 + successCount++;
  1494 + }
  1495 + catch (Exception ex)
  1496 + {
  1497 + errorMessages.Add($"第{i + 1}行数据处理失败: {ex.Message}");
  1498 + failCount++;
  1499 + }
  1500 + }
  1501 + }
  1502 + finally
  1503 + {
  1504 + // 清理临时文件
  1505 + if (File.Exists(tempFilePath))
  1506 + {
  1507 + File.Delete(tempFilePath);
  1508 + }
  1509 + }
  1510 +
  1511 + // 批量插入新记录
  1512 + if (recordsToInsert.Any())
  1513 + {
  1514 + await _db.Insertable(recordsToInsert).ExecuteCommandAsync();
  1515 + }
  1516 +
  1517 + // 批量更新现有记录
  1518 + if (recordsToUpdate.Any())
  1519 + {
  1520 + await _db.Updateable(recordsToUpdate).ExecuteCommandAsync();
  1521 + }
  1522 +
  1523 + return new
  1524 + {
  1525 + success = true,
  1526 + message = $"导入完成:成功 {successCount} 条,失败 {failCount} 条,跳过 {skippedCount} 条(已锁定或已确认)",
  1527 + successCount = successCount,
  1528 + failCount = failCount,
  1529 + skippedCount = skippedCount,
  1530 + errors = errorMessages
  1531 + };
  1532 + }
  1533 + catch (Exception ex)
  1534 + {
  1535 + throw NCCException.Oh($"导入科技部老师工资数据失败: {ex.Message}");
  1536 + }
  1537 + }
  1538 +
  1539 + #endregion
781 1540 }
782 1541 }
783 1542  
... ...