Commit 41c75b24ff9920291a9881dbf795419a2e0ad7e0
1 parent
3d57a288
refactor: 移除所有薪酬服务列表查询中的自动计算逻辑
- 移除9个薪酬服务列表查询方法中的自动计算工资逻辑 - 列表查询现在只负责查询已存在的工资数据 - 工资计算功能需要单独调用计算接口 - 新增健康师工资核算规则说明文档 - 新增员工门店归属变更问题分析与解决方案文档 - 新增员工门店归属快照表方案详细设计文档 涉及服务: 1. LqSalaryService (健康师工资) 2. LqTechTeacherSalaryService (科技部老师工资) 3. LqAssistantSalaryService (店助工资) 4. LqStoreManagerSalaryService (店长工资) 5. LqDirectorSalaryService (主任工资) 6. LqMajorProjectTeacherSalaryService (大项目老师工资) 7. LqMajorProjectDirectorSalaryService (大项目主管工资) 8. LqTechGeneralManagerSalaryService (科技部总经理工资) 9. LqBusinessUnitManagerSalaryService (事业部总经理工资)
Showing
12 changed files
with
7122 additions
and
151 deletions
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 | using Microsoft.AspNetCore.Authorization; | 1 | using Microsoft.AspNetCore.Authorization; |
| 2 | +using Microsoft.AspNetCore.Http; | ||
| 2 | using Microsoft.AspNetCore.Mvc; | 3 | using Microsoft.AspNetCore.Mvc; |
| 4 | +using Microsoft.Extensions.Logging; | ||
| 3 | using NCC.Common.Filter; | 5 | using NCC.Common.Filter; |
| 4 | using NCC.Common.Helper; | 6 | using NCC.Common.Helper; |
| 5 | using NCC.Dependency; | 7 | using NCC.Dependency; |
| 6 | using NCC.DynamicApiController; | 8 | using NCC.DynamicApiController; |
| 7 | using NCC.Extend.Entitys.Dto.LqAssistantSalary; | 9 | using NCC.Extend.Entitys.Dto.LqAssistantSalary; |
| 10 | +using NCC.Extend.Entitys.Dto.LqSalary; | ||
| 8 | using NCC.Extend.Entitys.lq_assistant_salary_statistics; | 11 | using NCC.Extend.Entitys.lq_assistant_salary_statistics; |
| 9 | using NCC.Extend.Entitys.lq_attendance_summary; | 12 | using NCC.Extend.Entitys.lq_attendance_summary; |
| 10 | using NCC.Extend.Entitys.lq_hytk_hytk; | 13 | using NCC.Extend.Entitys.lq_hytk_hytk; |
| @@ -14,10 +17,13 @@ using NCC.Extend.Entitys.lq_md_xdbhsj; | @@ -14,10 +17,13 @@ using NCC.Extend.Entitys.lq_md_xdbhsj; | ||
| 14 | using NCC.Extend.Entitys.lq_mdxx; | 17 | using NCC.Extend.Entitys.lq_mdxx; |
| 15 | using NCC.Extend.Entitys.lq_xh_hyhk; | 18 | using NCC.Extend.Entitys.lq_xh_hyhk; |
| 16 | using NCC.Extend.Entitys.lq_xh_jksyj; | 19 | using NCC.Extend.Entitys.lq_xh_jksyj; |
| 20 | +using NCC.FriendlyException; | ||
| 17 | using NCC.System.Entitys.Permission; | 21 | using NCC.System.Entitys.Permission; |
| 18 | using SqlSugar; | 22 | using SqlSugar; |
| 19 | using System; | 23 | using System; |
| 20 | using System.Collections.Generic; | 24 | using System.Collections.Generic; |
| 25 | +using System.Data; | ||
| 26 | +using System.IO; | ||
| 21 | using System.Linq; | 27 | using System.Linq; |
| 22 | using System.Threading.Tasks; | 28 | using System.Threading.Tasks; |
| 23 | using Yitter.IdGenerator; | 29 | using Yitter.IdGenerator; |
| @@ -32,13 +38,15 @@ namespace NCC.Extend | @@ -32,13 +38,15 @@ namespace NCC.Extend | ||
| 32 | public class LqAssistantSalaryService : IDynamicApiController, ITransient | 38 | public class LqAssistantSalaryService : IDynamicApiController, ITransient |
| 33 | { | 39 | { |
| 34 | private readonly ISqlSugarClient _db; | 40 | private readonly ISqlSugarClient _db; |
| 41 | + private readonly ILogger<LqAssistantSalaryService> _logger; | ||
| 35 | 42 | ||
| 36 | /// <summary> | 43 | /// <summary> |
| 37 | /// 初始化一个<see cref="LqAssistantSalaryService"/>类型的新实例 | 44 | /// 初始化一个<see cref="LqAssistantSalaryService"/>类型的新实例 |
| 38 | /// </summary> | 45 | /// </summary> |
| 39 | - public LqAssistantSalaryService(ISqlSugarClient db) | 46 | + public LqAssistantSalaryService(ISqlSugarClient db, ILogger<LqAssistantSalaryService> logger) |
| 40 | { | 47 | { |
| 41 | _db = db; | 48 | _db = db; |
| 49 | + _logger = logger; | ||
| 42 | } | 50 | } |
| 43 | 51 | ||
| 44 | /// <summary> | 52 | /// <summary> |
| @@ -51,17 +59,7 @@ namespace NCC.Extend | @@ -51,17 +59,7 @@ namespace NCC.Extend | ||
| 51 | { | 59 | { |
| 52 | var monthStr = $"{input.Year}{input.Month:D2}"; | 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 | var query = _db.Queryable<LqAssistantSalaryStatisticsEntity>() | 63 | var query = _db.Queryable<LqAssistantSalaryStatisticsEntity>() |
| 66 | .Where(x => x.StatisticsMonth == monthStr); | 64 | .Where(x => x.StatisticsMonth == monthStr); |
| 67 | 65 | ||
| @@ -125,6 +123,69 @@ namespace NCC.Extend | @@ -125,6 +123,69 @@ namespace NCC.Extend | ||
| 125 | } | 123 | } |
| 126 | 124 | ||
| 127 | /// <summary> | 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 | /// </summary> | 190 | /// </summary> |
| 130 | /// <param name="year">年份</param> | 191 | /// <param name="year">年份</param> |
| @@ -356,7 +417,7 @@ namespace NCC.Extend | @@ -356,7 +417,7 @@ namespace NCC.Extend | ||
| 356 | // - 100%以上部分:0.6% | 417 | // - 100%以上部分:0.6% |
| 357 | totalCommission = CalculateAssistantCommission(salary.StoreTotalPerformance, salary.StoreLifeline); | 418 | totalCommission = CalculateAssistantCommission(salary.StoreTotalPerformance, salary.StoreLifeline); |
| 358 | } | 419 | } |
| 359 | - | 420 | + |
| 360 | // 计算平均提成比例(用于显示) | 421 | // 计算平均提成比例(用于显示) |
| 361 | if (salary.StoreTotalPerformance > 0) | 422 | if (salary.StoreTotalPerformance > 0) |
| 362 | { | 423 | { |
| @@ -465,7 +526,7 @@ namespace NCC.Extend | @@ -465,7 +526,7 @@ namespace NCC.Extend | ||
| 465 | { | 526 | { |
| 466 | storeTotalCommission = CalculateAssistantCommission(salary.StoreTotalPerformance, salary.StoreLifeline); | 527 | storeTotalCommission = CalculateAssistantCommission(salary.StoreTotalPerformance, salary.StoreLifeline); |
| 467 | } | 528 | } |
| 468 | - | 529 | + |
| 469 | // 按比例计算提成:门店总提成 / 当月天数 × 在店天数 | 530 | // 按比例计算提成:门店总提成 / 当月天数 × 在店天数 |
| 470 | salary.CommissionAmount = storeTotalCommission / daysInMonth * workingDays; | 531 | salary.CommissionAmount = storeTotalCommission / daysInMonth * workingDays; |
| 471 | 532 | ||
| @@ -540,12 +601,71 @@ namespace NCC.Extend | @@ -540,12 +601,71 @@ namespace NCC.Extend | ||
| 540 | // 3. 保存数据 | 601 | // 3. 保存数据 |
| 541 | if (assistantSalaryList.Any()) | 602 | if (assistantSalaryList.Any()) |
| 542 | { | 603 | { |
| 543 | - // 先删除当月旧数据 (防止重复) | ||
| 544 | - await _db.Deleteable<LqAssistantSalaryStatisticsEntity>() | 604 | + // 查询当月已存在的记录(用于检查是否已锁定或已确认) |
| 605 | + var existingRecords = await _db.Queryable<LqAssistantSalaryStatisticsEntity>() | ||
| 545 | .Where(x => x.StatisticsMonth == monthStr) | 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,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,17 +62,7 @@ namespace NCC.Extend | ||
| 62 | { | 62 | { |
| 63 | var monthStr = $"{input.Year}{input.Month:D2}"; | 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 | var query = _db.Queryable<LqBusinessUnitManagerSalaryStatisticsEntity>() | 66 | var query = _db.Queryable<LqBusinessUnitManagerSalaryStatisticsEntity>() |
| 77 | .Where(x => x.StatisticsMonth == monthStr); | 67 | .Where(x => x.StatisticsMonth == monthStr); |
| 78 | 68 | ||
| @@ -141,6 +131,74 @@ namespace NCC.Extend | @@ -141,6 +131,74 @@ namespace NCC.Extend | ||
| 141 | } | 131 | } |
| 142 | 132 | ||
| 143 | /// <summary> | 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 | /// </summary> | 203 | /// </summary> |
| 146 | /// <param name="year">年份</param> | 204 | /// <param name="year">年份</param> |
| @@ -635,6 +693,35 @@ namespace NCC.Extend | @@ -635,6 +693,35 @@ namespace NCC.Extend | ||
| 635 | 693 | ||
| 636 | #region 员工工资确认 | 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 | [HttpPost("confirm")] | 725 | [HttpPost("confirm")] |
| 639 | public async Task<string> ConfirmSalary([FromBody] SalaryConfirmInput input) | 726 | public async Task<string> ConfirmSalary([FromBody] SalaryConfirmInput input) |
| 640 | { | 727 | { |
| @@ -662,6 +749,156 @@ namespace NCC.Extend | @@ -662,6 +749,156 @@ namespace NCC.Extend | ||
| 662 | 749 | ||
| 663 | #endregion | 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 | #region 导入工资 | 902 | #region 导入工资 |
| 666 | 903 | ||
| 667 | /// <summary> | 904 | /// <summary> |
netcore/src/Modularity/Extend/NCC.Extend/LqDirectorSalaryService.cs
| 1 | using Microsoft.AspNetCore.Authorization; | 1 | using Microsoft.AspNetCore.Authorization; |
| 2 | +using Microsoft.AspNetCore.Http; | ||
| 2 | using Microsoft.AspNetCore.Mvc; | 3 | using Microsoft.AspNetCore.Mvc; |
| 4 | +using Microsoft.Extensions.Logging; | ||
| 3 | using NCC.Common.Filter; | 5 | using NCC.Common.Filter; |
| 4 | using NCC.Common.Helper; | 6 | using NCC.Common.Helper; |
| 5 | using NCC.Dependency; | 7 | using NCC.Dependency; |
| 6 | using NCC.DynamicApiController; | 8 | using NCC.DynamicApiController; |
| 7 | using NCC.Extend.Entitys.Dto.LqDirectorSalary; | 9 | using NCC.Extend.Entitys.Dto.LqDirectorSalary; |
| 10 | +using NCC.Extend.Entitys.Dto.LqSalary; | ||
| 8 | using NCC.Extend.Entitys.Enum; | 11 | using NCC.Extend.Entitys.Enum; |
| 9 | using NCC.Extend.Entitys.lq_attendance_summary; | 12 | using NCC.Extend.Entitys.lq_attendance_summary; |
| 10 | using NCC.Extend.Entitys.lq_cooperation_cost; | 13 | using NCC.Extend.Entitys.lq_cooperation_cost; |
| @@ -18,10 +21,13 @@ using NCC.Extend.Entitys.lq_mdxx; | @@ -18,10 +21,13 @@ using NCC.Extend.Entitys.lq_mdxx; | ||
| 18 | using NCC.Extend.Entitys.lq_store_expense; | 21 | using NCC.Extend.Entitys.lq_store_expense; |
| 19 | using NCC.Extend.Entitys.lq_xh_hyhk; | 22 | using NCC.Extend.Entitys.lq_xh_hyhk; |
| 20 | using NCC.Extend.Entitys.lq_xh_jksyj; | 23 | using NCC.Extend.Entitys.lq_xh_jksyj; |
| 24 | +using NCC.FriendlyException; | ||
| 21 | using NCC.System.Entitys.Permission; | 25 | using NCC.System.Entitys.Permission; |
| 22 | using SqlSugar; | 26 | using SqlSugar; |
| 23 | using System; | 27 | using System; |
| 24 | using System.Collections.Generic; | 28 | using System.Collections.Generic; |
| 29 | +using System.Data; | ||
| 30 | +using System.IO; | ||
| 25 | using System.Linq; | 31 | using System.Linq; |
| 26 | using System.Threading.Tasks; | 32 | using System.Threading.Tasks; |
| 27 | using Yitter.IdGenerator; | 33 | using Yitter.IdGenerator; |
| @@ -36,13 +42,15 @@ namespace NCC.Extend | @@ -36,13 +42,15 @@ namespace NCC.Extend | ||
| 36 | public class LqDirectorSalaryService : IDynamicApiController, ITransient | 42 | public class LqDirectorSalaryService : IDynamicApiController, ITransient |
| 37 | { | 43 | { |
| 38 | private readonly ISqlSugarClient _db; | 44 | private readonly ISqlSugarClient _db; |
| 45 | + private readonly ILogger<LqDirectorSalaryService> _logger; | ||
| 39 | 46 | ||
| 40 | /// <summary> | 47 | /// <summary> |
| 41 | /// 初始化一个<see cref="LqDirectorSalaryService"/>类型的新实例 | 48 | /// 初始化一个<see cref="LqDirectorSalaryService"/>类型的新实例 |
| 42 | /// </summary> | 49 | /// </summary> |
| 43 | - public LqDirectorSalaryService(ISqlSugarClient db) | 50 | + public LqDirectorSalaryService(ISqlSugarClient db, ILogger<LqDirectorSalaryService> logger) |
| 44 | { | 51 | { |
| 45 | _db = db; | 52 | _db = db; |
| 53 | + _logger = logger; | ||
| 46 | } | 54 | } |
| 47 | 55 | ||
| 48 | /// <summary> | 56 | /// <summary> |
| @@ -55,17 +63,7 @@ namespace NCC.Extend | @@ -55,17 +63,7 @@ namespace NCC.Extend | ||
| 55 | { | 63 | { |
| 56 | var monthStr = $"{input.Year}{input.Month:D2}"; | 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 | var query = _db.Queryable<LqDirectorSalaryStatisticsEntity>() | 67 | var query = _db.Queryable<LqDirectorSalaryStatisticsEntity>() |
| 70 | .Where(x => x.StatisticsMonth == monthStr); | 68 | .Where(x => x.StatisticsMonth == monthStr); |
| 71 | 69 | ||
| @@ -133,6 +131,59 @@ namespace NCC.Extend | @@ -133,6 +131,59 @@ namespace NCC.Extend | ||
| 133 | } | 131 | } |
| 134 | 132 | ||
| 135 | /// <summary> | 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 | /// </summary> | 188 | /// </summary> |
| 138 | /// <param name="year">年份</param> | 189 | /// <param name="year">年份</param> |
| @@ -552,12 +603,42 @@ namespace NCC.Extend | @@ -552,12 +603,42 @@ namespace NCC.Extend | ||
| 552 | // 3. 保存数据 | 603 | // 3. 保存数据 |
| 553 | if (directorSalaryList.Any()) | 604 | if (directorSalaryList.Any()) |
| 554 | { | 605 | { |
| 555 | - // 先删除当月旧数据 (防止重复) | ||
| 556 | - await _db.Deleteable<LqDirectorSalaryStatisticsEntity>() | 606 | + var existingRecords = await _db.Queryable<LqDirectorSalaryStatisticsEntity>() |
| 557 | .Where(x => x.StatisticsMonth == monthStr) | 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,6 +730,448 @@ namespace NCC.Extend | ||
| 649 | 730 | ||
| 650 | salary.TotalCommissionAmount = salary.CommissionAmountBelowLifeline + salary.CommissionAmountAboveLifeline; | 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 | using Microsoft.AspNetCore.Authorization; | 1 | using Microsoft.AspNetCore.Authorization; |
| 2 | using Microsoft.AspNetCore.Mvc; | 2 | using Microsoft.AspNetCore.Mvc; |
| 3 | +using Microsoft.Extensions.Logging; | ||
| 3 | using NCC.Common.Filter; | 4 | using NCC.Common.Filter; |
| 4 | using NCC.Common.Helper; | 5 | using NCC.Common.Helper; |
| 5 | using NCC.Dependency; | 6 | using NCC.Dependency; |
| 6 | using NCC.DynamicApiController; | 7 | using NCC.DynamicApiController; |
| 7 | using NCC.Extend.Entitys.Dto.LqMajorProjectDirectorSalary; | 8 | using NCC.Extend.Entitys.Dto.LqMajorProjectDirectorSalary; |
| 9 | +using NCC.Extend.Entitys.Dto.LqSalary; | ||
| 8 | using NCC.Extend.Entitys.lq_attendance_summary; | 10 | using NCC.Extend.Entitys.lq_attendance_summary; |
| 9 | using NCC.Extend.Entitys.lq_hytk_hytk; | 11 | using NCC.Extend.Entitys.lq_hytk_hytk; |
| 10 | using NCC.Extend.Entitys.lq_hytk_mx; | 12 | using NCC.Extend.Entitys.lq_hytk_mx; |
| @@ -14,14 +16,18 @@ using NCC.Extend.Entitys.lq_kd_jksyj; | @@ -14,14 +16,18 @@ using NCC.Extend.Entitys.lq_kd_jksyj; | ||
| 14 | using NCC.Extend.Entitys.lq_md_target; | 16 | using NCC.Extend.Entitys.lq_md_target; |
| 15 | using NCC.Extend.Entitys.lq_mdxx; | 17 | using NCC.Extend.Entitys.lq_mdxx; |
| 16 | using NCC.Extend.Entitys.lq_major_project_director_salary_statistics; | 18 | using NCC.Extend.Entitys.lq_major_project_director_salary_statistics; |
| 19 | +using NCC.FriendlyException; | ||
| 17 | using NCC.System.Entitys.Permission; | 20 | using NCC.System.Entitys.Permission; |
| 18 | using SqlSugar; | 21 | using SqlSugar; |
| 19 | using System; | 22 | using System; |
| 20 | using System.Collections.Generic; | 23 | using System.Collections.Generic; |
| 24 | +using System.Data; | ||
| 25 | +using System.IO; | ||
| 21 | using System.Linq; | 26 | using System.Linq; |
| 22 | using System.Threading.Tasks; | 27 | using System.Threading.Tasks; |
| 23 | using Yitter.IdGenerator; | 28 | using Yitter.IdGenerator; |
| 24 | using Newtonsoft.Json; | 29 | using Newtonsoft.Json; |
| 30 | +using Microsoft.AspNetCore.Http; | ||
| 25 | 31 | ||
| 26 | namespace NCC.Extend | 32 | namespace NCC.Extend |
| 27 | { | 33 | { |
| @@ -33,13 +39,15 @@ namespace NCC.Extend | @@ -33,13 +39,15 @@ namespace NCC.Extend | ||
| 33 | public class LqMajorProjectDirectorSalaryService : IDynamicApiController, ITransient | 39 | public class LqMajorProjectDirectorSalaryService : IDynamicApiController, ITransient |
| 34 | { | 40 | { |
| 35 | private readonly ISqlSugarClient _db; | 41 | private readonly ISqlSugarClient _db; |
| 42 | + private readonly ILogger<LqMajorProjectDirectorSalaryService> _logger; | ||
| 36 | 43 | ||
| 37 | /// <summary> | 44 | /// <summary> |
| 38 | /// 初始化一个<see cref="LqMajorProjectDirectorSalaryService"/>类型的新实例 | 45 | /// 初始化一个<see cref="LqMajorProjectDirectorSalaryService"/>类型的新实例 |
| 39 | /// </summary> | 46 | /// </summary> |
| 40 | - public LqMajorProjectDirectorSalaryService(ISqlSugarClient db) | 47 | + public LqMajorProjectDirectorSalaryService(ISqlSugarClient db, ILogger<LqMajorProjectDirectorSalaryService> logger) |
| 41 | { | 48 | { |
| 42 | _db = db; | 49 | _db = db; |
| 50 | + _logger = logger; | ||
| 43 | } | 51 | } |
| 44 | 52 | ||
| 45 | /// <summary> | 53 | /// <summary> |
| @@ -52,17 +60,7 @@ namespace NCC.Extend | @@ -52,17 +60,7 @@ namespace NCC.Extend | ||
| 52 | { | 60 | { |
| 53 | var monthStr = $"{input.Year}{input.Month:D2}"; | 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 | var query = _db.Queryable<LqMajorProjectDirectorSalaryStatisticsEntity>() | 64 | var query = _db.Queryable<LqMajorProjectDirectorSalaryStatisticsEntity>() |
| 67 | .Where(x => x.StatisticsMonth == monthStr); | 65 | .Where(x => x.StatisticsMonth == monthStr); |
| 68 | 66 | ||
| @@ -128,6 +126,71 @@ namespace NCC.Extend | @@ -128,6 +126,71 @@ namespace NCC.Extend | ||
| 128 | } | 126 | } |
| 129 | 127 | ||
| 130 | /// <summary> | 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 | /// </summary> | 195 | /// </summary> |
| 133 | /// <param name="year">年份</param> | 196 | /// <param name="year">年份</param> |
| @@ -362,12 +425,41 @@ namespace NCC.Extend | @@ -362,12 +425,41 @@ namespace NCC.Extend | ||
| 362 | // 3. 保存数据 | 425 | // 3. 保存数据 |
| 363 | if (directorStats.Any()) | 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,6 +534,420 @@ namespace NCC.Extend | ||
| 442 | public decimal RefundAmount { get; set; } | 534 | public decimal RefundAmount { get; set; } |
| 443 | public decimal TotalPerformance { get; set; } | 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 | using Microsoft.AspNetCore.Authorization; | 1 | using Microsoft.AspNetCore.Authorization; |
| 2 | using Microsoft.AspNetCore.Mvc; | 2 | using Microsoft.AspNetCore.Mvc; |
| 3 | +using Microsoft.Extensions.Logging; | ||
| 3 | using NCC.Common.Filter; | 4 | using NCC.Common.Filter; |
| 4 | using NCC.Common.Helper; | 5 | using NCC.Common.Helper; |
| 5 | using NCC.Dependency; | 6 | using NCC.Dependency; |
| 6 | using NCC.DynamicApiController; | 7 | using NCC.DynamicApiController; |
| 7 | using NCC.Extend.Entitys.Dto.LqMajorProjectTeacherSalary; | 8 | using NCC.Extend.Entitys.Dto.LqMajorProjectTeacherSalary; |
| 9 | +using NCC.Extend.Entitys.Dto.LqSalary; | ||
| 8 | using NCC.Extend.Entitys.lq_attendance_summary; | 10 | using NCC.Extend.Entitys.lq_attendance_summary; |
| 9 | using NCC.Extend.Entitys.lq_hytk_hytk; | 11 | using NCC.Extend.Entitys.lq_hytk_hytk; |
| 10 | using NCC.Extend.Entitys.lq_hytk_mx; | 12 | using NCC.Extend.Entitys.lq_hytk_mx; |
| @@ -15,13 +17,17 @@ using NCC.Extend.Entitys.lq_md_major_project_teacher_assignment; | @@ -15,13 +17,17 @@ using NCC.Extend.Entitys.lq_md_major_project_teacher_assignment; | ||
| 15 | using NCC.Extend.Entitys.lq_md_xdbhsj; | 17 | using NCC.Extend.Entitys.lq_md_xdbhsj; |
| 16 | using NCC.Extend.Entitys.lq_mdxx; | 18 | using NCC.Extend.Entitys.lq_mdxx; |
| 17 | using NCC.Extend.Entitys.lq_major_project_teacher_salary_statistics; | 19 | using NCC.Extend.Entitys.lq_major_project_teacher_salary_statistics; |
| 20 | +using NCC.FriendlyException; | ||
| 18 | using NCC.System.Entitys.Permission; | 21 | using NCC.System.Entitys.Permission; |
| 19 | using SqlSugar; | 22 | using SqlSugar; |
| 20 | using System; | 23 | using System; |
| 21 | using System.Collections.Generic; | 24 | using System.Collections.Generic; |
| 25 | +using System.Data; | ||
| 26 | +using System.IO; | ||
| 22 | using System.Linq; | 27 | using System.Linq; |
| 23 | using System.Threading.Tasks; | 28 | using System.Threading.Tasks; |
| 24 | using Yitter.IdGenerator; | 29 | using Yitter.IdGenerator; |
| 30 | +using Microsoft.AspNetCore.Http; | ||
| 25 | 31 | ||
| 26 | namespace NCC.Extend | 32 | namespace NCC.Extend |
| 27 | { | 33 | { |
| @@ -33,13 +39,15 @@ namespace NCC.Extend | @@ -33,13 +39,15 @@ namespace NCC.Extend | ||
| 33 | public class LqMajorProjectTeacherSalaryService : IDynamicApiController, ITransient | 39 | public class LqMajorProjectTeacherSalaryService : IDynamicApiController, ITransient |
| 34 | { | 40 | { |
| 35 | private readonly ISqlSugarClient _db; | 41 | private readonly ISqlSugarClient _db; |
| 42 | + private readonly ILogger<LqMajorProjectTeacherSalaryService> _logger; | ||
| 36 | 43 | ||
| 37 | /// <summary> | 44 | /// <summary> |
| 38 | /// 初始化一个<see cref="LqMajorProjectTeacherSalaryService"/>类型的新实例 | 45 | /// 初始化一个<see cref="LqMajorProjectTeacherSalaryService"/>类型的新实例 |
| 39 | /// </summary> | 46 | /// </summary> |
| 40 | - public LqMajorProjectTeacherSalaryService(ISqlSugarClient db) | 47 | + public LqMajorProjectTeacherSalaryService(ISqlSugarClient db, ILogger<LqMajorProjectTeacherSalaryService> logger) |
| 41 | { | 48 | { |
| 42 | _db = db; | 49 | _db = db; |
| 50 | + _logger = logger; | ||
| 43 | } | 51 | } |
| 44 | 52 | ||
| 45 | /// <summary> | 53 | /// <summary> |
| @@ -52,17 +60,7 @@ namespace NCC.Extend | @@ -52,17 +60,7 @@ namespace NCC.Extend | ||
| 52 | { | 60 | { |
| 53 | var monthStr = $"{input.Year}{input.Month:D2}"; | 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 | var query = _db.Queryable<LqMajorProjectTeacherSalaryStatisticsEntity>() | 64 | var query = _db.Queryable<LqMajorProjectTeacherSalaryStatisticsEntity>() |
| 67 | .Where(x => x.StatisticsMonth == monthStr); | 65 | .Where(x => x.StatisticsMonth == monthStr); |
| 68 | 66 | ||
| @@ -143,6 +141,86 @@ namespace NCC.Extend | @@ -143,6 +141,86 @@ namespace NCC.Extend | ||
| 143 | } | 141 | } |
| 144 | 142 | ||
| 145 | /// <summary> | 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 | /// </summary> | 225 | /// </summary> |
| 148 | /// <param name="year">年份</param> | 226 | /// <param name="year">年份</param> |
| @@ -418,12 +496,41 @@ namespace NCC.Extend | @@ -418,12 +496,41 @@ namespace NCC.Extend | ||
| 418 | // 5. 保存数据 | 496 | // 5. 保存数据 |
| 419 | if (teacherStats.Any()) | 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,6 +557,445 @@ namespace NCC.Extend | ||
| 450 | return (2.5m, totalPerformance * 0.025m); | 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 | using Microsoft.AspNetCore.Authorization; | 1 | using Microsoft.AspNetCore.Authorization; |
| 2 | +using Microsoft.AspNetCore.Http; | ||
| 2 | using Microsoft.AspNetCore.Mvc; | 3 | using Microsoft.AspNetCore.Mvc; |
| 4 | +using Microsoft.Extensions.Logging; | ||
| 3 | using NCC.Common.Enum; | 5 | using NCC.Common.Enum; |
| 4 | using NCC.Common.Filter; | 6 | using NCC.Common.Filter; |
| 5 | using NCC.Common.Helper; | 7 | using NCC.Common.Helper; |
| 6 | using NCC.Dependency; | 8 | using NCC.Dependency; |
| 7 | using NCC.DynamicApiController; | 9 | using NCC.DynamicApiController; |
| 8 | using NCC.Extend.Entitys.Dto.LqSalary; | 10 | using NCC.Extend.Entitys.Dto.LqSalary; |
| 11 | +using NCC.FriendlyException; | ||
| 12 | +using System.IO; | ||
| 9 | using Yitter.IdGenerator; | 13 | using Yitter.IdGenerator; |
| 10 | using NCC.Extend.Entitys.lq_attendance_summary; | 14 | using NCC.Extend.Entitys.lq_attendance_summary; |
| 11 | using NCC.Extend.Entitys.lq_jinsanjiao_user; | 15 | using NCC.Extend.Entitys.lq_jinsanjiao_user; |
| @@ -38,13 +42,15 @@ namespace NCC.Extend | @@ -38,13 +42,15 @@ namespace NCC.Extend | ||
| 38 | public class LqSalaryService : IDynamicApiController, ITransient | 42 | public class LqSalaryService : IDynamicApiController, ITransient |
| 39 | { | 43 | { |
| 40 | private readonly ISqlSugarClient _db; | 44 | private readonly ISqlSugarClient _db; |
| 45 | + private readonly ILogger<LqSalaryService> _logger; | ||
| 41 | 46 | ||
| 42 | /// <summary> | 47 | /// <summary> |
| 43 | /// 初始化一个<see cref="LqSalaryService"/>类型的新实例 | 48 | /// 初始化一个<see cref="LqSalaryService"/>类型的新实例 |
| 44 | /// </summary> | 49 | /// </summary> |
| 45 | - public LqSalaryService(ISqlSugarClient db) | 50 | + public LqSalaryService(ISqlSugarClient db, ILogger<LqSalaryService> logger) |
| 46 | { | 51 | { |
| 47 | _db = db; | 52 | _db = db; |
| 53 | + _logger = logger; | ||
| 48 | } | 54 | } |
| 49 | 55 | ||
| 50 | /// <summary> | 56 | /// <summary> |
| @@ -57,17 +63,7 @@ namespace NCC.Extend | @@ -57,17 +63,7 @@ namespace NCC.Extend | ||
| 57 | { | 63 | { |
| 58 | var monthStr = $"{input.Year}{input.Month:D2}"; | 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 | var query = _db.Queryable<LqSalaryStatisticsEntity>() | 67 | var query = _db.Queryable<LqSalaryStatisticsEntity>() |
| 72 | .Where(x => x.StatisticsMonth == monthStr); | 68 | .Where(x => x.StatisticsMonth == monthStr); |
| 73 | 69 | ||
| @@ -178,6 +174,146 @@ namespace NCC.Extend | @@ -178,6 +174,146 @@ namespace NCC.Extend | ||
| 178 | } | 174 | } |
| 179 | 175 | ||
| 180 | /// <summary> | 176 | /// <summary> |
| 177 | + /// 通过月份和员工ID查询工资 | ||
| 178 | + /// </summary> | ||
| 179 | + /// <remarks> | ||
| 180 | + /// 根据年份、月份和员工ID查询对应员工的工资记录 | ||
| 181 | + /// | ||
| 182 | + /// 示例请求: | ||
| 183 | + /// ``` | ||
| 184 | + /// GET /api/Extend/lqsalary/query-by-employee?Year=2026&Month=1&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 | /// </summary> | 318 | /// </summary> |
| 183 | /// <param name="year">年份</param> | 319 | /// <param name="year">年份</param> |
| @@ -710,7 +846,7 @@ namespace NCC.Extend | @@ -710,7 +846,7 @@ namespace NCC.Extend | ||
| 710 | // 获取战队人数 | 846 | // 获取战队人数 |
| 711 | var teamMemberList = employeeStats.Values.Where(x => x.GoldTriangleId == salary.GoldTriangleId).ToList(); | 847 | var teamMemberList = employeeStats.Values.Where(x => x.GoldTriangleId == salary.GoldTriangleId).ToList(); |
| 712 | var teamMemberCount = teamMemberList.Count; | 848 | var teamMemberCount = teamMemberList.Count; |
| 713 | - | 849 | + |
| 714 | // 单人或者一个人的战队就没有顾问 | 850 | // 单人或者一个人的战队就没有顾问 |
| 715 | if (teamMemberCount > 1) | 851 | if (teamMemberCount > 1) |
| 716 | { | 852 | { |
| @@ -769,13 +905,789 @@ namespace NCC.Extend | @@ -769,13 +905,789 @@ namespace NCC.Extend | ||
| 769 | // 5. 保存数据 | 905 | // 5. 保存数据 |
| 770 | if (employeeStats.Any()) | 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 | /// <summary> | 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 | /// </summary> | 1692 | /// </summary> |
| 781 | private decimal CalculateBaseSalary(decimal dailyAvgConsumption, decimal dailyAvgProjectCount, int daysInMonth, decimal workingDays, bool isNewStore) | 1693 | private decimal CalculateBaseSalary(decimal dailyAvgConsumption, decimal dailyAvgProjectCount, int daysInMonth, decimal workingDays, bool isNewStore) |
netcore/src/Modularity/Extend/NCC.Extend/LqStoreManagerSalaryService.cs
| 1 | using Microsoft.AspNetCore.Authorization; | 1 | using Microsoft.AspNetCore.Authorization; |
| 2 | +using Microsoft.AspNetCore.Http; | ||
| 2 | using Microsoft.AspNetCore.Mvc; | 3 | using Microsoft.AspNetCore.Mvc; |
| 4 | +using Microsoft.Extensions.Logging; | ||
| 3 | using NCC.Common.Filter; | 5 | using NCC.Common.Filter; |
| 4 | using NCC.Common.Helper; | 6 | using NCC.Common.Helper; |
| 5 | using NCC.Dependency; | 7 | using NCC.Dependency; |
| 6 | using NCC.DynamicApiController; | 8 | using NCC.DynamicApiController; |
| 9 | +using NCC.Extend.Entitys.Dto.LqSalary; | ||
| 7 | using NCC.Extend.Entitys.Dto.LqStoreManagerSalary; | 10 | using NCC.Extend.Entitys.Dto.LqStoreManagerSalary; |
| 8 | using NCC.Extend.Entitys.Enum; | 11 | using NCC.Extend.Entitys.Enum; |
| 9 | using NCC.Extend.Entitys.lq_attendance_summary; | 12 | using NCC.Extend.Entitys.lq_attendance_summary; |
| @@ -19,10 +22,13 @@ using NCC.Extend.Entitys.lq_store_expense; | @@ -19,10 +22,13 @@ using NCC.Extend.Entitys.lq_store_expense; | ||
| 19 | using NCC.Extend.Entitys.lq_store_manager_salary_statistics; | 22 | using NCC.Extend.Entitys.lq_store_manager_salary_statistics; |
| 20 | using NCC.Extend.Entitys.lq_xh_hyhk; | 23 | using NCC.Extend.Entitys.lq_xh_hyhk; |
| 21 | using NCC.Extend.Entitys.lq_xh_jksyj; | 24 | using NCC.Extend.Entitys.lq_xh_jksyj; |
| 25 | +using NCC.FriendlyException; | ||
| 22 | using NCC.System.Entitys.Permission; | 26 | using NCC.System.Entitys.Permission; |
| 23 | using SqlSugar; | 27 | using SqlSugar; |
| 24 | using System; | 28 | using System; |
| 25 | using System.Collections.Generic; | 29 | using System.Collections.Generic; |
| 30 | +using System.Data; | ||
| 31 | +using System.IO; | ||
| 26 | using System.Linq; | 32 | using System.Linq; |
| 27 | using System.Threading.Tasks; | 33 | using System.Threading.Tasks; |
| 28 | using Yitter.IdGenerator; | 34 | using Yitter.IdGenerator; |
| @@ -37,13 +43,15 @@ namespace NCC.Extend | @@ -37,13 +43,15 @@ namespace NCC.Extend | ||
| 37 | public class LqStoreManagerSalaryService : IDynamicApiController, ITransient | 43 | public class LqStoreManagerSalaryService : IDynamicApiController, ITransient |
| 38 | { | 44 | { |
| 39 | private readonly ISqlSugarClient _db; | 45 | private readonly ISqlSugarClient _db; |
| 46 | + private readonly ILogger<LqStoreManagerSalaryService> _logger; | ||
| 40 | 47 | ||
| 41 | /// <summary> | 48 | /// <summary> |
| 42 | /// 初始化一个<see cref="LqStoreManagerSalaryService"/>类型的新实例 | 49 | /// 初始化一个<see cref="LqStoreManagerSalaryService"/>类型的新实例 |
| 43 | /// </summary> | 50 | /// </summary> |
| 44 | - public LqStoreManagerSalaryService(ISqlSugarClient db) | 51 | + public LqStoreManagerSalaryService(ISqlSugarClient db, ILogger<LqStoreManagerSalaryService> logger) |
| 45 | { | 52 | { |
| 46 | _db = db; | 53 | _db = db; |
| 54 | + _logger = logger; | ||
| 47 | } | 55 | } |
| 48 | 56 | ||
| 49 | /// <summary> | 57 | /// <summary> |
| @@ -56,17 +64,7 @@ namespace NCC.Extend | @@ -56,17 +64,7 @@ namespace NCC.Extend | ||
| 56 | { | 64 | { |
| 57 | var monthStr = $"{input.Year}{input.Month:D2}"; | 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 | var query = _db.Queryable<LqStoreManagerSalaryStatisticsEntity>() | 68 | var query = _db.Queryable<LqStoreManagerSalaryStatisticsEntity>() |
| 71 | .Where(x => x.StatisticsMonth == monthStr); | 69 | .Where(x => x.StatisticsMonth == monthStr); |
| 72 | 70 | ||
| @@ -134,6 +132,73 @@ namespace NCC.Extend | @@ -134,6 +132,73 @@ namespace NCC.Extend | ||
| 134 | } | 132 | } |
| 135 | 133 | ||
| 136 | /// <summary> | 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 | /// </summary> | 203 | /// </summary> |
| 139 | /// <param name="year">年份</param> | 204 | /// <param name="year">年份</param> |
| @@ -546,12 +611,61 @@ namespace NCC.Extend | @@ -546,12 +611,61 @@ namespace NCC.Extend | ||
| 546 | // 3. 保存数据 | 611 | // 3. 保存数据 |
| 547 | if (storeManagerSalaryList.Any()) | 612 | if (storeManagerSalaryList.Any()) |
| 548 | { | 613 | { |
| 549 | - // 先删除当月旧数据 (防止重复) | ||
| 550 | - await _db.Deleteable<LqStoreManagerSalaryStatisticsEntity>() | 614 | + var existingRecords = await _db.Queryable<LqStoreManagerSalaryStatisticsEntity>() |
| 551 | .Where(x => x.StatisticsMonth == monthStr) | 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,6 +742,496 @@ namespace NCC.Extend | ||
| 628 | salary.CommissionRate = commissionRate; | 742 | salary.CommissionRate = commissionRate; |
| 629 | salary.CommissionAmount = grossProfit * commissionRate; | 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 | using Microsoft.AspNetCore.Authorization; | 1 | using Microsoft.AspNetCore.Authorization; |
| 2 | using Microsoft.AspNetCore.Mvc; | 2 | using Microsoft.AspNetCore.Mvc; |
| 3 | +using Microsoft.Extensions.Logging; | ||
| 3 | using NCC.Common.Filter; | 4 | using NCC.Common.Filter; |
| 4 | using NCC.Common.Helper; | 5 | using NCC.Common.Helper; |
| 5 | using NCC.Dependency; | 6 | using NCC.Dependency; |
| 6 | using NCC.DynamicApiController; | 7 | using NCC.DynamicApiController; |
| 8 | +using NCC.Extend.Entitys.Dto.LqSalary; | ||
| 7 | using NCC.Extend.Entitys.Dto.LqTechGeneralManagerSalary; | 9 | using NCC.Extend.Entitys.Dto.LqTechGeneralManagerSalary; |
| 8 | using NCC.Extend.Entitys.lq_attendance_summary; | 10 | using NCC.Extend.Entitys.lq_attendance_summary; |
| 9 | using NCC.Extend.Entitys.lq_hytk_hytk; | 11 | using NCC.Extend.Entitys.lq_hytk_hytk; |
| @@ -16,14 +18,18 @@ using NCC.Extend.Entitys.lq_md_general_manager_lifeline; | @@ -16,14 +18,18 @@ using NCC.Extend.Entitys.lq_md_general_manager_lifeline; | ||
| 16 | using NCC.Extend.Entitys.lq_mdxx; | 18 | using NCC.Extend.Entitys.lq_mdxx; |
| 17 | using NCC.Extend.Entitys.lq_tech_general_manager_salary_statistics; | 19 | using NCC.Extend.Entitys.lq_tech_general_manager_salary_statistics; |
| 18 | using NCC.Extend.Entitys.lq_xmzl; | 20 | using NCC.Extend.Entitys.lq_xmzl; |
| 21 | +using NCC.FriendlyException; | ||
| 19 | using NCC.System.Entitys.Permission; | 22 | using NCC.System.Entitys.Permission; |
| 20 | using SqlSugar; | 23 | using SqlSugar; |
| 21 | using System; | 24 | using System; |
| 22 | using System.Collections.Generic; | 25 | using System.Collections.Generic; |
| 26 | +using System.Data; | ||
| 27 | +using System.IO; | ||
| 23 | using System.Linq; | 28 | using System.Linq; |
| 24 | using System.Threading.Tasks; | 29 | using System.Threading.Tasks; |
| 25 | using Yitter.IdGenerator; | 30 | using Yitter.IdGenerator; |
| 26 | using Newtonsoft.Json; | 31 | using Newtonsoft.Json; |
| 32 | +using Microsoft.AspNetCore.Http; | ||
| 27 | 33 | ||
| 28 | namespace NCC.Extend | 34 | namespace NCC.Extend |
| 29 | { | 35 | { |
| @@ -35,13 +41,15 @@ namespace NCC.Extend | @@ -35,13 +41,15 @@ namespace NCC.Extend | ||
| 35 | public class LqTechGeneralManagerSalaryService : IDynamicApiController, ITransient | 41 | public class LqTechGeneralManagerSalaryService : IDynamicApiController, ITransient |
| 36 | { | 42 | { |
| 37 | private readonly ISqlSugarClient _db; | 43 | private readonly ISqlSugarClient _db; |
| 44 | + private readonly ILogger<LqTechGeneralManagerSalaryService> _logger; | ||
| 38 | 45 | ||
| 39 | /// <summary> | 46 | /// <summary> |
| 40 | /// 初始化一个<see cref="LqTechGeneralManagerSalaryService"/>类型的新实例 | 47 | /// 初始化一个<see cref="LqTechGeneralManagerSalaryService"/>类型的新实例 |
| 41 | /// </summary> | 48 | /// </summary> |
| 42 | - public LqTechGeneralManagerSalaryService(ISqlSugarClient db) | 49 | + public LqTechGeneralManagerSalaryService(ISqlSugarClient db, ILogger<LqTechGeneralManagerSalaryService> logger) |
| 43 | { | 50 | { |
| 44 | _db = db; | 51 | _db = db; |
| 52 | + _logger = logger; | ||
| 45 | } | 53 | } |
| 46 | 54 | ||
| 47 | /// <summary> | 55 | /// <summary> |
| @@ -54,17 +62,7 @@ namespace NCC.Extend | @@ -54,17 +62,7 @@ namespace NCC.Extend | ||
| 54 | { | 62 | { |
| 55 | var monthStr = $"{input.Year}{input.Month:D2}"; | 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 | var query = _db.Queryable<LqTechGeneralManagerSalaryStatisticsEntity>() | 66 | var query = _db.Queryable<LqTechGeneralManagerSalaryStatisticsEntity>() |
| 69 | .Where(x => x.StatisticsMonth == monthStr); | 67 | .Where(x => x.StatisticsMonth == monthStr); |
| 70 | 68 | ||
| @@ -132,6 +130,73 @@ namespace NCC.Extend | @@ -132,6 +130,73 @@ namespace NCC.Extend | ||
| 132 | } | 130 | } |
| 133 | 131 | ||
| 134 | /// <summary> | 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 | /// </summary> | 201 | /// </summary> |
| 137 | /// <param name="year">年份</param> | 202 | /// <param name="year">年份</param> |
| @@ -429,12 +494,41 @@ namespace NCC.Extend | @@ -429,12 +494,41 @@ namespace NCC.Extend | ||
| 429 | // 3. 保存数据 | 494 | // 3. 保存数据 |
| 430 | if (managerStats.Any()) | 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,8 +624,424 @@ namespace NCC.Extend | ||
| 530 | public decimal TraceabilityAmount { get; set; } | 624 | public decimal TraceabilityAmount { get; set; } |
| 531 | public decimal CellBillingAmount { get; set; } | 625 | public decimal CellBillingAmount { get; set; } |
| 532 | public decimal CellRefundAmount { get; set; } | 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 | using Microsoft.AspNetCore.Authorization; | 1 | using Microsoft.AspNetCore.Authorization; |
| 2 | +using Microsoft.AspNetCore.Http; | ||
| 2 | using Microsoft.AspNetCore.Mvc; | 3 | using Microsoft.AspNetCore.Mvc; |
| 4 | +using Microsoft.Extensions.Logging; | ||
| 3 | using NCC.Common.Filter; | 5 | using NCC.Common.Filter; |
| 4 | using NCC.Common.Helper; | 6 | using NCC.Common.Helper; |
| 5 | using NCC.Dependency; | 7 | using NCC.Dependency; |
| 6 | using NCC.DynamicApiController; | 8 | using NCC.DynamicApiController; |
| 9 | +using NCC.Extend.Entitys.Dto.LqSalary; | ||
| 7 | using NCC.Extend.Entitys.Dto.LqTechTeacherSalary; | 10 | using NCC.Extend.Entitys.Dto.LqTechTeacherSalary; |
| 8 | using NCC.Extend.Entitys.lq_hytk_kjbsyj; | 11 | using NCC.Extend.Entitys.lq_hytk_kjbsyj; |
| 9 | using NCC.Extend.Entitys.lq_kd_kjbsyj; | 12 | using NCC.Extend.Entitys.lq_kd_kjbsyj; |
| @@ -14,10 +17,13 @@ using NCC.Extend.Entitys.lq_tech_teacher_salary_statistics; | @@ -14,10 +17,13 @@ using NCC.Extend.Entitys.lq_tech_teacher_salary_statistics; | ||
| 14 | using NCC.Extend.Entitys.lq_xh_hyhk; | 17 | using NCC.Extend.Entitys.lq_xh_hyhk; |
| 15 | using NCC.Extend.Entitys.lq_xh_kjbsyj; | 18 | using NCC.Extend.Entitys.lq_xh_kjbsyj; |
| 16 | using NCC.Extend.Entitys.lq_person_times_record; | 19 | using NCC.Extend.Entitys.lq_person_times_record; |
| 20 | +using NCC.FriendlyException; | ||
| 17 | using NCC.System.Entitys.Permission; | 21 | using NCC.System.Entitys.Permission; |
| 18 | using SqlSugar; | 22 | using SqlSugar; |
| 19 | using System; | 23 | using System; |
| 20 | using System.Collections.Generic; | 24 | using System.Collections.Generic; |
| 25 | +using System.Data; | ||
| 26 | +using System.IO; | ||
| 21 | using System.Linq; | 27 | using System.Linq; |
| 22 | using System.Threading.Tasks; | 28 | using System.Threading.Tasks; |
| 23 | using Yitter.IdGenerator; | 29 | using Yitter.IdGenerator; |
| @@ -32,13 +38,15 @@ namespace NCC.Extend | @@ -32,13 +38,15 @@ namespace NCC.Extend | ||
| 32 | public class LqTechTeacherSalaryService : IDynamicApiController, ITransient | 38 | public class LqTechTeacherSalaryService : IDynamicApiController, ITransient |
| 33 | { | 39 | { |
| 34 | private readonly ISqlSugarClient _db; | 40 | private readonly ISqlSugarClient _db; |
| 41 | + private readonly ILogger<LqTechTeacherSalaryService> _logger; | ||
| 35 | 42 | ||
| 36 | /// <summary> | 43 | /// <summary> |
| 37 | /// 初始化一个<see cref="LqTechTeacherSalaryService"/>类型的新实例 | 44 | /// 初始化一个<see cref="LqTechTeacherSalaryService"/>类型的新实例 |
| 38 | /// </summary> | 45 | /// </summary> |
| 39 | - public LqTechTeacherSalaryService(ISqlSugarClient db) | 46 | + public LqTechTeacherSalaryService(ISqlSugarClient db, ILogger<LqTechTeacherSalaryService> logger) |
| 40 | { | 47 | { |
| 41 | _db = db; | 48 | _db = db; |
| 49 | + _logger = logger; | ||
| 42 | } | 50 | } |
| 43 | 51 | ||
| 44 | /// <summary> | 52 | /// <summary> |
| @@ -51,17 +59,7 @@ namespace NCC.Extend | @@ -51,17 +59,7 @@ namespace NCC.Extend | ||
| 51 | { | 59 | { |
| 52 | var monthStr = $"{input.Year}{input.Month:D2}"; | 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 | var query = _db.Queryable<LqTechTeacherSalaryStatisticsEntity>() | 63 | var query = _db.Queryable<LqTechTeacherSalaryStatisticsEntity>() |
| 66 | .Where(x => x.StatisticsMonth == monthStr); | 64 | .Where(x => x.StatisticsMonth == monthStr); |
| 67 | 65 | ||
| @@ -146,6 +144,90 @@ namespace NCC.Extend | @@ -146,6 +144,90 @@ namespace NCC.Extend | ||
| 146 | } | 144 | } |
| 147 | 145 | ||
| 148 | /// <summary> | 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 | /// </summary> | 232 | /// </summary> |
| 151 | /// <param name="year">年份</param> | 233 | /// <param name="year">年份</param> |
| @@ -412,12 +494,71 @@ namespace NCC.Extend | @@ -412,12 +494,71 @@ namespace NCC.Extend | ||
| 412 | // 4. 保存数据 | 494 | // 4. 保存数据 |
| 413 | if (techTeacherStats.Any()) | 495 | if (techTeacherStats.Any()) |
| 414 | { | 496 | { |
| 415 | - // 先删除当月旧数据 (防止重复) | ||
| 416 | - await _db.Deleteable<LqTechTeacherSalaryStatisticsEntity>() | 497 | + // 查询当月已存在的记录(用于检查是否已锁定或已确认) |
| 498 | + var existingRecords = await _db.Queryable<LqTechTeacherSalaryStatisticsEntity>() | ||
| 417 | .Where(x => x.StatisticsMonth == monthStr) | 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,14 +911,632 @@ namespace NCC.Extend | ||
| 770 | PersonTimes = personTimesStat?.PersonTimes ?? 0m, | 911 | PersonTimes = personTimesStat?.PersonTimes ?? 0m, |
| 771 | LaborCost = consumeStat?.LaborCost ?? 0m, // 手工费只统计耗卡中的手工费 | 912 | LaborCost = consumeStat?.LaborCost ?? 0m, // 手工费只统计耗卡中的手工费 |
| 772 | DepartmentId = teacher.OrganizeId, | 913 | DepartmentId = teacher.OrganizeId, |
| 773 | - DepartmentName = !string.IsNullOrEmpty(teacher.DepartmentName) | ||
| 774 | - ? teacher.DepartmentName | 914 | + DepartmentName = !string.IsNullOrEmpty(teacher.DepartmentName) |
| 915 | + ? teacher.DepartmentName | ||
| 775 | : (techOrganizeDict.ContainsKey(teacher.OrganizeId) ? techOrganizeDict[teacher.OrganizeId] : "") | 916 | : (techOrganizeDict.ContainsKey(teacher.OrganizeId) ? techOrganizeDict[teacher.OrganizeId] : "") |
| 776 | }); | 917 | }); |
| 777 | } | 918 | } |
| 778 | 919 | ||
| 779 | return result; | 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 |