diff --git a/docs/健康师工资核算规则说明.md b/docs/健康师工资核算规则说明.md new file mode 100644 index 0000000..9c0c221 --- /dev/null +++ b/docs/健康师工资核算规则说明.md @@ -0,0 +1,343 @@ +# 健康师工资核算规则说明 + +## 一、适用范围 + +本规则适用于所有岗位为"健康师"的员工。 + +## 二、工资构成 + +健康师工资 = **底薪** + **提成** + **手工费** + **补贴合计** - **扣款合计** + +--- + +## 三、底薪计算规则 + +### 3.1 底薪档位标准(按日均计算) + +底薪按**日均消耗**和**日均项目数**两项指标评定星级,取两项指标中的较低星级作为最终星级。 + +**日均标准计算方式:** +- 一星标准:月消耗 10,000元 ÷ 当月天数,项目数 96个 ÷ 当月天数 +- 二星标准:月消耗 20,000元 ÷ 当月天数,项目数 126个 ÷ 当月天数 +- 三星标准:月消耗 40,000元 ÷ 当月天数,项目数 156个 ÷ 当月天数 + +**星级评定规则:** +- **0星**:两项指标均未达标 → 底薪 1,800元 +- **1星**:日均消耗 ≥ 一星标准 且 日均项目数 ≥ 一星标准 → 底薪 2,000元 +- **2星**:日均消耗 ≥ 二星标准 且 日均项目数 ≥ 二星标准 → 底薪 2,200元 +- **3星**:日均消耗 ≥ 三星标准 且 日均项目数 ≥ 三星标准 → 底薪 2,400元 + +**特殊规则:** +1. **单项达标保护**:如果消耗和项目数中仅一项未达标(达到0星),底薪按1星(2,000元)计算 +2. **新店保底**:新店底薪最低为1星(2,000元),即使不满足1星标准也按1星计算 + +### 3.2 底薪按在店天数计算 + +**最终底薪 = (档位底薪 ÷ 当月天数)× 在店天数** + +**在店天数说明:** +- 从考勤数据中获取,仅统计有效工作日 +- 不包含请假天数 + +--- + +## 四、业绩数据说明 + +### 4.1 业绩分类 + +- **基础业绩**:基础项目的开单业绩 +- **合作业绩**:合作项目的开单业绩 +- **总业绩**:基础业绩 + 合作业绩 +- **新客业绩**:首次到店客户的开单业绩 +- **升单业绩**:非首次到店客户的开单业绩 + +### 4.2 业绩扣除 + +- 所有业绩需扣除对应的退款金额 +- 基础业绩扣除基础业绩退款 +- 合作业绩扣除合作业绩退款 +- 总业绩扣除所有退款 + +### 4.3 实际基础业绩计算 + +**实际基础业绩 = 基础业绩 - 基础奖励业绩 + 其他业绩加 - 其他业绩减** + +**新店特殊规则:** +- **第一阶段**:实际基础业绩 = 基础业绩 - 基础奖励业绩 + 其他业绩加 - 其他业绩减 - 新客业绩 +- **第二阶段**:实际基础业绩 = 基础业绩 - 基础奖励业绩 + 其他业绩加 - 其他业绩减 - 升单业绩 +- **第三阶段**:无特殊扣除 + +### 4.4 实际合作业绩计算 + +**实际合作业绩 = 合作业绩 - 合作奖励业绩** + +--- + +## 五、提成计算规则 + +### 5.1 提成前提条件 + +**战队成员提成门槛:** +- 如果健康师属于战队成员,个人总业绩必须 ≥ 6,000元(按日均计算) +- 日均业绩门槛 = (6,000元 ÷ 当月天数)× 在店天数 +- 如果个人总业绩低于门槛,所有提成为0 + +**单人提成门槛:** +- 单人无此门槛限制 + +### 5.2 提成点确定规则 + +#### 5.2.1 战队成员(3人及以上) + +根据战队总业绩确定提成点(不按日均考核): + +| 战队总业绩 | 提成点 | +|-----------|--------| +| ≥ 15万元 | 7% | +| ≥ 12万元 | 6% | +| ≥ 9万元 | 5% | +| ≥ 6万元 | 4% | +| ≥ 3万元 | 3% | +| < 3万元 | 0% | + +#### 5.2.2 战队成员(2人) + +根据战队总业绩确定提成点(不按日均考核): + +| 战队总业绩 | 提成点 | +|-----------|--------| +| ≥ 8万元 | 6% | +| ≥ 6万元 | 5% | +| ≥ 4万元 | 4% | +| ≥ 2万元 | 3% | +| < 2万元 | 0% | + +#### 5.2.3 单人(或被剔除出战队) + +根据个人总业绩确定提成点(**按日均考核**): + +日均业绩门槛 = 标准门槛 ÷ 当月天数 × 在店天数 + +| 日均业绩 | 提成点 | +|---------|--------| +| ≥ 6万元日均 | 6% | +| ≥ 4万元日均 | 5% | +| ≥ 2万元日均 | 4% | +| ≥ 1万元日均 | 3% | +| < 1万元日均 | 0% | + +### 5.3 基础业绩提成 + +**基础业绩提成 = 实际基础业绩 × 0.95 × 提成点** + +### 5.4 合作业绩提成 + +**合作业绩提成 = 实际合作业绩 × 0.95 × 0.65 × 提成点** + +### 5.5 新客转化率提成(仅新店第一阶段) + +**适用范围:** 新店第一阶段 + +**提成比例:** + +| 新客转化率 | 提成比例 | +|-----------|---------| +| ≥ 50% | 20% | +| ≥ 45% | 15% | +| ≥ 35% | 10% | +| ≥ 0% | 6% | + +**计算公式:** +新客转化率提成 = 新客业绩 × 提成比例 × 0.95 + +### 5.6 升单人头提成(仅新店第二阶段) + +**适用范围:** 新店第二阶段 + +**提成比例:** + +| 升单人头数 | 提成比例 | +|----------|---------| +| ≥ 10人 | 12% | +| 7-9人 | 10% | +| 4-6人 | 7% | +| < 4人 | 0% | + +**计算公式:** +升单人头提成 = 升单业绩 × 提成比例 × 0.95 + +### 5.7 顾问提成 + +**适用范围:** 仅战队队长(岗位为"顾问") + +**前提条件:** +- 战队人数必须 ≥ 2人 +- 单人战队无顾问提成 + +**3人及以上战队规则:** +- 战队总业绩 ≥ 6万元 +- 组员业绩(除顾问外的其他成员业绩总和)≥ 战队总业绩 × 40% +- 整组消耗 ≥ 6万元(新店不考核消耗) +- **提成金额 = 战队总业绩 × 0.8%** + +**2人战队规则:** +- 战队总业绩 ≥ 4万元 +- 组员业绩(除顾问外的其他成员业绩总和)≥ 战队总业绩 × 30% +- 整组消耗 ≥ 4万元(新店不考核消耗) +- **提成金额 = 战队总业绩 × 0.3%** + +**说明:** +- "组员业绩"指除顾问外的其他成员业绩总和 +- 只统计有效战队成员(考勤≥20天,未被剔除的成员) +- 新店不考核消耗条件 + +### 5.8 门店T区提成 + +**适用范围:** 姓名包含"T区"的员工 + +**计算公式:** +门店T区提成 = 门店总业绩 × 0.05 × 0.05 + +**特殊规则:** +- T区人员仅核算提成,其他项(底薪、手工费、基础提成、合作提成、顾问提成、新客提成、升单提成、补贴、扣款)全部归零 + +### 5.9 提成合计 + +**提成合计 = 基础业绩提成 + 合作业绩提成 + 顾问提成 + 新客转化率提成 + 升单人头提成 + 门店T区提成** + +--- + +## 六、战队规则说明 + +### 6.1 战队成员资格 + +- 健康师需要被分配到战队(金三角战队) +- 战队成员共享战队总业绩 + +### 6.2 考勤要求 + +- **有效战队成员**:在店天数 ≥ 20天 +- **无效成员处理**:在店天数 < 20天的成员,将被剔除出战队,按单人计算 + - 移除战队标识 + - 战队业绩 = 个人总业绩 + - 岗位降级为"健康师"(如果是顾问,则降级) + +### 6.3 岗位说明 + +- **健康师**:普通战队成员或单人 +- **顾问**:战队队长(需在战队配置中设置为队长) + - 单人战队或被剔除出战队后,顾问自动降级为健康师 + - 顾问可享受顾问提成 + +--- + +## 七、新店规则说明 + +### 7.1 新店识别 + +- 根据门店新店保护配置表判断 +- 统计月份的第一天在保护期内,则视为新店 + +### 7.2 新店阶段 + +新店分为三个阶段,不同阶段有不同的业绩扣除和提成规则: + +- **第一阶段**:扣除新客业绩,计算新客转化率提成 +- **第二阶段**:扣除升单业绩,计算升单人头提成 +- **第三阶段**:无特殊扣除,不计算新客/升单提成 + +### 7.3 新店特殊规则 + +1. **底薪保底**:新店底薪最低为1星(2,000元) +2. **顾问提成**:新店顾问不考核消耗条件 +3. **新客/升单提成**:仅在新店的第一阶段和第二阶段计算,且需乘以0.95系数 + +--- + +## 八、其他说明 + +### 8.1 手工费 + +- 从消耗记录中统计 +- 按实际产生的手工费金额计算 + +### 8.2 最终工资计算 + +**实发工资 = 底薪 + 提成合计 + 手工费 + 补贴合计 - 扣款合计** + +### 8.3 数据来源 + +- **业绩数据**:开单记录表(lq_kd_jksyj) +- **退款数据**:退款记录表(lq_hytk_jksyj) +- **消耗数据**:消耗记录表(lq_xh_jksyj) +- **考勤数据**:考勤汇总表(lq_attendance_summary) +- **战队信息**:战队成员表(lq_jinsanjiao_user) +- **新店信息**:门店新店保护表(lq_md_xdbhsj) +- **额外计算数据**:健康师工资额外计算表(lq_salary_extra_calculation) + +### 8.4 注意事项 + +1. 所有金额计算保留2位小数 +2. 在店天数必须 > 0,否则底薪为0 +3. 业绩和消耗数据仅统计有效记录(IsEffective = 1) +4. 退款金额从总业绩中扣除 +5. 提成计算使用实际业绩(扣除奖励业绩和其他调整后的业绩) +6. 战队成员按战队总业绩确定提成点,单人按个人总业绩确定提成点 +7. 单人提成点按日均考核,战队成员提成点不按日均考核 + +--- + +## 九、核算流程建议 + +1. **数据准备** + - 确认当月天数 + - 确认新店保护配置 + - 确认战队成员配置 + - 确认考勤数据完整 + +2. **底薪核算** + - 计算日均消耗和日均项目数 + - 评定星级 + - 计算最终底薪 + +3. **业绩统计** + - 统计个人业绩(基础、合作、新客、升单) + - 扣除退款 + - 计算实际业绩 + +4. **提成核算** + - 判断是否满足提成门槛 + - 确定提成点 + - 计算各项提成 + - 汇总提成合计 + +5. **最终核算** + - 汇总底薪、提成、手工费 + - 加上补贴 + - 扣除扣款 + - 得出实发工资 + +--- + +## 十、常见问题 + +**Q1:如果健康师在店天数为0,底薪是多少?** +A:在店天数为0时,底薪为0。 + +**Q2:战队成员如果个人业绩低于6,000元,是否还有提成?** +A:如果个人总业绩低于6,000元(按日均计算的门槛),所有提成为0。 + +**Q3:新店三个阶段如何区分?** +A:根据门店新店保护配置表中的阶段字段(Stage)区分:1=第一阶段,2=第二阶段,3=第三阶段。 + +**Q4:顾问提成如何计算?** +A:顾问提成基于战队总业绩,需要满足业绩、组员业绩占比、消耗(新店不考核)等条件,具体比例见"5.7 顾问提成"章节。 + +**Q5:T区人员如何核算?** +A:T区人员仅核算门店T区提成(门店总业绩 × 0.05 × 0.05),其他所有项目(底薪、手工费、其他提成等)均为0。 + +--- + +**文档版本:** v1.0 +**最后更新:** 2026-01-09 +**适用范围:** 健康师工资核算 diff --git a/docs/员工门店归属变更问题分析与解决方案.md b/docs/员工门店归属变更问题分析与解决方案.md new file mode 100644 index 0000000..9f90bf0 --- /dev/null +++ b/docs/员工门店归属变更问题分析与解决方案.md @@ -0,0 +1,383 @@ +# 员工门店归属变更问题分析与解决方案 + +## 📋 问题描述 + +### 业务场景 +员工每个月的归属门店可能会发生变化。当发生改变后,在以下场景中会遇到门店归属问题: + +1. **工资计算场景**: + - 员工A在1月在门店A工作 + - 2月调到门店B(`BASE_USER.F_Mdid` 更新为门店B) + - 计算1月工资时,如何确定该员工1月的归属门店? + +2. **数据补录场景**: + - 员工A在1月在门店A产生开单/消耗数据 + - 2月调到门店B + - 如果1月的某些开单/消耗数据在2月补录,这些数据应该归属到哪个门店? + +### 核心矛盾 +- **时间维度**:数据应该按照实际发生时间归属门店 +- **当前归属**:`BASE_USER.F_Mdid` 只记录当前门店归属,无法追溯历史 +- **数据完整性**:补录的历史数据需要正确的门店归属 + +--- + +## 🔍 现状分析 + +### 当前数据存储情况 + +1. **员工门店归属**: + - 存储位置:`BASE_USER.F_Mdid` + - 特点:只有一个字段,记录当前门店ID + - 问题:无法追溯历史门店归属 + +2. **开单/消耗数据**: + - 开单业绩表 `lq_kd_jksyj`:有 `F_StoreId` 字段(记录开单时的门店) + - 消耗表 `lq_xh_jksyj`:有 `F_StoreId` 字段(记录消耗时的门店) + - 开单主表 `lq_kd_kdjlb`:有 `djmd` 字段(单据门店) + +3. **工资计算逻辑**(以健康师工资为例): + ```csharp + // 当前逻辑: + // 1. 优先从业绩数据中获取门店(performanceData中的StoreId) + // 2. 如果没有,从消耗数据中获取门店 + // 3. 如果还没有,使用 BASE_USER.F_Mdid + ``` + +### 存在的问题 + +1. **门店归属判断不准确**: + - 如果业务数据(开单/消耗)中没有门店信息,会回退到使用 `BASE_USER.F_Mdid` + - 但 `BASE_USER.F_Mdid` 是当前门店,不是历史门店 + +2. **补录数据归属问题**: + - 补录历史数据时,如果没有明确的门店信息,可能被错误归属 + +3. **工资计算偏差**: + - 计算历史月份工资时,可能使用了错误的门店归属 + - 影响门店统计、新店判断等逻辑 + +--- + +## 💡 解决方案思路 + +### 方案一:时间维度优先 + 门店归属快照表(推荐) + +#### 核心思路 +以**时间维度**为唯一标准,通过门店归属快照表记录员工每月的门店归属。 + +#### 实现方案 + +1. **创建门店归属快照表** + ``` + 表名:lq_employee_store_assignment(员工门店归属表) + + 字段: + - F_Id:主键ID + - F_EmployeeId:员工ID + - F_StoreId:门店ID + - F_StoreName:门店名称(冗余字段,便于查询) + - F_Year:年份 + - F_Month:月份 + - F_StatisticsMonth:统计月份(YYYYMM格式) + - F_StartDate:归属开始日期(精确到天,用于处理月中调店) + - F_EndDate:归属结束日期(可为空,表示当前) + - F_CreateTime:创建时间 + - F_UpdateTime:更新时间 + - F_CreateUser:创建人 + ``` + +2. **门店归属维护机制** + - 在员工调店时,自动创建/更新归属记录 + - 支持月中调店(一个员工一个月可能归属多个门店) + - 支持批量导入历史归属数据 + +3. **工资计算逻辑调整** + ``` + 工资计算时: + 1. 根据统计月份(YYYYMM)查询门店归属快照表 + 2. 如果一个月有多个门店,按时间范围拆分计算 + 3. 如果查询不到归属记录,再回退到当前逻辑 + ``` + +4. **数据补录逻辑** + ``` + 补录数据时: + 1. 根据数据实际发生时间(Yjsj)确定月份 + 2. 查询该月份的门店归属快照 + 3. 将数据的StoreId设置为归属门店 + ``` + +#### 优点 +- ✅ 时间维度清晰,历史可追溯 +- ✅ 支持月中调店场景 +- ✅ 不影响现有数据结构 +- ✅ 工资计算和数据补录都基于时间维度 + +#### 缺点 +- ⚠️ 需要维护额外的归属表 +- ⚠️ 需要迁移历史数据 + +--- + +### 方案二:业务数据中强制记录门店信息 + +#### 核心思路 +在开单/消耗数据录入时,强制要求记录门店信息,工资计算完全依赖业务数据中的门店。 + +#### 实现方案 + +1. **数据录入规则** + - 开单/消耗数据必须包含门店信息(StoreId) + - 补录数据时,根据数据发生时间,查询当时的门店归属 + - 如果无法确定,要求用户手动选择门店 + +2. **工资计算逻辑** + ``` + 工资计算时: + 1. 只从业务数据(开单/消耗)中获取门店信息 + 2. 如果业务数据中没有门店信息,报错或跳过 + 3. 不再使用 BASE_USER.F_Mdid 作为回退 + ``` + +#### 优点 +- ✅ 数据来源单一,逻辑清晰 +- ✅ 不需要额外的表结构 + +#### 缺点 +- ❌ 如果业务数据中没有门店信息,无法计算工资 +- ❌ 补录历史数据时,需要手动确定门店归属 +- ❌ 数据完整性要求高 + +--- + +### 方案三:BASE_USER表增加门店归属历史字段 + +#### 核心思路 +在 `BASE_USER` 表中增加字段,记录门店归属历史(JSON格式或关联表)。 + +#### 实现方案 + +1. **字段设计** + ``` + 方案A:JSON字段 + F_StoreAssignmentHistory:JSON格式存储历史归属 + { + "202501": "store_id_1", + "202502": "store_id_2", + ... + } + + 方案B:关联表 + 创建 BASE_USER_STORE_HISTORY 表 + 记录员工每个月的门店归属 + ``` + +2. **维护机制** + - 员工调店时,更新历史记录 + - 支持批量导入历史数据 + +#### 优点 +- ✅ 数据集中管理 +- ✅ 查询方便 + +#### 缺点 +- ⚠️ 修改核心用户表,影响面大 +- ⚠️ JSON字段查询性能较差 +- ⚠️ 关联表方案与方案一类似 + +--- + +### 方案四:工资统计表记录门店归属 + +#### 核心思路 +在工资统计表中记录门店归属,计算工资时使用该记录,但首次计算需要确定归属。 + +#### 实现方案 + +1. **工资统计表已有字段** + - `F_StoreId`:门店ID + - `F_StoreName`:门店名称 + - 这些字段已经记录了工资计算时的门店归属 + +2. **逻辑调整** + ``` + 工资计算时: + 1. 查询历史工资统计记录 + 2. 如果存在记录,使用记录中的门店归属 + 3. 如果不存在,使用当前逻辑确定门店归属 + ``` + +#### 优点 +- ✅ 不需要额外的表结构 +- ✅ 工资记录本身就是历史快照 + +#### 缺点 +- ❌ 首次计算工资时,门店归属判断仍有问题 +- ❌ 数据补录时,无法确定门店归属 +- ❌ 不能解决数据补录的问题 + +--- + +## 🎯 推荐方案 + +### 推荐:方案一(门店归属快照表) + +#### 理由 +1. **时间维度清晰**:以时间维度为唯一标准,符合业务逻辑 +2. **完整解决**:同时解决工资计算和数据补录的问题 +3. **扩展性好**:支持月中调店、批量导入等场景 +4. **影响面小**:新增表结构,不影响现有逻辑 + +#### 实施步骤建议 + +1. **第一阶段:数据准备** + - 创建门店归属快照表 + - 整理历史员工门店归属数据 + - 批量导入历史归属数据 + +2. **第二阶段:功能开发** + - 开发门店归属维护功能(调店时自动创建记录) + - 调整工资计算逻辑(优先使用快照表) + - 调整数据补录逻辑(根据时间查询归属) + +3. **第三阶段:数据迁移** + - 迁移历史数据 + - 验证数据准确性 + - 逐步切换到新逻辑 + +4. **第四阶段:优化** + - 性能优化 + - 用户体验优化 + - 监控和告警 + +--- + +## 📊 方案对比 + +| 方案 | 实施难度 | 数据完整性 | 可追溯性 | 扩展性 | 推荐度 | +|------|---------|-----------|---------|--------|--------| +| 方案一:快照表 | 中等 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | +| 方案二:业务数据强制 | 低 | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐ | +| 方案三:BASE_USER扩展 | 高 | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ | +| 方案四:工资表记录 | 低 | ⭐⭐ | ⭐⭐⭐ | ⭐⭐ | ⭐⭐ | + +--- + +## 🔧 技术实现要点 + +### 1. 门店归属快照表设计 + +```sql +CREATE TABLE lq_employee_store_assignment ( + F_Id VARCHAR(50) PRIMARY KEY, + F_EmployeeId VARCHAR(50) NOT NULL COMMENT '员工ID', + F_StoreId VARCHAR(50) NOT NULL COMMENT '门店ID', + F_StoreName VARCHAR(200) COMMENT '门店名称', + F_Year INT NOT NULL COMMENT '年份', + F_Month INT NOT NULL COMMENT '月份', + F_StatisticsMonth VARCHAR(6) NOT NULL COMMENT '统计月份YYYYMM', + F_StartDate DATE COMMENT '归属开始日期', + F_EndDate DATE COMMENT '归属结束日期', + F_CreateTime DATETIME, + F_UpdateTime DATETIME, + F_CreateUser VARCHAR(50), + INDEX idx_employee_month (F_EmployeeId, F_StatisticsMonth), + INDEX idx_store_month (F_StoreId, F_StatisticsMonth) +) COMMENT '员工门店归属表'; +``` + +### 2. 工资计算逻辑调整要点 + +```csharp +// 伪代码示例 +public async Task CalculateSalary(int year, int month) +{ + var monthStr = $"{year}{month:D2}"; + + // 1. 查询员工门店归属(优先使用快照表) + var storeAssignments = await _db.Queryable() + .Where(x => x.StatisticsMonth == monthStr) + .ToListAsync(); + + // 2. 如果没有快照记录,使用当前逻辑(回退方案) + if (!storeAssignments.Any()) + { + // 使用现有逻辑:从业务数据或BASE_USER获取 + } + + // 3. 按门店归属计算工资 + foreach (var assignment in storeAssignments) + { + // 查询该员工在该门店的业绩数据 + // 计算工资 + } +} +``` + +### 3. 数据补录逻辑调整要点 + +```csharp +// 伪代码示例 +public async Task ImportBillingData(DateTime billingDate, string employeeId) +{ + var monthStr = $"{billingDate.Year}{billingDate.Month:D2}"; + + // 1. 查询该月份的门店归属 + var assignment = await _db.Queryable() + .Where(x => x.EmployeeId == employeeId && x.StatisticsMonth == monthStr) + .Where(x => billingDate >= x.StartDate && (x.EndDate == null || billingDate <= x.EndDate)) + .FirstAsync(); + + // 2. 设置数据的门店ID + billingRecord.StoreId = assignment?.StoreId ?? GetStoreIdByCurrentLogic(); +} +``` + +--- + +## ⚠️ 注意事项 + +1. **数据一致性** + - 门店归属快照表的数据必须准确 + - 需要定期检查和校验 + +2. **性能考虑** + - 门店归属查询需要建立合适的索引 + - 工资计算时批量查询,避免N+1问题 + +3. **回退方案** + - 如果快照表中没有记录,需要有回退逻辑 + - 回退逻辑可以使用现有逻辑(业务数据或BASE_USER) + +4. **月中调店处理** + - 如果一个员工一个月内调店,需要创建多条记录 + - 工资计算时,需要按时间范围拆分计算 + +5. **历史数据迁移** + - 需要整理历史员工门店归属数据 + - 可能需要手动确认部分数据 + +--- + +## 📝 总结 + +员工门店归属变更问题的核心是**时间维度与当前归属的矛盾**。 + +**推荐使用方案一(门店归属快照表)**,理由: +- 以时间维度为唯一标准,符合业务逻辑 +- 完整解决工资计算和数据补录的问题 +- 扩展性好,支持复杂场景 +- 实施难度适中,影响面可控 + +实施时需要重点关注: +- 数据准确性(门店归属快照表) +- 性能优化(索引、批量查询) +- 回退方案(数据缺失时的处理) +- 历史数据迁移(数据整理和导入) + +--- + +**文档版本:** v1.0 +**创建日期:** 2026-01-09 +**文档性质:** 问题分析与方案设计(仅思考,不修改代码) diff --git a/docs/员工门店归属快照表方案详细设计.md b/docs/员工门店归属快照表方案详细设计.md new file mode 100644 index 0000000..cbeb57d --- /dev/null +++ b/docs/员工门店归属快照表方案详细设计.md @@ -0,0 +1,1047 @@ +# 员工门店归属快照表方案详细设计 + +## 📋 方案概述 + +通过创建门店归属快照表,记录员工每个月的门店归属历史,以时间维度为唯一标准,解决工资计算和数据补录中的门店归属问题。 + +--- + +## 🗄️ 数据模型设计 + +### 1. 核心表设计 + +#### 表名:`lq_employee_store_assignment`(员工门店归属表) + +#### 字段设计 + +| 字段名 | 类型 | 说明 | 约束 | 备注 | +|--------|------|------|------|------| +| F_Id | VARCHAR(50) | 主键ID | PRIMARY KEY | 使用YitIdHelper生成 | +| F_EmployeeId | VARCHAR(50) | 员工ID | NOT NULL | 关联BASE_USER.F_Id | +| F_StoreId | VARCHAR(50) | 门店ID | NOT NULL | 关联lq_mdxx.F_Id | +| F_StoreName | VARCHAR(200) | 门店名称 | | 冗余字段,便于查询和显示 | +| F_Year | INT | 年份 | NOT NULL | 如:2025 | +| F_Month | INT | 月份 | NOT NULL | 1-12 | +| F_StatisticsMonth | VARCHAR(6) | 统计月份 | NOT NULL | YYYYMM格式,如:202501 | +| F_StartDate | DATE | 归属开始日期 | NOT NULL | 精确到天,用于月中调店 | +| F_EndDate | DATE | 归属结束日期 | NULL | 为空表示当前仍在归属,精确到天 | +| F_CreateTime | DATETIME | 创建时间 | | | +| F_UpdateTime | DATETIME | 更新时间 | | | +| F_CreateUser | VARCHAR(50) | 创建人 | | | +| F_UpdateUser | VARCHAR(50) | 更新人 | | | +| F_IsEffective | INT | 是否有效 | DEFAULT 1 | 1=有效,0=无效(软删除) | +| F_Remark | VARCHAR(500) | 备注 | | 记录调店原因等 | + +#### 索引设计 + +```sql +-- 主键索引(自动创建) +PRIMARY KEY (F_Id) + +-- 员工+月份查询索引(最常用) +INDEX idx_employee_month (F_EmployeeId, F_StatisticsMonth, F_IsEffective) + +-- 门店+月份查询索引(用于门店维度统计) +INDEX idx_store_month (F_StoreId, F_StatisticsMonth, F_IsEffective) + +-- 日期范围查询索引(用于精确查询某天的归属) +INDEX idx_employee_date (F_EmployeeId, F_StartDate, F_EndDate) + +-- 时间范围查询索引(用于查询某个时间点的归属) +INDEX idx_date_range (F_StartDate, F_EndDate, F_IsEffective) +``` + +#### 唯一约束 + +**重要考虑:是否允许一个员工在一个月内归属多个门店?** + +**建议:允许**(支持月中调店场景) + +**唯一约束设计:** +- 不设置唯一约束(允许一个员工一个月有多条记录) +- 通过业务逻辑保证:同一个员工的同一天只能有一条有效记录 +- 通过应用层校验:插入/更新时检查是否有日期重叠 + +--- + +### 2. 数据关系图 + +``` +BASE_USER (员工表) + ↓ F_Id +lq_employee_store_assignment (门店归属表) + ↓ F_StoreId +lq_mdxx (门店表) + ↓ +lq_salary_statistics (工资统计表) - 使用归属表的门店信息 +lq_kd_jksyj (开单业绩表) - 使用归属表的门店信息 +lq_xh_jksyj (消耗业绩表) - 使用归属表的门店信息 +``` + +--- + +## 📐 业务规则设计 + +### 1. 基础规则 + +#### 1.1 时间维度原则 +- **唯一标准**:以数据实际发生的时间(年、月、日)作为门店归属的判断标准 +- **不可变更**:历史归属记录一旦创建,原则上不允许修改(可通过软删除+新增的方式调整) + +#### 1.2 归属记录创建规则 + +**场景A:月初调店(整月归属一个门店)** +- 员工在1月1日从门店A调到门店B +- 创建两条记录: + - 记录1:1月,门店A,开始日期:1月1日,结束日期:1月1日(或空,如果跨月) + - 记录2:1月,门店B,开始日期:1月2日,结束日期:空(或1月31日) + +**场景B:月中调店(一个月归属两个门店)** +- 员工在1月15日从门店A调到门店B +- 创建两条记录: + - 记录1:1月,门店A,开始日期:1月1日,结束日期:1月14日 + - 记录2:1月,门店B,开始日期:1月15日,结束日期:1月31日(或空) + +**场景C:整月归属一个门店** +- 员工整个月都在门店A +- 创建一条记录: + - 记录:1月,门店A,开始日期:1月1日,结束日期:1月31日(或空) + +#### 1.3 归属记录查询规则 + +**查询某员工某月的所有门店归属:** +```sql +SELECT * FROM lq_employee_store_assignment +WHERE F_EmployeeId = @EmployeeId + AND F_StatisticsMonth = @StatisticsMonth + AND F_IsEffective = 1 +ORDER BY F_StartDate +``` + +**查询某员工某天的门店归属:** +```sql +SELECT * FROM lq_employee_store_assignment +WHERE F_EmployeeId = @EmployeeId + AND F_StartDate <= @Date + AND (F_EndDate >= @Date OR F_EndDate IS NULL) + AND F_IsEffective = 1 +ORDER BY F_StartDate DESC +LIMIT 1 +``` + +**查询某门店某月的所有员工:** +```sql +SELECT DISTINCT F_EmployeeId FROM lq_employee_store_assignment +WHERE F_StoreId = @StoreId + AND F_StatisticsMonth = @StatisticsMonth + AND F_IsEffective = 1 +``` + +### 2. 数据维护规则 + +#### 2.1 自动维护机制 + +**触发时机:员工调店时** + +**操作步骤:** +1. 用户在前端操作:员工从门店A调到门店B(选择调店日期) +2. 后端处理逻辑: + - 查询当前员工的有效归属记录(结束日期为空的记录) + - 如果存在,将结束日期设置为调店日期的前一天 + - 创建新的归属记录:开始日期为调店日期,结束日期为空 + - 如果调店日期是月初,更新统计月份;如果是月中,保持原月份,新增记录 + +#### 2.2 手动维护机制 + +**适用场景:** +- 历史数据补录 +- 数据修正 +- 批量导入 + +**操作方式:** +- 提供管理界面:员工门店归属管理 +- 支持批量导入Excel +- 支持手动添加/编辑/删除(软删除) + +#### 2.3 数据校验规则 + +**插入/更新时的校验:** +1. **日期范围校验**: + - 开始日期不能大于结束日期 + - 开始日期和结束日期必须在同一个月内(或跨月但统计月份相同) + +2. **重叠校验**: + - 同一个员工,同一天不能有多条有效记录 + - 如果存在重叠,需要先处理旧记录(结束或删除) + +3. **完整性校验**: + - 员工ID、门店ID必须存在 + - 统计月份格式正确(YYYYMM) + +4. **业务逻辑校验**: + - 门店必须有效(未删除) + - 员工必须有效(未删除、已启用) + +--- + +## 🔄 与现有系统集成 + +### 1. 工资计算集成 + +#### 1.1 健康师工资计算 + +**当前逻辑:** +```csharp +// 1. 优先从业绩数据中获取门店(performanceData中的StoreId) +// 2. 如果没有,从消耗数据中获取门店 +// 3. 如果还没有,使用 BASE_USER.F_Mdid +``` + +**调整后逻辑:** +```csharp +// 1. 查询门店归属快照表(按统计月份) +var storeAssignments = await QueryStoreAssignment(employeeId, statisticsMonth); + +// 2. 如果快照表有记录,使用快照表的门店归属 +if (storeAssignments.Any()) +{ + // 按时间范围拆分计算(如果一个月有多个门店) + foreach (var assignment in storeAssignments) + { + // 查询该员工在该门店、该时间范围内的业绩数据 + // 计算该部分工资 + } +} +// 3. 如果快照表没有记录,使用当前逻辑(回退方案) +else +{ + // 使用现有逻辑:从业务数据或BASE_USER获取 +} +``` + +**特殊处理:月中调店** +- 如果一个月有多个门店归属,需要按时间范围拆分计算 +- 每个时间段的业绩数据单独统计 +- 工资计算结果需要合并(一个员工一个月可能有多条工资记录?还是合并为一条?) + +**建议:合并为一条记录** +- 工资统计表仍然是一个员工一个月一条记录 +- 但需要记录主要门店(归属时间最长的门店,或业绩最多的门店) + +#### 1.2 其他岗位工资计算 + +**店长工资:** +- 店长通常是固定门店,但也要支持调店 +- 计算逻辑与健康师类似 + +**主任工资:** +- 类似逻辑 + +**其他岗位:** +- 统一使用快照表逻辑 + +### 2. 数据补录集成 + +#### 2.1 开单数据补录 + +**当前问题:** +- 补录历史数据时,如果没有门店信息,可能使用当前门店(BASE_USER.F_Mdid) + +**调整后逻辑:** +```csharp +// 补录开单数据时 +public async Task ImportBillingData(BillingRecord record) +{ + var billingDate = record.BillingDate; // 开单日期 + var employeeId = record.EmployeeId; // 健康师ID + var statisticsMonth = $"{billingDate.Year}{billingDate.Month:D2}"; + + // 1. 查询该日期该员工的门店归属 + var assignment = await QueryStoreAssignmentByDate(employeeId, billingDate); + + // 2. 设置门店ID + if (assignment != null) + { + record.StoreId = assignment.StoreId; + } + else + { + // 回退方案:使用当前逻辑 + record.StoreId = GetStoreIdByCurrentLogic(employeeId); + + // 可选:记录警告日志,提示需要维护门店归属 + _logger.LogWarning($"员工{employeeId}在{billingDate}的门店归属未找到,使用当前门店"); + } + + // 3. 保存数据 + await SaveBillingRecord(record); +} +``` + +#### 2.2 消耗数据补录 + +**类似逻辑:** +- 根据消耗日期(Yjsj)查询门店归属 +- 设置消耗记录的StoreId + +### 3. 数据查询集成 + +#### 3.1 员工列表查询 + +**当前逻辑:** +- 直接从BASE_USER表查询,显示F_Mdid对应的门店 + +**调整后逻辑:** +- 如果要显示历史门店,需要关联查询快照表 +- 当前门店:BASE_USER.F_Mdid(实时) +- 历史门店:从快照表查询 + +#### 3.2 门店员工列表查询 + +**场景:查询某门店某月的所有员工** + +**当前逻辑:** +- 查询BASE_USER表,F_Mdid = 门店ID + +**调整后逻辑:** +```sql +-- 查询某门店某月的所有员工 +SELECT DISTINCT esa.F_EmployeeId, u.F_RealName +FROM lq_employee_store_assignment esa +INNER JOIN BASE_USER u ON esa.F_EmployeeId = u.F_Id +WHERE esa.F_StoreId = @StoreId + AND esa.F_StatisticsMonth = @StatisticsMonth + AND esa.F_IsEffective = 1 + AND u.F_DeleteMark IS NULL + AND u.F_EnabledMark = 1 +``` + +--- + +## 📊 数据迁移策略 + +### 1. 历史数据来源 + +#### 1.1 数据来源分析 + +**可能的数据来源:** +1. **工资统计表(lq_salary_statistics等)** + - 已有历史工资记录 + - 工资记录中有门店ID(F_StoreId) + - 可以反向推导:员工+月份 → 门店 + +2. **业务数据(开单/消耗表)** + - 开单业绩表(lq_kd_jksyj)有门店ID(F_StoreId) + - 消耗表(lq_xh_jksyj)有门店ID(F_StoreId) + - 可以统计:员工+月份 → 门店(取出现最多的门店) + +3. **BASE_USER表的变更日志(如果有)** + - 如果系统有记录用户表变更日志,可以追溯 + +4. **人工确认** + - 对于无法确定的数据,需要人工确认 + +#### 1.2 数据迁移优先级 + +**优先级1:工资统计表(最可靠)** +- 理由:工资记录是最终结果,门店归属相对准确 +- 方法:从工资统计表提取:员工ID + 统计月份 + 门店ID +- 时间范围:最近2年的数据(或更长时间,根据业务需要) + +**优先级2:业务数据统计(补充)** +- 理由:对于没有工资记录的月份,从业务数据推断 +- 方法:统计每个员工每个月的业绩数据,取门店出现最多的 +- 注意:可能存在一个月有多个门店的情况(月中调店) + +**优先级3:BASE_USER当前门店(兜底)** +- 理由:对于完全没有数据的员工,使用当前门店 +- 方法:BASE_USER.F_Mdid作为最后兜底 + +### 2. 数据迁移步骤 + +#### 步骤1:数据提取 + +```sql +-- 从工资统计表提取(健康师工资) +INSERT INTO lq_employee_store_assignment +(F_Id, F_EmployeeId, F_StoreId, F_StoreName, F_Year, F_Month, F_StatisticsMonth, F_StartDate, F_EndDate, ...) +SELECT + YitIdHelper.NextId(), + F_EmployeeId, + F_StoreId, + (SELECT F_Dm FROM lq_mdxx WHERE F_Id = F_StoreId), + YEAR(STR_TO_DATE(CONCAT(F_StatisticsMonth, '01'), '%Y%m%d')), + MONTH(STR_TO_DATE(CONCAT(F_StatisticsMonth, '01'), '%Y%m%d')), + F_StatisticsMonth, + STR_TO_DATE(CONCAT(F_StatisticsMonth, '01'), '%Y%m%d'), -- 开始日期:月初 + LAST_DAY(STR_TO_DATE(CONCAT(F_StatisticsMonth, '01'), '%Y%m%d')), -- 结束日期:月末 + NOW(), + NOW(), + 'System', + 'System', + 1 +FROM lq_salary_statistics +WHERE F_StoreId IS NOT NULL AND F_StoreId != '' +GROUP BY F_EmployeeId, F_StatisticsMonth, F_StoreId +``` + +#### 步骤2:数据清洗 + +**处理重复记录:** +- 如果一个员工一个月有多条记录(可能是数据异常,也可能是月中调店) +- 需要人工确认:是数据异常还是确实调店 + +**处理缺失数据:** +- 对于没有工资记录但可能有业务数据的员工 +- 从业务数据统计推断 + +#### 步骤3:数据验证 + +**验证规则:** +1. 每个员工每个月至少有一条记录(如果有业务数据) +2. 门店ID必须存在 +3. 统计月份格式正确 +4. 日期范围合理 + +**验证方法:** +- 抽样检查:随机抽取一定比例的数据,人工验证 +- 逻辑检查:检查是否有明显异常(如同一员工同一天多个门店) + +#### 步骤4:数据导入 + +**导入方式:** +- 批量导入(使用事务保证一致性) +- 分批次导入(避免锁表时间过长) +- 记录导入日志(成功/失败记录) + +### 3. 数据迁移时间窗口 + +**建议:** +- 选择业务低峰期(如夜间或周末) +- 分批次迁移(先迁移最近3个月,验证无误后再迁移更多) +- 保留回滚方案(如果迁移失败,可以删除导入的数据) + +--- + +## 🎯 边界情况处理 + +### 1. 月中调店 + +**场景:** 员工在1月15日从门店A调到门店B + +**处理方案:** +- 创建两条归属记录 +- 工资计算时,按时间范围拆分计算 +- 最终工资记录合并为一条(记录主要门店) + +**问题:** +- 工资统计表中的门店ID如何设置? + - 方案A:设置为主要门店(归属时间最长的门店,或业绩最多的门店) + - 方案B:设置为空,或记录为"多门店" + - **建议:方案A**,记录为主要门店,在备注或明细中说明 + +### 2. 数据缺失 + +**场景:** 快照表中没有某员工某月的归属记录 + +**处理方案:** +- 回退到现有逻辑(从业务数据或BASE_USER获取) +- 记录警告日志,提示需要维护门店归属 +- 可选:自动创建一条归属记录(使用当前门店或推断的门店) + +### 3. 数据冲突 + +**场景:** 同一个员工同一天有多条归属记录(数据异常) + +**处理方案:** +- 数据校验时发现冲突,不允许插入/更新 +- 提示用户先处理冲突记录 +- 提供数据修正工具 + +### 4. 跨月调店 + +**场景:** 员工在1月31日调到门店B,实际在2月1日生效 + +**处理方案:** +- 记录1月:门店A,结束日期:1月31日 +- 记录2月:门店B,开始日期:2月1日 +- 如果调店日期是月末最后一天,可以考虑归属到下个月 + +### 5. 离职员工 + +**场景:** 员工在月中离职 + +**处理方案:** +- 创建归属记录:结束日期设置为离职日期 +- 如果离职日期是月中,归属记录只到离职日期 +- 工资计算时,只计算到离职日期 + +### 6. 新员工入职 + +**场景:** 新员工在月中入职 + +**处理方案:** +- 创建归属记录:开始日期为入职日期 +- 如果入职日期是月中,从入职日期开始归属 + +--- + +## ⚡ 性能考虑 + +### 1. 查询性能优化 + +#### 1.1 索引优化 +- 已设计多个索引(见索引设计部分) +- 定期分析索引使用情况,优化索引 + +#### 1.2 查询优化 + +**批量查询:** +- 工资计算时,批量查询所有员工的归属记录,避免N+1查询 +```csharp +// 批量查询 +var employeeIds = salaryList.Select(s => s.EmployeeId).Distinct().ToList(); +var assignments = await _db.Queryable() + .Where(x => employeeIds.Contains(x.EmployeeId) && x.StatisticsMonth == monthStr) + .ToListAsync(); +var assignmentDict = assignments.GroupBy(x => x.EmployeeId).ToDictionary(g => g.Key, g => g.ToList()); +``` + +**缓存策略:** +- 对于频繁查询的数据(如当前月的归属记录),可以使用缓存 +- 缓存失效策略:归属记录更新时,清除相关缓存 + +### 2. 写入性能优化 + +#### 2.1 批量插入 +- 数据迁移时,使用批量插入(如每次插入1000条) +- 使用事务保证一致性 + +#### 2.2 异步处理 +- 数据迁移可以异步处理 +- 员工调店时,归属记录的创建可以异步处理(如果对实时性要求不高) + +### 3. 表分区考虑 + +**是否需要分区?** +- 如果数据量很大(如10年历史数据,每月几千员工),可以考虑按年份或月份分区 +- 当前阶段可能不需要,但设计时要考虑扩展性 + +--- + +## 🔒 数据一致性保证 + +### 1. 事务处理 + +**关键操作使用事务:** +- 员工调店时:更新旧记录 + 创建新记录(在一个事务中) +- 数据迁移:批量导入使用事务 + +### 2. 数据校验 + +**应用层校验:** +- 插入/更新前校验数据完整性和业务逻辑 +- 防止数据冲突 + +**数据库层约束:** +- 外键约束(如果MySQL支持,但通常不使用外键,使用应用层保证) +- 非空约束、唯一约束(根据业务需要) + +### 3. 数据同步 + +**与BASE_USER表同步:** +- 员工调店时,同时更新BASE_USER.F_Mdid和归属表 +- 或者:归属表作为数据源,BASE_USER.F_Mdid从归属表计算(当前月份的主要门店) + +**建议:** +- BASE_USER.F_Mdid保持为当前门店(实时) +- 归属表记录历史归属 +- 两者可以不一致(BASE_USER是当前状态,归属表是历史记录) + +--- + +## 👤 用户体验设计 + +### 1. 管理界面 + +#### 1.1 员工门店归属管理页面 + +**功能:** +- 列表展示:员工、门店、月份、时间范围 +- 查询:按员工、门店、月份查询 +- 添加:手动添加归属记录 +- 编辑:修改归属记录(需要校验) +- 删除:软删除归属记录 +- 批量导入:Excel导入 + +#### 1.2 员工调店操作 + +**操作流程:** +1. 选择员工 +2. 选择目标门店 +3. 选择调店日期(默认今天) +4. 系统自动: + - 查询当前有效归属记录 + - 更新旧记录结束日期 + - 创建新记录开始日期 +5. 确认保存 + +### 2. 数据展示 + +#### 2.1 员工详情页 + +**显示:** +- 当前门店:BASE_USER.F_Mdid(实时) +- 历史归属:从归属表查询,列表展示 + +#### 2.2 工资计算页面 + +**显示:** +- 如果使用了快照表的门店,显示来源标识 +- 如果使用了回退逻辑,显示警告提示 + +--- + +## 🔮 扩展性考虑 + +### 1. 未来可能的业务变化 + +#### 1.1 多门店归属 +**场景:** 一个员工可能同时归属多个门店(兼职) + +**扩展方案:** +- 当前设计已经支持(一个员工一个月可以有多条记录) +- 需要调整业务逻辑:工资计算时,如何分配业绩到不同门店 + +#### 1.2 部门归属 +**场景:** 不仅记录门店归属,还要记录部门归属 + +**扩展方案:** +- 可以增加字段:F_DepartmentId(部门ID) +- 或者创建新的表:员工部门归属表 + +#### 1.3 岗位变更 +**场景:** 记录员工岗位变更历史 + +**扩展方案:** +- 类似设计:员工岗位归属表 +- 或者:在归属表中增加字段:F_Position(岗位) + +### 2. 性能扩展 + +#### 2.1 数据归档 +- 历史数据(如3年前的数据)可以归档到历史表 +- 当前表只保留最近2-3年的数据 + +#### 2.2 读写分离 +- 如果查询压力大,可以考虑读写分离 +- 归属表主要是查询操作,可以放到只读库 + +--- + +## ⚠️ 风险点与应对措施 + +### 1. 数据准确性风险 + +**风险:** 历史数据迁移可能不准确 + +**应对:** +- 多数据源验证(工资表、业务数据、人工确认) +- 分批次迁移,每批验证 +- 提供数据修正工具 + +### 2. 性能风险 + +**风险:** 查询性能可能下降 + +**应对:** +- 合理设计索引 +- 使用缓存 +- 监控查询性能,及时优化 + +### 3. 数据一致性风险 + +**风险:** 归属表与BASE_USER表可能不一致 + +**应对:** +- 明确两者的作用:BASE_USER是当前状态,归属表是历史记录 +- 提供数据同步工具(可选) +- 在关键操作时校验一致性 + +### 4. 业务复杂度风险 + +**风险:** 月中调店等复杂场景增加业务复杂度 + +**应对:** +- 提供清晰的业务规则文档 +- 在管理界面提供操作指引 +- 提供数据校验和提示 + +--- + +## 📋 实施 Checklist + +### 阶段一:数据准备 +- [ ] 创建归属表结构 +- [ ] 设计索引 +- [ ] 编写数据迁移脚本 +- [ ] 准备测试数据 + +### 阶段二:功能开发 +- [ ] 开发归属记录管理功能(CRUD) +- [ ] 开发员工调店功能(自动创建归属记录) +- [ ] 调整工资计算逻辑(集成快照表) +- [ ] 调整数据补录逻辑(集成快照表) +- [ ] 开发数据校验逻辑 + +### 阶段三:数据迁移 +- [ ] 备份现有数据 +- [ ] 执行数据迁移(小批量测试) +- [ ] 验证数据准确性 +- [ ] 全量数据迁移 +- [ ] 数据验证和修正 + +### 阶段四:测试验证 +- [ ] 单元测试 +- [ ] 集成测试 +- [ ] 性能测试 +- [ ] 用户验收测试 + +### 阶段五:上线部署 +- [ ] 部署到测试环境 +- [ ] 测试环境验证 +- [ ] 部署到生产环境 +- [ ] 监控运行情况 +- [ ] 数据修正和支持 + +--- + +## 🔍 参考现有系统设计 + +### 1. 现有归属表分析 + +系统中已经存在类似的归属表设计,可以参考其设计思路: + +#### 1.1 大项目部老师归属表(lq_md_major_project_teacher_assignment) + +**设计特点:** +- 只记录月份级别(Year + Month),不记录日期范围 +- 唯一约束:门店 + 年份 + 月份 + 老师ID +- 一个老师一个月只能有一条记录(不支持月中调店) + +**适用场景:** +- 大项目部老师通常是整月归属一个门店 +- 如果需要月中调店,需要拆分月份处理 + +#### 1.2 总经理门店归属表(lq_md_general_manager_lifeline) + +**设计特点:** +- 记录月份级别(Month,YYYYMM格式) +- 唯一约束:门店 + 月份 + 总经理ID +- 一个总经理一个月在一个门店只能有一条记录 +- 但一个总经理可以管理多个门店(通过多条记录实现) + +**适用场景:** +- 总经理/经理通常管理多个门店 +- 整月归属,不涉及月中调店 + +### 2. 设计对比与选择 + +#### 方案A:按月设计(参考现有归属表) + +**特点:** +- 只记录月份级别(Year + Month) +- 一个员工一个月只能有一条记录 +- 不支持月中调店(如果需要调店,需要拆分月份) + +**优点:** +- 设计简单,与现有归属表一致 +- 查询逻辑简单 +- 性能好(数据量少) + +**缺点:** +- 不支持月中调店场景 +- 如果员工在月中调店,需要手动拆分月份 + +#### 方案B:按日期范围设计(推荐方案) + +**特点:** +- 记录日期范围(StartDate + EndDate) +- 一个员工一个月可以有多条记录 +- 支持月中调店 + +**优点:** +- 支持月中调店等复杂场景 +- 时间维度精确 +- 更灵活 + +**缺点:** +- 设计相对复杂 +- 查询逻辑需要处理日期范围 +- 数据量可能更多 + +### 3. 推荐方案 + +**建议采用方案B(按日期范围设计)**,理由: + +1. **业务需求**:健康师等岗位确实存在月中调店的场景 +2. **数据准确性**:日期范围设计更精确,能准确反映实际归属情况 +3. **扩展性**:未来如果有更细粒度的需求,日期范围设计更容易扩展 +4. **兼容性**:可以与现有按月设计的归属表共存,互不影响 + +**但是要注意:** +- 如果某些岗位(如大项目部老师、总经理)不需要支持月中调店,可以继续使用现有的按月设计 +- 门店归属快照表主要用于健康师、店助、店长等需要精确时间范围的岗位 + +--- + +## 💭 额外思考点 + +### 1. 数据一致性策略 + +#### 1.1 BASE_USER.F_Mdid 与归属表的关系 + +**问题:** BASE_USER.F_Mdid 应该如何处理? + +**方案A:保持独立** +- BASE_USER.F_Mdid 保持为当前门店(实时状态) +- 归属表记录历史归属 +- 两者可以不一致(BASE_USER是当前状态,归属表是历史记录) + +**方案B:同步更新** +- 员工调店时,同时更新 BASE_USER.F_Mdid 和归属表 +- 保持一致 + +**方案C:BASE_USER.F_Mdid 从归属表计算** +- BASE_USER.F_Mdid 不再手动维护 +- 从归属表计算当前月份的主要门店 +- 查询时实时计算或定期同步 + +**推荐:方案A** +- 理由:BASE_USER.F_Mdid 是实时状态,归属表是历史记录,用途不同 +- BASE_USER.F_Mdid 用于当前业务查询(如:查询当前门店的员工) +- 归属表用于历史数据查询(如:计算历史月份工资) +- 两者可以不一致,但需要明确各自的作用 + +#### 1.2 数据同步策略 + +**如果采用方案A,需要考虑:** +- 员工调店时,BASE_USER.F_Mdid 和归属表的更新是否需要在同一个事务中? +- 建议:在同一个事务中更新,保证一致性 + +### 2. 数据维护的便利性 + +#### 2.1 自动维护 vs 手动维护 + +**自动维护:** +- 员工调店时,自动创建归属记录 +- 优点:减少人工操作,数据准确 +- 缺点:如果调店操作本身有问题,归属记录也会有问题 + +**手动维护:** +- 需要手动创建/编辑归属记录 +- 优点:可控性强 +- 缺点:增加操作复杂度,可能遗漏 + +**推荐:混合模式** +- 员工调店时,自动创建归属记录(默认) +- 提供手动维护界面,可以修改/补录 +- 支持批量导入历史数据 + +#### 2.2 数据修正机制 + +**场景:发现历史归属数据有误** + +**处理方案:** +1. **不允许修改历史数据**(严格模式) + - 如果需要修正,只能删除(软删除)+ 新增 + - 优点:保持数据可追溯性 + - 缺点:操作复杂 + +2. **允许修改历史数据**(灵活模式) + - 可以直接修改历史记录 + - 优点:操作简单 + - 缺点:无法追溯修改历史 + +**推荐:方案1(严格模式)** +- 历史数据一旦创建,不允许直接修改 +- 需要修正时,软删除旧记录,创建新记录 +- 记录修正原因和操作人 + +### 3. 查询性能优化 + +#### 3.1 批量查询优化 + +**场景:计算某月所有员工的工资** + +**优化策略:** +```csharp +// 一次性查询所有员工的归属记录 +var employeeIds = allEmployees.Select(e => e.Id).ToList(); +var assignments = await _db.Queryable() + .Where(x => employeeIds.Contains(x.EmployeeId) && x.StatisticsMonth == monthStr) + .ToListAsync(); + +// 转换为字典,便于快速查找 +var assignmentDict = assignments + .GroupBy(x => x.EmployeeId) + .ToDictionary(g => g.Key, g => g.OrderBy(x => x.StartDate).ToList()); +``` + +#### 3.2 缓存策略 + +**缓存哪些数据?** +- 当前月的归属记录(变化较少) +- 员工当前门店(BASE_USER.F_Mdid) + +**缓存失效策略:** +- 归属记录更新时,清除相关缓存 +- 定时刷新(如每天凌晨) + +#### 3.3 数据分区考虑 + +**是否需要分区?** +- 如果数据量很大(如10年历史,每月几千员工),可以考虑按年份分区 +- 当前阶段可能不需要,但设计时要考虑扩展性 + +### 4. 异常情况处理 + +#### 4.1 数据冲突检测 + +**场景:同一个员工同一天有多条归属记录** + +**检测时机:** +- 插入/更新归属记录时 +- 数据迁移时 + +**处理方案:** +- 应用层校验:插入前检查是否有日期重叠 +- 如果发现冲突,拒绝操作,提示用户处理 + +**校验SQL示例:** +```sql +-- 检查是否存在日期重叠 +SELECT COUNT(*) +FROM lq_employee_store_assignment +WHERE F_EmployeeId = @EmployeeId + AND F_IsEffective = 1 + AND ( + (F_StartDate <= @EndDate AND F_EndDate >= @StartDate) + OR (F_StartDate IS NULL AND F_EndDate IS NULL) + ) +``` + +#### 4.2 数据缺失处理 + +**场景:快照表中没有某员工某月的归属记录** + +**处理方案:** +1. **回退到现有逻辑**(推荐) + - 从业务数据或BASE_USER获取 + - 记录警告日志 + - 可选:自动创建一条归属记录(使用当前门店或推断的门店) + +2. **报错处理** + - 如果快照表没有记录,直接报错 + - 要求用户先维护归属记录 + +**推荐:方案1** +- 保持系统的容错性 +- 记录警告,便于后续补录数据 + +### 5. 与现有系统的兼容性 + +#### 5.1 渐进式迁移 + +**迁移策略:** +1. **第一阶段:新建表,不影响现有逻辑** + - 创建归属表 + - 数据迁移 + - 但工资计算仍使用现有逻辑 + +2. **第二阶段:双写模式** + - 工资计算时,同时使用新逻辑和旧逻辑 + - 对比结果,验证新逻辑的正确性 + +3. **第三阶段:切换到新逻辑** + - 新逻辑验证无误后,切换到新逻辑 + - 保留旧逻辑作为回退方案 + +4. **第四阶段:移除旧逻辑** + - 新逻辑稳定运行一段时间后,移除旧逻辑 + +#### 5.2 数据迁移的准确性 + +**迁移数据来源优先级:** +1. 工资统计表(最可靠) +2. 业务数据统计(补充) +3. BASE_USER当前门店(兜底) + +**迁移后的验证:** +- 抽样检查:随机抽取一定比例的数据,人工验证 +- 逻辑检查:检查是否有明显异常 +- 对比验证:对比迁移前后的工资计算结果 + +### 6. 未来扩展性 + +#### 6.1 多门店归属 + +**场景:一个员工同时归属多个门店(兼职)** + +**扩展方案:** +- 当前设计已经支持(一个员工一个月可以有多条记录) +- 需要调整工资计算逻辑:如何分配业绩到不同门店 +- 可能需要增加字段:业绩分配比例 + +#### 6.2 部门归属 + +**场景:不仅记录门店归属,还要记录部门归属** + +**扩展方案:** +- 可以增加字段:F_DepartmentId(部门ID) +- 或者创建新表:员工部门归属表 +- 或者:在归属表中增加字段:F_DepartmentId(如果需要同时记录门店和部门) + +#### 6.3 岗位变更历史 + +**场景:记录员工岗位变更历史** + +**扩展方案:** +- 可以创建新表:员工岗位归属表(类似设计) +- 或者:在归属表中增加字段:F_Position(岗位) + +#### 6.4 其他业务属性 + +**场景:需要记录其他业务属性(如:职级、薪资等级等)** + +**扩展方案:** +- 可以在归属表中增加字段 +- 或者创建关联表 + +**设计原则:** +- 优先考虑在归属表中增加字段(如果属性是时间相关的) +- 如果属性与门店归属无关,考虑创建新表 + +--- + +## 📝 总结 + +门店归属快照表方案是一个相对完善的解决方案,能够: + +1. **解决核心问题**:以时间维度为唯一标准,准确记录历史门店归属 +2. **支持复杂场景**:月中调店、跨月调店等 +3. **保证数据完整性**:通过多数据源验证和数据校验 +4. **考虑性能**:合理设计索引和查询优化 +5. **易于扩展**:设计时考虑了未来的业务变化 + +**关键成功因素:** +- 数据迁移的准确性 +- 业务规则的清晰性 +- 用户操作的简便性 +- 系统性能的稳定性 + +--- + +**文档版本:** v1.0 +**创建日期:** 2026-01-09 +**文档性质:** 详细设计方案(仅思考,不修改代码) diff --git a/netcore/src/Modularity/Extend/NCC.Extend/LqAssistantSalaryService.cs b/netcore/src/Modularity/Extend/NCC.Extend/LqAssistantSalaryService.cs index 86bd13d..3578b5f 100644 --- a/netcore/src/Modularity/Extend/NCC.Extend/LqAssistantSalaryService.cs +++ b/netcore/src/Modularity/Extend/NCC.Extend/LqAssistantSalaryService.cs @@ -1,10 +1,13 @@ using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; using NCC.Common.Filter; using NCC.Common.Helper; using NCC.Dependency; using NCC.DynamicApiController; using NCC.Extend.Entitys.Dto.LqAssistantSalary; +using NCC.Extend.Entitys.Dto.LqSalary; using NCC.Extend.Entitys.lq_assistant_salary_statistics; using NCC.Extend.Entitys.lq_attendance_summary; using NCC.Extend.Entitys.lq_hytk_hytk; @@ -14,10 +17,13 @@ using NCC.Extend.Entitys.lq_md_xdbhsj; using NCC.Extend.Entitys.lq_mdxx; using NCC.Extend.Entitys.lq_xh_hyhk; using NCC.Extend.Entitys.lq_xh_jksyj; +using NCC.FriendlyException; using NCC.System.Entitys.Permission; using SqlSugar; using System; using System.Collections.Generic; +using System.Data; +using System.IO; using System.Linq; using System.Threading.Tasks; using Yitter.IdGenerator; @@ -32,13 +38,15 @@ namespace NCC.Extend public class LqAssistantSalaryService : IDynamicApiController, ITransient { private readonly ISqlSugarClient _db; + private readonly ILogger _logger; /// /// 初始化一个类型的新实例 /// - public LqAssistantSalaryService(ISqlSugarClient db) + public LqAssistantSalaryService(ISqlSugarClient db, ILogger logger) { _db = db; + _logger = logger; } /// @@ -51,17 +59,7 @@ namespace NCC.Extend { var monthStr = $"{input.Year}{input.Month:D2}"; - // 1. 检查当月是否已生成工资数据 - var exists = await _db.Queryable() - .AnyAsync(x => x.StatisticsMonth == monthStr); - - // 2. 如果没有数据,则进行计算 - if (!exists) - { - await CalculateAssistantSalary(input.Year, input.Month); - } - - // 3. 查询数据 + // 查询数据 var query = _db.Queryable() .Where(x => x.StatisticsMonth == monthStr); @@ -125,6 +123,69 @@ namespace NCC.Extend } /// + /// 通过月份和员工ID查询工资 + /// + [HttpGet("query-by-employee")] + public async Task GetSalaryByEmployee([FromQuery] SalaryQueryByEmployeeInput input) + { + if (input.Year <= 0 || input.Month <= 0 || input.Month > 12) + throw NCCException.Oh("年份和月份参数不正确"); + if (string.IsNullOrWhiteSpace(input.EmployeeId)) + throw NCCException.Oh("员工ID不能为空"); + var monthStr = $"{input.Year}{input.Month:D2}"; + var salary = await _db.Queryable() + .Where(x => x.StatisticsMonth == monthStr && x.EmployeeId == input.EmployeeId) + .Select(x => new AssistantSalaryOutput + { + Id = x.Id, + StoreName = x.StoreName, + EmployeeName = x.EmployeeName, + Position = x.Position, + StoreTotalPerformance = x.StoreTotalPerformance, + StoreBillingPerformance = x.StoreBillingPerformance, + StoreRefundPerformance = x.StoreRefundPerformance, + StoreLifeline = x.StoreLifeline, + PerformanceCompletionRate = x.PerformanceCompletionRate, + CommissionRate = x.CommissionRate, + CommissionAmount = x.CommissionAmount, + HeadCount = x.HeadCount, + Stage1TargetHeadCount = x.Stage1TargetHeadCount, + Stage2TargetHeadCount = x.Stage2TargetHeadCount, + ReachedStage1 = x.ReachedStage1, + ReachedStage2 = x.ReachedStage2, + StageRewardAmount = x.StageRewardAmount, + Stage1Reward = x.Stage1Reward, + Stage2Reward = x.Stage2Reward, + BaseSalary = x.BaseSalary, + PhoneManagementFee = x.PhoneManagementFee, + WorkingDays = x.WorkingDays, + LeaveDays = x.LeaveDays, + GrossSalary = x.GrossSalary, + ActualSalary = x.ActualSalary, + TotalDeduction = x.TotalDeduction, + TotalSubsidy = x.TotalSubsidy, + Bonus = x.Bonus, + ReturnPhoneDeposit = x.ReturnPhoneDeposit, + ReturnAccommodationDeposit = x.ReturnAccommodationDeposit, + MonthlyPaymentStatus = x.MonthlyPaymentStatus, + PaidAmount = x.PaidAmount, + PendingAmount = x.PendingAmount, + LastMonthSupplement = x.LastMonthSupplement, + MonthlyTotalPayment = x.MonthlyTotalPayment, + IsLocked = x.IsLocked, + UpdateTime = x.UpdateTime, + StoreType = x.StoreType, + StoreCategory = x.StoreCategory, + IsNewStore = x.IsNewStore, + NewStoreProtectionStage = x.NewStoreProtectionStage + }) + .FirstAsync(); + if (salary == null) + throw NCCException.Oh($"未找到员工{input.EmployeeId}在{input.Year}年{input.Month}月的工资记录"); + return salary; + } + + /// /// 计算店助工资 /// /// 年份 @@ -356,7 +417,7 @@ namespace NCC.Extend // - 100%以上部分:0.6% totalCommission = CalculateAssistantCommission(salary.StoreTotalPerformance, salary.StoreLifeline); } - + // 计算平均提成比例(用于显示) if (salary.StoreTotalPerformance > 0) { @@ -465,7 +526,7 @@ namespace NCC.Extend { storeTotalCommission = CalculateAssistantCommission(salary.StoreTotalPerformance, salary.StoreLifeline); } - + // 按比例计算提成:门店总提成 / 当月天数 × 在店天数 salary.CommissionAmount = storeTotalCommission / daysInMonth * workingDays; @@ -540,12 +601,71 @@ namespace NCC.Extend // 3. 保存数据 if (assistantSalaryList.Any()) { - // 先删除当月旧数据 (防止重复) - await _db.Deleteable() + // 查询当月已存在的记录(用于检查是否已锁定或已确认) + var existingRecords = await _db.Queryable() .Where(x => x.StatisticsMonth == monthStr) - .ExecuteCommandAsync(); + .ToListAsync(); + + var existingDict = existingRecords + .Where(x => !string.IsNullOrEmpty(x.EmployeeId)) + .GroupBy(x => x.EmployeeId) + .ToDictionary(g => g.Key, g => g.First()); + + // 分离需要插入的新记录和需要更新的记录,以及需要跳过的记录 + var recordsToInsert = new List(); + var recordsToUpdate = new List(); + var skippedCount = 0; - await _db.Insertable(assistantSalaryList).ExecuteCommandAsync(); + foreach (var salary in assistantSalaryList) + { + if (existingDict.ContainsKey(salary.EmployeeId)) + { + var existing = existingDict[salary.EmployeeId]; + // 如果已锁定或已确认,则跳过,不更新 + if (existing.IsLocked == 1 || existing.EmployeeConfirmStatus == 1) + { + skippedCount++; + continue; // 跳过,不更新 + } + + // 更新现有记录(保留确认状态相关字段) + salary.Id = existing.Id; + salary.EmployeeConfirmStatus = existing.EmployeeConfirmStatus; + salary.EmployeeConfirmTime = existing.EmployeeConfirmTime; + salary.EmployeeConfirmRemark = existing.EmployeeConfirmRemark; + salary.IsLocked = existing.IsLocked; // 保留锁定状态 + salary.CreateTime = existing.CreateTime; + salary.CreateUser = existing.CreateUser; + recordsToUpdate.Add(salary); + } + else + { + // 新记录 + salary.Id = YitIdHelper.NextId().ToString(); + salary.EmployeeConfirmStatus = 0; + salary.IsLocked = 0; + salary.CreateTime = DateTime.Now; + salary.CreateUser = ""; // 新记录,创建人为空或系统 + recordsToInsert.Add(salary); + } + } + + // 批量插入新记录 + if (recordsToInsert.Any()) + { + await _db.Insertable(recordsToInsert).ExecuteCommandAsync(); + } + + // 批量更新现有记录 + if (recordsToUpdate.Any()) + { + await _db.Updateable(recordsToUpdate).ExecuteCommandAsync(); + } + + if (skippedCount > 0) + { + _logger.LogWarning($"计算工资时跳过了 {skippedCount} 条已锁定或已确认的记录(月份:{monthStr})"); + } } } @@ -666,6 +786,487 @@ namespace NCC.Extend }; } + #region 员工工资确认 + + /// + /// 员工确认工资条 + /// + /// + /// 员工确认自己的工资条,确认后工资数据不可再修改 + /// + /// 示例请求: + /// + /// { + /// "id": "工资记录ID", + /// "employeeId": "员工ID", + /// "remark": "确认备注(可选)" + /// } + /// + /// + /// 参数说明: + /// - id: 工资记录ID(必填) + /// - employeeId: 员工ID(必填) + /// - remark: 确认备注(可选) + /// + /// 注意事项: + /// - 只能确认自己的工资条 + /// - 只能确认已锁定的工资条(IsLocked = 1) + /// - 已确认的工资条不能重复确认 + /// + /// 确认参数 + /// 操作结果 + /// 确认成功 + /// 参数错误或记录不存在 + [HttpPost("confirm")] + public async Task ConfirmSalary([FromBody] SalaryConfirmInput input) + { + try + { + if (string.IsNullOrWhiteSpace(input.Id)) + { + throw NCCException.Oh("工资记录ID不能为空"); + } + + if (string.IsNullOrWhiteSpace(input.EmployeeId)) + { + throw NCCException.Oh("员工ID不能为空"); + } + + var salary = await _db.Queryable() + .Where(s => s.Id == input.Id && s.EmployeeId == input.EmployeeId) + .FirstAsync(); + + if (salary == null) + { + throw NCCException.Oh("工资记录不存在或不属于该员工"); + } + + if (salary.EmployeeConfirmStatus == 1) + { + throw NCCException.Oh("该工资条已确认,不能重复确认"); + } + + if (salary.IsLocked != 1) + { + throw NCCException.Oh("该工资条尚未锁定,请等待管理员锁定后再确认"); + } + + salary.EmployeeConfirmStatus = 1; + salary.EmployeeConfirmTime = DateTime.Now; + salary.EmployeeConfirmRemark = input.Remark; + salary.UpdateTime = DateTime.Now; + + await _db.Updateable(salary).ExecuteCommandAsync(); + + return "确认成功"; + } + catch (Exception ex) + { + throw NCCException.Oh($"确认工资条失败: {ex.Message}"); + } + } + + #endregion + + #region 工资锁定/解锁 + + /// + /// 批量锁定/解锁工资条 + /// + [HttpPost("lock")] + public async Task LockSalary([FromBody] SalaryLockInput input) + { + try + { + if (input == null || input.Ids == null || !input.Ids.Any()) + throw NCCException.Oh("工资记录ID列表不能为空"); + var salaries = await _db.Queryable() + .Where(s => input.Ids.Contains(s.Id)) + .ToListAsync(); + if (!salaries.Any()) + throw NCCException.Oh("未找到指定的工资记录"); + var lockedCount = 0; + var unlockedCount = 0; + var skippedCount = 0; + foreach (var salary in salaries) + { + if (salary.EmployeeConfirmStatus == 1 && !input.IsLocked) + { + skippedCount++; + continue; + } + salary.IsLocked = input.IsLocked ? 1 : 0; + salary.UpdateTime = DateTime.Now; + if (input.IsLocked) lockedCount++; else unlockedCount++; + } + await _db.Updateable(salaries).ExecuteCommandAsync(); + var action = input.IsLocked ? "锁定" : "解锁"; + var count = input.IsLocked ? lockedCount : unlockedCount; + var message = $"{action}成功:{count}条"; + if (skippedCount > 0) + message += $",跳过{skippedCount}条(已确认的记录不能解锁)"; + return message; + } + catch (Exception ex) + { + throw NCCException.Oh($"锁定/解锁工资条失败: {ex.Message}"); + } + } + + /// + /// 批量锁定当月所有工资 + /// + /// 批量锁定输入参数 + /// 锁定结果 + [HttpPost("lock-by-month")] + public async Task LockSalaryByMonth([FromBody] SalaryLockByMonthInput input) + { + try + { + if (input == null) + throw NCCException.Oh("参数不能为空"); + + if (input.Year <= 0 || input.Month <= 0 || input.Month > 12) + throw NCCException.Oh("年份和月份参数不正确"); + + var monthStr = $"{input.Year}{input.Month:D2}"; + + var salaries = await _db.Queryable() + .Where(s => s.StatisticsMonth == monthStr) + .ToListAsync(); + + if (!salaries.Any()) + throw NCCException.Oh($"未找到{input.Year}年{input.Month}月的工资记录"); + + var lockedCount = 0; + var unlockedCount = 0; + var skippedCount = 0; + var alreadyLockedCount = 0; + + foreach (var salary in salaries) + { + if (salary.EmployeeConfirmStatus == 1 && !input.IsLocked) + { + skippedCount++; + continue; + } + + if (salary.IsLocked == 1 && input.IsLocked) + { + alreadyLockedCount++; + continue; + } + + if (salary.IsLocked == 0 && !input.IsLocked) + { + alreadyLockedCount++; + continue; + } + + salary.IsLocked = input.IsLocked ? 1 : 0; + salary.UpdateTime = DateTime.Now; + + if (input.IsLocked) + lockedCount++; + else + unlockedCount++; + } + + if (lockedCount > 0 || unlockedCount > 0) + { + var salariesToUpdate = salaries.Where(s => + (input.IsLocked && s.IsLocked == 0) || + (!input.IsLocked && s.IsLocked == 1 && s.EmployeeConfirmStatus != 1) + ).ToList(); + + if (salariesToUpdate.Any()) + { + await _db.Updateable(salariesToUpdate) + .UpdateColumns(s => new { s.IsLocked, s.UpdateTime }) + .ExecuteCommandAsync(); + } + } + + var action = input.IsLocked ? "锁定" : "解锁"; + var count = input.IsLocked ? lockedCount : unlockedCount; + var message = $"{action}成功:{count}条"; + + if (alreadyLockedCount > 0) + message += $",跳过{alreadyLockedCount}条(已是{action}状态)"; + + if (skippedCount > 0) + message += $",跳过{skippedCount}条(已确认的记录不能解锁)"; + + return new + { + success = true, + message = message, + total = salaries.Count, + locked = lockedCount, + unlocked = unlockedCount, + skipped = skippedCount, + alreadyLocked = alreadyLockedCount + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "批量锁定当月工资失败"); + var action = input?.IsLocked == true ? "锁定" : "解锁"; + throw NCCException.Oh($"批量{action}当月工资失败: {ex.Message}"); + } + } + + #endregion + + #region 导入工资 + + /// + /// 从Excel导入店助工资数据 + /// + /// Excel文件 + /// 导入结果 + [HttpPost("import")] + public async Task ImportSalaryFromExcel(IFormFile file) + { + try + { + if (file == null || file.Length == 0) + throw NCCException.Oh("请选择要上传的Excel文件"); + + var allowedExtensions = new[] { ".xlsx", ".xls" }; + var fileExtension = Path.GetExtension(file.FileName).ToLowerInvariant(); + if (!allowedExtensions.Contains(fileExtension)) + throw NCCException.Oh("只支持.xlsx和.xls格式的Excel文件"); + + var recordsToInsert = new List(); + var recordsToUpdate = new List(); + var errorMessages = new List(); + var successCount = 0; + var failCount = 0; + var skippedCount = 0; + + var tempFilePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString() + Path.GetExtension(file.FileName)); + try + { + using (var stream = new FileStream(tempFilePath, FileMode.Create)) + { + await file.CopyToAsync(stream); + } + + var dataTable = ExcelImportHelper.ToDataTable(tempFilePath, 0, 0); + if (dataTable.Rows.Count == 0) + throw NCCException.Oh("Excel文件中没有数据行"); + + Func ParseDecimal = (str) => + { + if (string.IsNullOrWhiteSpace(str)) return 0; + var cleaned = str.Trim().Replace(",", "").Replace(",", "").Replace("¥", "").Replace("$", "").Replace("元", "").Replace("%", "").Replace(" ", ""); + return decimal.TryParse(cleaned, out decimal result) ? result : 0; + }; + + Func ParseInt = (str) => + { + if (string.IsNullOrWhiteSpace(str)) return 0; + var cleaned = str.Trim().Replace(",", "").Replace(",", "").Replace(" ", ""); + return int.TryParse(cleaned, out int result) ? result : 0; + }; + + for (int i = 1; i < dataTable.Rows.Count; i++) + { + try + { + var row = dataTable.Rows[i]; + Func GetColumnValue = (colIndex) => colIndex < row.ItemArray.Length && row[colIndex] != null ? row[colIndex].ToString().Trim() : ""; + + var firstColumnValue = GetColumnValue(0); + bool isOldFormat = !string.IsNullOrWhiteSpace(firstColumnValue) && (firstColumnValue == "门店名称" || (!long.TryParse(firstColumnValue, out _) && firstColumnValue.Length > 20)); + + int storeNameIndex = isOldFormat ? 0 : 1; + int employeeNameIndex = isOldFormat ? 1 : 2; + int offset = isOldFormat ? 0 : 1; + + var id = isOldFormat ? "" : GetColumnValue(0); + var employeeName = GetColumnValue(employeeNameIndex); + var storeName = GetColumnValue(storeNameIndex); + + if (string.IsNullOrWhiteSpace(id) && string.IsNullOrWhiteSpace(employeeName)) + continue; + + if (string.IsNullOrWhiteSpace(id) && !string.IsNullOrWhiteSpace(employeeName)) + { + var matchedRecord = await _db.Queryable() + .Where(x => x.EmployeeName == employeeName) + .WhereIF(!string.IsNullOrWhiteSpace(storeName), x => x.StoreName == storeName) + .OrderBy(x => x.CreateTime, OrderByType.Desc) + .FirstAsync(); + if (matchedRecord != null) id = matchedRecord.Id; + } + + if (string.IsNullOrWhiteSpace(employeeName)) + { + errorMessages.Add($"第{i + 1}行:员工姓名不能为空"); + failCount++; + continue; + } + + LqAssistantSalaryStatisticsEntity existing = null; + if (!string.IsNullOrWhiteSpace(id)) + { + existing = await _db.Queryable() + .Where(x => x.Id == id).FirstAsync(); + + if (existing != null && (existing.IsLocked == 1 || existing.EmployeeConfirmStatus == 1)) + { + skippedCount++; + failCount++; + continue; + } + } + + var entity = existing ?? new LqAssistantSalaryStatisticsEntity + { + Id = string.IsNullOrWhiteSpace(id) ? YitIdHelper.NextId().ToString() : id, + EmployeeConfirmStatus = 0, + IsLocked = 0, + CreateTime = DateTime.Now, + CreateUser = "" + }; + + entity.StoreName = storeName; + entity.EmployeeName = employeeName; + entity.Position = GetColumnValue(2 + offset); + entity.StoreTotalPerformance = ParseDecimal(GetColumnValue(3 + offset)); + entity.StoreBillingPerformance = ParseDecimal(GetColumnValue(4 + offset)); + entity.StoreRefundPerformance = ParseDecimal(GetColumnValue(5 + offset)); + entity.StoreLifeline = ParseDecimal(GetColumnValue(6 + offset)); + entity.PerformanceCompletionRate = ParseDecimal(GetColumnValue(7 + offset)); + entity.CommissionRate = ParseDecimal(GetColumnValue(8 + offset)); + entity.CommissionAmount = ParseDecimal(GetColumnValue(9 + offset)); + entity.HeadCount = ParseInt(GetColumnValue(10 + offset)); + entity.Stage1TargetHeadCount = ParseInt(GetColumnValue(11 + offset)); + entity.Stage2TargetHeadCount = ParseInt(GetColumnValue(12 + offset)); + entity.ReachedStage1 = GetColumnValue(13 + offset); + entity.ReachedStage2 = GetColumnValue(14 + offset); + entity.StageRewardAmount = ParseDecimal(GetColumnValue(15 + offset)); + entity.Stage1Reward = ParseDecimal(GetColumnValue(16 + offset)); + entity.Stage2Reward = ParseDecimal(GetColumnValue(17 + offset)); + entity.BaseSalary = ParseDecimal(GetColumnValue(18 + offset)); + entity.PhoneManagementFee = ParseDecimal(GetColumnValue(19 + offset)); + entity.WorkingDays = ParseInt(GetColumnValue(20 + offset)); + entity.LeaveDays = ParseInt(GetColumnValue(21 + offset)); + entity.GrossSalary = ParseDecimal(GetColumnValue(22 + offset)); + entity.ActualSalary = ParseDecimal(GetColumnValue(23 + offset)); + entity.TotalDeduction = ParseDecimal(GetColumnValue(24 + offset)); + entity.TotalSubsidy = ParseDecimal(GetColumnValue(25 + offset)); + entity.Bonus = ParseDecimal(GetColumnValue(26 + offset)); + entity.ReturnPhoneDeposit = ParseDecimal(GetColumnValue(27 + offset)); + entity.ReturnAccommodationDeposit = ParseDecimal(GetColumnValue(28 + offset)); + entity.LastMonthSupplement = ParseDecimal(GetColumnValue(29 + offset)); + entity.MonthlyPaymentStatus = GetColumnValue(30 + offset); + entity.PaidAmount = ParseDecimal(GetColumnValue(31 + offset)); + entity.PendingAmount = ParseDecimal(GetColumnValue(32 + offset)); + entity.MonthlyTotalPayment = ParseDecimal(GetColumnValue(33 + offset)); + entity.IsNewStore = GetColumnValue(34 + offset) == "是" ? "是" : "否"; + entity.NewStoreProtectionStage = ParseInt(GetColumnValue(35 + offset)); + + if (existing != null) + { + entity.StoreId = existing.StoreId; + entity.EmployeeId = existing.EmployeeId; + entity.StatisticsMonth = existing.StatisticsMonth; + } + else + { + // 对于新记录,尝试通过员工姓名和门店名称匹配已有记录以获取统计月份 + LqAssistantSalaryStatisticsEntity matchedRecord = null; + if (!string.IsNullOrWhiteSpace(employeeName)) + { + var user = await _db.Queryable() + .Where(u => u.RealName == employeeName).FirstAsync(); + if (user != null) + { + entity.EmployeeId = user.Id; + if (!string.IsNullOrEmpty(user.Mdid) && string.IsNullOrWhiteSpace(storeName)) + entity.StoreId = user.Mdid; + + // 尝试通过员工ID匹配已有记录获取统计月份 + matchedRecord = await _db.Queryable() + .Where(x => x.EmployeeId == user.Id) + .WhereIF(!string.IsNullOrWhiteSpace(storeName), x => x.StoreName == storeName) + .OrderBy(x => x.CreateTime, OrderByType.Desc) + .FirstAsync(); + } + + if (!string.IsNullOrWhiteSpace(storeName) && string.IsNullOrEmpty(entity.StoreId)) + { + var store = await _db.Queryable() + .Where(s => s.Dm == storeName).FirstAsync(); + if (store != null) + { + entity.StoreId = store.Id; + // 如果没有匹配到记录,再通过门店ID尝试 + if (matchedRecord == null) + { + matchedRecord = await _db.Queryable() + .Where(x => x.StoreId == store.Id) + .WhereIF(!string.IsNullOrWhiteSpace(employeeName), x => x.EmployeeName == employeeName) + .OrderBy(x => x.CreateTime, OrderByType.Desc) + .FirstAsync(); + } + } + } + } + + // 如果有匹配的记录,使用其统计月份;否则使用当前年月作为默认值 + if (matchedRecord != null && !string.IsNullOrWhiteSpace(matchedRecord.StatisticsMonth)) + { + entity.StatisticsMonth = matchedRecord.StatisticsMonth; + } + else + { + // 如果没有匹配记录,使用当前年月(YYYYMM格式) + var now = DateTime.Now; + entity.StatisticsMonth = $"{now.Year}{now.Month:D2}"; + } + } + + entity.UpdateTime = DateTime.Now; + if (existing != null) recordsToUpdate.Add(entity); + else recordsToInsert.Add(entity); + successCount++; + } + catch (Exception ex) + { + errorMessages.Add($"第{i + 1}行数据处理失败: {ex.Message}"); + failCount++; + } + } + } + finally + { + if (File.Exists(tempFilePath)) File.Delete(tempFilePath); + } + + if (recordsToInsert.Any()) await _db.Insertable(recordsToInsert).ExecuteCommandAsync(); + if (recordsToUpdate.Any()) await _db.Updateable(recordsToUpdate).ExecuteCommandAsync(); + + return new + { + success = true, + message = $"导入完成:成功 {successCount} 条,失败 {failCount} 条,跳过 {skippedCount} 条(已锁定或已确认)", + successCount, + failCount, + skippedCount, + errors = errorMessages + }; + } + catch (Exception ex) + { + throw NCCException.Oh($"导入店助工资数据失败: {ex.Message}"); + } + } + + #endregion } } diff --git a/netcore/src/Modularity/Extend/NCC.Extend/LqBusinessUnitManagerSalaryService.cs b/netcore/src/Modularity/Extend/NCC.Extend/LqBusinessUnitManagerSalaryService.cs index a0116f8..5053aac 100644 --- a/netcore/src/Modularity/Extend/NCC.Extend/LqBusinessUnitManagerSalaryService.cs +++ b/netcore/src/Modularity/Extend/NCC.Extend/LqBusinessUnitManagerSalaryService.cs @@ -62,17 +62,7 @@ namespace NCC.Extend { var monthStr = $"{input.Year}{input.Month:D2}"; - // 1. 检查当月是否已生成工资数据 - var exists = await _db.Queryable() - .AnyAsync(x => x.StatisticsMonth == monthStr); - - // 2. 如果没有数据,则进行计算 - if (!exists) - { - await CalculateBusinessUnitManagerSalary(input.Year, input.Month); - } - - // 3. 查询数据 + // 查询数据 var query = _db.Queryable() .Where(x => x.StatisticsMonth == monthStr); @@ -141,6 +131,74 @@ namespace NCC.Extend } /// + /// 通过月份和员工ID查询工资 + /// + [HttpGet("query-by-employee")] + public async Task GetSalaryByEmployee([FromQuery] SalaryQueryByEmployeeInput input) + { + if (input.Year <= 0 || input.Month <= 0 || input.Month > 12) + throw NCCException.Oh("年份和月份参数不正确"); + if (string.IsNullOrWhiteSpace(input.EmployeeId)) + throw NCCException.Oh("员工ID不能为空"); + var monthStr = $"{input.Year}{input.Month:D2}"; + var salary = await _db.Queryable() + .Where(x => x.StatisticsMonth == monthStr && x.EmployeeId == input.EmployeeId) + .Select(x => new BusinessUnitManagerSalaryOutput + { + Id = x.Id, + StatisticsMonth = x.StatisticsMonth, + Position = x.Position, + EmployeeName = x.EmployeeName, + EmployeeId = x.EmployeeId, + EmployeeAccount = x.EmployeeAccount, + ManagerType = x.ManagerType, + IsTerminated = x.IsTerminated, + StorePerformanceDetail = x.StorePerformanceDetail, + SalesPerformance = x.SalesPerformance, + ProductMaterial = x.ProductMaterial, + CooperationCost = x.CooperationCost, + StoreExpense = x.StoreExpense, + LaundryCost = x.LaundryCost, + GrossProfit = x.GrossProfit, + BaseSalary = x.BaseSalary, + TotalCommission = x.TotalCommission, + WorkingDays = x.WorkingDays, + LeaveDays = x.LeaveDays, + CalculatedGrossSalary = x.CalculatedGrossSalary, + FinalGrossSalary = x.FinalGrossSalary, + MonthlyTrainingSubsidy = x.MonthlyTrainingSubsidy, + MonthlyTransportSubsidy = x.MonthlyTransportSubsidy, + LastMonthTrainingSubsidy = x.LastMonthTrainingSubsidy, + LastMonthTransportSubsidy = x.LastMonthTransportSubsidy, + TotalSubsidy = x.TotalSubsidy, + MissingCard = x.MissingCard, + LateArrival = x.LateArrival, + LeaveDeduction = x.LeaveDeduction, + SocialInsuranceDeduction = x.SocialInsuranceDeduction, + RewardDeduction = x.RewardDeduction, + AccommodationDeduction = x.AccommodationDeduction, + StudyPeriodDeduction = x.StudyPeriodDeduction, + WorkClothesDeduction = x.WorkClothesDeduction, + TotalDeduction = x.TotalDeduction, + Bonus = x.Bonus, + ReturnPhoneDeposit = x.ReturnPhoneDeposit, + ReturnAccommodationDeposit = x.ReturnAccommodationDeposit, + ActualSalary = x.ActualSalary, + MonthlyPaymentStatus = x.MonthlyPaymentStatus, + PaidAmount = x.PaidAmount, + PendingAmount = x.PendingAmount, + LastMonthSupplement = x.LastMonthSupplement, + MonthlyTotalPayment = x.MonthlyTotalPayment, + IsLocked = x.IsLocked, + UpdateTime = x.UpdateTime + }) + .FirstAsync(); + if (salary == null) + throw NCCException.Oh($"未找到员工{input.EmployeeId}在{input.Year}年{input.Month}月的工资记录"); + return salary; + } + + /// /// 计算事业部总经理/经理工资 /// /// 年份 @@ -635,6 +693,35 @@ namespace NCC.Extend #region 员工工资确认 + /// + /// 员工确认工资条 + /// + /// + /// 员工确认自己的工资条,确认后工资数据不可再修改 + /// + /// 示例请求: + /// + /// { + /// "id": "工资记录ID", + /// "employeeId": "员工ID", + /// "remark": "确认备注(可选)" + /// } + /// + /// + /// 参数说明: + /// - id: 工资记录ID(必填) + /// - employeeId: 员工ID(必填) + /// - remark: 确认备注(可选) + /// + /// 注意事项: + /// - 只能确认自己的工资条 + /// - 只能确认已锁定的工资条(IsLocked = 1) + /// - 已确认的工资条不能重复确认 + /// + /// 确认参数 + /// 操作结果 + /// 确认成功 + /// 参数错误或记录不存在 [HttpPost("confirm")] public async Task ConfirmSalary([FromBody] SalaryConfirmInput input) { @@ -662,6 +749,156 @@ namespace NCC.Extend #endregion + #region 工资锁定/解锁 + + /// + /// 批量锁定/解锁工资条 + /// + [HttpPost("lock")] + public async Task LockSalary([FromBody] SalaryLockInput input) + { + try + { + if (input == null || input.Ids == null || !input.Ids.Any()) + throw NCCException.Oh("工资记录ID列表不能为空"); + var salaries = await _db.Queryable() + .Where(s => input.Ids.Contains(s.Id)) + .ToListAsync(); + if (!salaries.Any()) + throw NCCException.Oh("未找到指定的工资记录"); + var lockedCount = 0; + var unlockedCount = 0; + var skippedCount = 0; + foreach (var salary in salaries) + { + if (salary.EmployeeConfirmStatus == 1 && !input.IsLocked) + { + skippedCount++; + continue; + } + salary.IsLocked = input.IsLocked ? 1 : 0; + salary.UpdateTime = DateTime.Now; + if (input.IsLocked) lockedCount++; else unlockedCount++; + } + await _db.Updateable(salaries).ExecuteCommandAsync(); + var action = input.IsLocked ? "锁定" : "解锁"; + var count = input.IsLocked ? lockedCount : unlockedCount; + var message = $"{action}成功:{count}条"; + if (skippedCount > 0) + message += $",跳过{skippedCount}条(已确认的记录不能解锁)"; + return message; + } + catch (Exception ex) + { + throw NCCException.Oh($"锁定/解锁工资条失败: {ex.Message}"); + } + } + + /// + /// 批量锁定当月所有工资 + /// + /// 批量锁定输入参数 + /// 锁定结果 + [HttpPost("lock-by-month")] + public async Task LockSalaryByMonth([FromBody] SalaryLockByMonthInput input) + { + try + { + if (input == null) + throw NCCException.Oh("参数不能为空"); + + if (input.Year <= 0 || input.Month <= 0 || input.Month > 12) + throw NCCException.Oh("年份和月份参数不正确"); + + var monthStr = $"{input.Year}{input.Month:D2}"; + + var salaries = await _db.Queryable() + .Where(s => s.StatisticsMonth == monthStr) + .ToListAsync(); + + if (!salaries.Any()) + throw NCCException.Oh($"未找到{input.Year}年{input.Month}月的工资记录"); + + var lockedCount = 0; + var unlockedCount = 0; + var skippedCount = 0; + var alreadyLockedCount = 0; + + foreach (var salary in salaries) + { + if (salary.EmployeeConfirmStatus == 1 && !input.IsLocked) + { + skippedCount++; + continue; + } + + if (salary.IsLocked == 1 && input.IsLocked) + { + alreadyLockedCount++; + continue; + } + + if (salary.IsLocked == 0 && !input.IsLocked) + { + alreadyLockedCount++; + continue; + } + + salary.IsLocked = input.IsLocked ? 1 : 0; + salary.UpdateTime = DateTime.Now; + + if (input.IsLocked) + lockedCount++; + else + unlockedCount++; + } + + if (lockedCount > 0 || unlockedCount > 0) + { + var salariesToUpdate = salaries.Where(s => + (input.IsLocked && s.IsLocked == 0) || + (!input.IsLocked && s.IsLocked == 1 && s.EmployeeConfirmStatus != 1) + ).ToList(); + + if (salariesToUpdate.Any()) + { + await _db.Updateable(salariesToUpdate) + .UpdateColumns(s => new { s.IsLocked, s.UpdateTime }) + .ExecuteCommandAsync(); + } + } + + var action = input.IsLocked ? "锁定" : "解锁"; + var count = input.IsLocked ? lockedCount : unlockedCount; + var message = $"{action}成功:{count}条"; + + if (alreadyLockedCount > 0) + message += $",跳过{alreadyLockedCount}条(已是{action}状态)"; + + if (skippedCount > 0) + message += $",跳过{skippedCount}条(已确认的记录不能解锁)"; + + return new + { + success = true, + message = message, + total = salaries.Count, + locked = lockedCount, + unlocked = unlockedCount, + skipped = skippedCount, + alreadyLocked = alreadyLockedCount + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "批量锁定当月工资失败"); + var action = input?.IsLocked == true ? "锁定" : "解锁"; + throw NCCException.Oh($"批量{action}当月工资失败: {ex.Message}"); + } + } + + #endregion + #region 导入工资 /// diff --git a/netcore/src/Modularity/Extend/NCC.Extend/LqDirectorSalaryService.cs b/netcore/src/Modularity/Extend/NCC.Extend/LqDirectorSalaryService.cs index 6be2921..75aaac8 100644 --- a/netcore/src/Modularity/Extend/NCC.Extend/LqDirectorSalaryService.cs +++ b/netcore/src/Modularity/Extend/NCC.Extend/LqDirectorSalaryService.cs @@ -1,10 +1,13 @@ using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; using NCC.Common.Filter; using NCC.Common.Helper; using NCC.Dependency; using NCC.DynamicApiController; using NCC.Extend.Entitys.Dto.LqDirectorSalary; +using NCC.Extend.Entitys.Dto.LqSalary; using NCC.Extend.Entitys.Enum; using NCC.Extend.Entitys.lq_attendance_summary; using NCC.Extend.Entitys.lq_cooperation_cost; @@ -18,10 +21,13 @@ using NCC.Extend.Entitys.lq_mdxx; using NCC.Extend.Entitys.lq_store_expense; using NCC.Extend.Entitys.lq_xh_hyhk; using NCC.Extend.Entitys.lq_xh_jksyj; +using NCC.FriendlyException; using NCC.System.Entitys.Permission; using SqlSugar; using System; using System.Collections.Generic; +using System.Data; +using System.IO; using System.Linq; using System.Threading.Tasks; using Yitter.IdGenerator; @@ -36,13 +42,15 @@ namespace NCC.Extend public class LqDirectorSalaryService : IDynamicApiController, ITransient { private readonly ISqlSugarClient _db; + private readonly ILogger _logger; /// /// 初始化一个类型的新实例 /// - public LqDirectorSalaryService(ISqlSugarClient db) + public LqDirectorSalaryService(ISqlSugarClient db, ILogger logger) { _db = db; + _logger = logger; } /// @@ -55,17 +63,7 @@ namespace NCC.Extend { var monthStr = $"{input.Year}{input.Month:D2}"; - // 1. 检查当月是否已生成工资数据 - var exists = await _db.Queryable() - .AnyAsync(x => x.StatisticsMonth == monthStr); - - // 2. 如果没有数据,则进行计算 - if (!exists) - { - await CalculateDirectorSalary(input.Year, input.Month); - } - - // 3. 查询数据 + // 查询数据 var query = _db.Queryable() .Where(x => x.StatisticsMonth == monthStr); @@ -133,6 +131,59 @@ namespace NCC.Extend } /// + /// 通过月份和员工ID查询工资 + /// + [HttpGet("query-by-employee")] + public async Task GetSalaryByEmployee([FromQuery] SalaryQueryByEmployeeInput input) + { + if (input.Year <= 0 || input.Month <= 0 || input.Month > 12) + throw NCCException.Oh("年份和月份参数不正确"); + if (string.IsNullOrWhiteSpace(input.EmployeeId)) + throw NCCException.Oh("员工ID不能为空"); + var monthStr = $"{input.Year}{input.Month:D2}"; + var salary = await _db.Queryable() + .Where(x => x.StatisticsMonth == monthStr && x.EmployeeId == input.EmployeeId) + .Select(x => new DirectorSalaryOutput + { + Id = x.Id, + StoreName = x.StoreName, + EmployeeName = x.EmployeeName, + Position = x.Position, + StoreTotalPerformance = x.StoreTotalPerformance, + StoreBillingPerformance = x.StoreBillingPerformance, + StoreRefundPerformance = x.StoreRefundPerformance, + StoreLifeline = x.StoreLifeline, + PerformanceCompletionRate = x.PerformanceCompletionRate, + CommissionRateBelowLifeline = x.CommissionRateBelowLifeline, + CommissionRateAboveLifeline = x.CommissionRateAboveLifeline, + CommissionAmountBelowLifeline = x.CommissionAmountBelowLifeline, + CommissionAmountAboveLifeline = x.CommissionAmountAboveLifeline, + TotalCommissionAmount = x.TotalCommissionAmount, + BaseSalary = x.BaseSalary, + WorkingDays = x.WorkingDays, + LeaveDays = x.LeaveDays, + GrossSalary = x.GrossSalary, + ActualSalary = x.ActualSalary, + TotalDeduction = x.TotalDeduction, + TotalSubsidy = x.TotalSubsidy, + Bonus = x.Bonus, + MonthlyPaymentStatus = x.MonthlyPaymentStatus, + PaidAmount = x.PaidAmount, + PendingAmount = x.PendingAmount, + IsLocked = x.IsLocked, + StoreType = x.StoreType, + StoreCategory = x.StoreCategory, + IsNewStore = x.IsNewStore, + NewStoreProtectionStage = x.NewStoreProtectionStage, + UpdateTime = x.UpdateTime + }) + .FirstAsync(); + if (salary == null) + throw NCCException.Oh($"未找到员工{input.EmployeeId}在{input.Year}年{input.Month}月的工资记录"); + return salary; + } + + /// /// 计算主任工资 /// /// 年份 @@ -552,12 +603,42 @@ namespace NCC.Extend // 3. 保存数据 if (directorSalaryList.Any()) { - // 先删除当月旧数据 (防止重复) - await _db.Deleteable() + var existingRecords = await _db.Queryable() .Where(x => x.StatisticsMonth == monthStr) - .ExecuteCommandAsync(); - - await _db.Insertable(directorSalaryList).ExecuteCommandAsync(); + .ToListAsync(); + var existingDict = existingRecords.Where(x => !string.IsNullOrEmpty(x.EmployeeId)) + .GroupBy(x => x.EmployeeId).ToDictionary(g => g.Key, g => g.First()); + var recordsToInsert = new List(); + var recordsToUpdate = new List(); + var skippedCount = 0; + foreach (var salary in directorSalaryList) + { + if (existingDict.ContainsKey(salary.EmployeeId)) + { + var existing = existingDict[salary.EmployeeId]; + if (existing.IsLocked == 1 || existing.EmployeeConfirmStatus == 1) { skippedCount++; continue; } + salary.Id = existing.Id; + salary.EmployeeConfirmStatus = existing.EmployeeConfirmStatus; + salary.EmployeeConfirmTime = existing.EmployeeConfirmTime; + salary.EmployeeConfirmRemark = existing.EmployeeConfirmRemark; + salary.IsLocked = existing.IsLocked; + salary.CreateTime = existing.CreateTime; + salary.CreateUser = existing.CreateUser; + recordsToUpdate.Add(salary); + } + else + { + salary.Id = YitIdHelper.NextId().ToString(); + salary.EmployeeConfirmStatus = 0; + salary.IsLocked = 0; + salary.CreateTime = DateTime.Now; + salary.CreateUser = ""; + recordsToInsert.Add(salary); + } + } + if (recordsToInsert.Any()) await _db.Insertable(recordsToInsert).ExecuteCommandAsync(); + if (recordsToUpdate.Any()) await _db.Updateable(recordsToUpdate).ExecuteCommandAsync(); + if (skippedCount > 0) _logger.LogWarning($"计算工资时跳过了 {skippedCount} 条已锁定或已确认的记录(月份:{monthStr})"); } } @@ -649,6 +730,448 @@ namespace NCC.Extend salary.TotalCommissionAmount = salary.CommissionAmountBelowLifeline + salary.CommissionAmountAboveLifeline; } + + #region 员工工资确认 + + /// + /// 员工确认工资条 + /// + /// + /// 员工确认自己的工资条,确认后工资数据不可再修改 + /// + /// 示例请求: + /// + /// { + /// "id": "工资记录ID", + /// "employeeId": "员工ID", + /// "remark": "确认备注(可选)" + /// } + /// + /// + /// 参数说明: + /// - id: 工资记录ID(必填) + /// - employeeId: 员工ID(必填) + /// - remark: 确认备注(可选) + /// + /// 注意事项: + /// - 只能确认自己的工资条 + /// - 只能确认已锁定的工资条(IsLocked = 1) + /// - 已确认的工资条不能重复确认 + /// + /// 确认参数 + /// 操作结果 + /// 确认成功 + /// 参数错误或记录不存在 + [HttpPost("confirm")] + public async Task ConfirmSalary([FromBody] SalaryConfirmInput input) + { + try + { + if (string.IsNullOrWhiteSpace(input.Id) || string.IsNullOrWhiteSpace(input.EmployeeId)) + throw NCCException.Oh("工资记录ID和员工ID不能为空"); + var salary = await _db.Queryable() + .Where(s => s.Id == input.Id && s.EmployeeId == input.EmployeeId).FirstAsync(); + if (salary == null) throw NCCException.Oh("工资记录不存在或不属于该员工"); + if (salary.EmployeeConfirmStatus == 1) throw NCCException.Oh("该工资条已确认,不能重复确认"); + if (salary.IsLocked != 1) throw NCCException.Oh("该工资条尚未锁定,请等待管理员锁定后再确认"); + salary.EmployeeConfirmStatus = 1; + salary.EmployeeConfirmTime = DateTime.Now; + salary.EmployeeConfirmRemark = input.Remark; + salary.UpdateTime = DateTime.Now; + await _db.Updateable(salary).ExecuteCommandAsync(); + return "确认成功"; + } + catch (Exception ex) + { + throw NCCException.Oh($"确认工资条失败: {ex.Message}"); + } + } + + #endregion + + #region 工资锁定/解锁 + + /// + /// 批量锁定/解锁工资条 + /// + [HttpPost("lock")] + public async Task LockSalary([FromBody] SalaryLockInput input) + { + try + { + if (input == null || input.Ids == null || !input.Ids.Any()) + throw NCCException.Oh("工资记录ID列表不能为空"); + var salaries = await _db.Queryable() + .Where(s => input.Ids.Contains(s.Id)) + .ToListAsync(); + if (!salaries.Any()) + throw NCCException.Oh("未找到指定的工资记录"); + var lockedCount = 0; + var unlockedCount = 0; + var skippedCount = 0; + foreach (var salary in salaries) + { + if (salary.EmployeeConfirmStatus == 1 && !input.IsLocked) + { + skippedCount++; + continue; + } + salary.IsLocked = input.IsLocked ? 1 : 0; + salary.UpdateTime = DateTime.Now; + if (input.IsLocked) lockedCount++; else unlockedCount++; + } + await _db.Updateable(salaries).ExecuteCommandAsync(); + var action = input.IsLocked ? "锁定" : "解锁"; + var count = input.IsLocked ? lockedCount : unlockedCount; + var message = $"{action}成功:{count}条"; + if (skippedCount > 0) + message += $",跳过{skippedCount}条(已确认的记录不能解锁)"; + return message; + } + catch (Exception ex) + { + throw NCCException.Oh($"锁定/解锁工资条失败: {ex.Message}"); + } + } + + /// + /// 批量锁定当月所有工资 + /// + /// 批量锁定输入参数 + /// 锁定结果 + [HttpPost("lock-by-month")] + public async Task LockSalaryByMonth([FromBody] SalaryLockByMonthInput input) + { + try + { + if (input == null) + throw NCCException.Oh("参数不能为空"); + + if (input.Year <= 0 || input.Month <= 0 || input.Month > 12) + throw NCCException.Oh("年份和月份参数不正确"); + + var monthStr = $"{input.Year}{input.Month:D2}"; + + var salaries = await _db.Queryable() + .Where(s => s.StatisticsMonth == monthStr) + .ToListAsync(); + + if (!salaries.Any()) + throw NCCException.Oh($"未找到{input.Year}年{input.Month}月的工资记录"); + + var lockedCount = 0; + var unlockedCount = 0; + var skippedCount = 0; + var alreadyLockedCount = 0; + + foreach (var salary in salaries) + { + if (salary.EmployeeConfirmStatus == 1 && !input.IsLocked) + { + skippedCount++; + continue; + } + + if (salary.IsLocked == 1 && input.IsLocked) + { + alreadyLockedCount++; + continue; + } + + if (salary.IsLocked == 0 && !input.IsLocked) + { + alreadyLockedCount++; + continue; + } + + salary.IsLocked = input.IsLocked ? 1 : 0; + salary.UpdateTime = DateTime.Now; + + if (input.IsLocked) + lockedCount++; + else + unlockedCount++; + } + + if (lockedCount > 0 || unlockedCount > 0) + { + var salariesToUpdate = salaries.Where(s => + (input.IsLocked && s.IsLocked == 0) || + (!input.IsLocked && s.IsLocked == 1 && s.EmployeeConfirmStatus != 1) + ).ToList(); + + if (salariesToUpdate.Any()) + { + await _db.Updateable(salariesToUpdate) + .UpdateColumns(s => new { s.IsLocked, s.UpdateTime }) + .ExecuteCommandAsync(); + } + } + + var action = input.IsLocked ? "锁定" : "解锁"; + var count = input.IsLocked ? lockedCount : unlockedCount; + var message = $"{action}成功:{count}条"; + + if (alreadyLockedCount > 0) + message += $",跳过{alreadyLockedCount}条(已是{action}状态)"; + + if (skippedCount > 0) + message += $",跳过{skippedCount}条(已确认的记录不能解锁)"; + + return new + { + success = true, + message = message, + total = salaries.Count, + locked = lockedCount, + unlocked = unlockedCount, + skipped = skippedCount, + alreadyLocked = alreadyLockedCount + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "批量锁定当月工资失败"); + var action = input?.IsLocked == true ? "锁定" : "解锁"; + throw NCCException.Oh($"批量{action}当月工资失败: {ex.Message}"); + } + } + + #endregion + + #region 导入工资 + + /// + /// 从Excel导入主任工资数据 + /// + /// Excel文件 + /// 导入结果 + [HttpPost("import")] + public async Task ImportSalaryFromExcel(IFormFile file) + { + try + { + if (file == null || file.Length == 0) + throw NCCException.Oh("请选择要上传的Excel文件"); + + var allowedExtensions = new[] { ".xlsx", ".xls" }; + var fileExtension = Path.GetExtension(file.FileName).ToLowerInvariant(); + if (!allowedExtensions.Contains(fileExtension)) + throw NCCException.Oh("只支持.xlsx和.xls格式的Excel文件"); + + var recordsToInsert = new List(); + var recordsToUpdate = new List(); + var errorMessages = new List(); + var successCount = 0; + var failCount = 0; + var skippedCount = 0; + + var tempFilePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString() + Path.GetExtension(file.FileName)); + try + { + using (var stream = new FileStream(tempFilePath, FileMode.Create)) + { + await file.CopyToAsync(stream); + } + + var dataTable = ExcelImportHelper.ToDataTable(tempFilePath, 0, 0); + if (dataTable.Rows.Count == 0) + throw NCCException.Oh("Excel文件中没有数据行"); + + Func ParseDecimal = (str) => + { + if (string.IsNullOrWhiteSpace(str)) return 0; + var cleaned = str.Trim().Replace(",", "").Replace(",", "").Replace("¥", "").Replace("$", "").Replace("元", "").Replace("%", "").Replace(" ", ""); + return decimal.TryParse(cleaned, out decimal result) ? result : 0; + }; + + Func ParseInt = (str) => + { + if (string.IsNullOrWhiteSpace(str)) return 0; + var cleaned = str.Trim().Replace(",", "").Replace(",", "").Replace(" ", ""); + return int.TryParse(cleaned, out int result) ? result : 0; + }; + + for (int i = 1; i < dataTable.Rows.Count; i++) + { + try + { + var row = dataTable.Rows[i]; + Func GetColumnValue = (colIndex) => colIndex < row.ItemArray.Length && row[colIndex] != null ? row[colIndex].ToString().Trim() : ""; + + var firstColumnValue = GetColumnValue(0); + bool isOldFormat = !string.IsNullOrWhiteSpace(firstColumnValue) && (firstColumnValue == "门店名称" || (!long.TryParse(firstColumnValue, out _) && firstColumnValue.Length > 20)); + + int storeNameIndex = isOldFormat ? 0 : 1; + int employeeNameIndex = isOldFormat ? 1 : 2; + int offset = isOldFormat ? 0 : 1; + + var id = isOldFormat ? "" : GetColumnValue(0); + var employeeName = GetColumnValue(employeeNameIndex); + var storeName = GetColumnValue(storeNameIndex); + + if (string.IsNullOrWhiteSpace(id) && string.IsNullOrWhiteSpace(employeeName)) + continue; + + if (string.IsNullOrWhiteSpace(id) && !string.IsNullOrWhiteSpace(employeeName)) + { + var matchedRecord = await _db.Queryable() + .Where(x => x.EmployeeName == employeeName) + .WhereIF(!string.IsNullOrWhiteSpace(storeName), x => x.StoreName == storeName) + .OrderBy(x => x.CreateTime, OrderByType.Desc) + .FirstAsync(); + if (matchedRecord != null) id = matchedRecord.Id; + } + + if (string.IsNullOrWhiteSpace(employeeName)) + { + errorMessages.Add($"第{i + 1}行:员工姓名不能为空"); + failCount++; + continue; + } + + LqDirectorSalaryStatisticsEntity existing = null; + if (!string.IsNullOrWhiteSpace(id)) + { + existing = await _db.Queryable() + .Where(x => x.Id == id).FirstAsync(); + + if (existing != null && (existing.IsLocked == 1 || existing.EmployeeConfirmStatus == 1)) + { + skippedCount++; + failCount++; + continue; + } + } + + var entity = existing ?? new LqDirectorSalaryStatisticsEntity + { + Id = string.IsNullOrWhiteSpace(id) ? YitIdHelper.NextId().ToString() : id, + EmployeeConfirmStatus = 0, + IsLocked = 0, + CreateTime = DateTime.Now, + CreateUser = "" + }; + + // Excel字段映射(主任工资43列,Excel顺序:门店名称,员工姓名,岗位,实发工资,补贴合计,扣款合计,是否锁定,是否新店,新店保护阶段,门店总业绩...) + entity.StoreName = storeName; + entity.EmployeeName = employeeName; + entity.Position = GetColumnValue(2 + offset); + entity.ActualSalary = ParseDecimal(GetColumnValue(3 + offset)); + entity.TotalSubsidy = ParseDecimal(GetColumnValue(4 + offset)); + entity.TotalDeduction = ParseDecimal(GetColumnValue(5 + offset)); + // 跳过"是否锁定"字段(第7列),在最后处理 + entity.IsNewStore = GetColumnValue(7 + offset) == "是" ? "是" : "否"; + entity.NewStoreProtectionStage = ParseInt(GetColumnValue(8 + offset)); + entity.StoreTotalPerformance = ParseDecimal(GetColumnValue(9 + offset)); + entity.StoreBillingPerformance = ParseDecimal(GetColumnValue(10 + offset)); + entity.StoreRefundPerformance = ParseDecimal(GetColumnValue(11 + offset)); + entity.StoreLifeline = ParseDecimal(GetColumnValue(12 + offset)); + entity.PerformanceCompletionRate = ParseDecimal(GetColumnValue(13 + offset)); + entity.PerformanceReached = GetColumnValue(14 + offset); + entity.HeadCountReached = GetColumnValue(15 + offset); + entity.ConsumeReached = GetColumnValue(16 + offset); + entity.AssessmentDeduction = ParseDecimal(GetColumnValue(17 + offset)); + entity.UnreachedIndicatorCount = ParseInt(GetColumnValue(18 + offset)); + entity.HeadCount = ParseInt(GetColumnValue(19 + offset)); + entity.TargetHeadCount = ParseDecimal(GetColumnValue(20 + offset)); + entity.StoreConsume = ParseDecimal(GetColumnValue(21 + offset)); + entity.TargetConsume = ParseDecimal(GetColumnValue(22 + offset)); + entity.CommissionRateBelowLifeline = ParseDecimal(GetColumnValue(23 + offset)); + entity.CommissionRateAboveLifeline = ParseDecimal(GetColumnValue(24 + offset)); + entity.CommissionAmountBelowLifeline = ParseDecimal(GetColumnValue(25 + offset)); + entity.CommissionAmountAboveLifeline = ParseDecimal(GetColumnValue(26 + offset)); + entity.TotalCommissionAmount = ParseDecimal(GetColumnValue(27 + offset)); + entity.BaseSalary = ParseDecimal(GetColumnValue(28 + offset)); + entity.ActualBaseSalary = ParseDecimal(GetColumnValue(29 + offset)); + entity.WorkingDays = ParseInt(GetColumnValue(30 + offset)); + entity.LeaveDays = ParseInt(GetColumnValue(31 + offset)); + entity.GrossSalary = ParseDecimal(GetColumnValue(32 + offset)); + entity.Bonus = ParseDecimal(GetColumnValue(33 + offset)); + entity.ReturnPhoneDeposit = ParseDecimal(GetColumnValue(34 + offset)); + entity.ReturnAccommodationDeposit = ParseDecimal(GetColumnValue(35 + offset)); + entity.MonthlyPaymentStatus = GetColumnValue(36 + offset); + entity.PaidAmount = ParseDecimal(GetColumnValue(37 + offset)); + entity.PendingAmount = ParseDecimal(GetColumnValue(38 + offset)); + entity.LastMonthSupplement = ParseDecimal(GetColumnValue(39 + offset)); + entity.MonthlyTotalPayment = ParseDecimal(GetColumnValue(40 + offset)); + // 处理门店类型和类别 + var storeTypeStr = GetColumnValue(41 + offset); + if (!string.IsNullOrWhiteSpace(storeTypeStr) && int.TryParse(storeTypeStr, out int storeType)) + entity.StoreType = storeType; + var storeCategoryStr = GetColumnValue(42 + offset); + if (!string.IsNullOrWhiteSpace(storeCategoryStr) && int.TryParse(storeCategoryStr, out int storeCategory)) + entity.StoreCategory = storeCategory; + // 处理锁定状态(第7列) + var isLockedStr = GetColumnValue(6 + offset); + if (isLockedStr == "已锁定" || isLockedStr == "1" || isLockedStr == "锁定") entity.IsLocked = 1; + else entity.IsLocked = 0; + + if (existing != null) + { + entity.StoreId = existing.StoreId; + entity.EmployeeId = existing.EmployeeId; + entity.StatisticsMonth = existing.StatisticsMonth; + } + else + { + if (!string.IsNullOrWhiteSpace(employeeName)) + { + var user = await _db.Queryable() + .Where(u => u.RealName == employeeName).FirstAsync(); + if (user != null) + { + entity.EmployeeId = user.Id; + if (!string.IsNullOrEmpty(user.Mdid) && string.IsNullOrWhiteSpace(storeName)) + entity.StoreId = user.Mdid; + } + + if (!string.IsNullOrWhiteSpace(storeName) && string.IsNullOrEmpty(entity.StoreId)) + { + var store = await _db.Queryable() + .Where(s => s.Dm == storeName).FirstAsync(); + if (store != null) entity.StoreId = store.Id; + } + } + } + + entity.UpdateTime = DateTime.Now; + if (existing != null) recordsToUpdate.Add(entity); + else recordsToInsert.Add(entity); + successCount++; + } + catch (Exception ex) + { + errorMessages.Add($"第{i + 1}行数据处理失败: {ex.Message}"); + failCount++; + } + } + } + finally + { + if (File.Exists(tempFilePath)) File.Delete(tempFilePath); + } + + if (recordsToInsert.Any()) await _db.Insertable(recordsToInsert).ExecuteCommandAsync(); + if (recordsToUpdate.Any()) await _db.Updateable(recordsToUpdate).ExecuteCommandAsync(); + + return new + { + success = true, + message = $"导入完成:成功 {successCount} 条,失败 {failCount} 条,跳过 {skippedCount} 条(已锁定或已确认)", + successCount, + failCount, + skippedCount, + errors = errorMessages + }; + } + catch (Exception ex) + { + throw NCCException.Oh($"导入主任工资数据失败: {ex.Message}"); + } + } + + #endregion } } diff --git a/netcore/src/Modularity/Extend/NCC.Extend/LqMajorProjectDirectorSalaryService.cs b/netcore/src/Modularity/Extend/NCC.Extend/LqMajorProjectDirectorSalaryService.cs index d6e74df..a40efff 100644 --- a/netcore/src/Modularity/Extend/NCC.Extend/LqMajorProjectDirectorSalaryService.cs +++ b/netcore/src/Modularity/Extend/NCC.Extend/LqMajorProjectDirectorSalaryService.cs @@ -1,10 +1,12 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; using NCC.Common.Filter; using NCC.Common.Helper; using NCC.Dependency; using NCC.DynamicApiController; using NCC.Extend.Entitys.Dto.LqMajorProjectDirectorSalary; +using NCC.Extend.Entitys.Dto.LqSalary; using NCC.Extend.Entitys.lq_attendance_summary; using NCC.Extend.Entitys.lq_hytk_hytk; using NCC.Extend.Entitys.lq_hytk_mx; @@ -14,14 +16,18 @@ using NCC.Extend.Entitys.lq_kd_jksyj; using NCC.Extend.Entitys.lq_md_target; using NCC.Extend.Entitys.lq_mdxx; using NCC.Extend.Entitys.lq_major_project_director_salary_statistics; +using NCC.FriendlyException; using NCC.System.Entitys.Permission; using SqlSugar; using System; using System.Collections.Generic; +using System.Data; +using System.IO; using System.Linq; using System.Threading.Tasks; using Yitter.IdGenerator; using Newtonsoft.Json; +using Microsoft.AspNetCore.Http; namespace NCC.Extend { @@ -33,13 +39,15 @@ namespace NCC.Extend public class LqMajorProjectDirectorSalaryService : IDynamicApiController, ITransient { private readonly ISqlSugarClient _db; + private readonly ILogger _logger; /// /// 初始化一个类型的新实例 /// - public LqMajorProjectDirectorSalaryService(ISqlSugarClient db) + public LqMajorProjectDirectorSalaryService(ISqlSugarClient db, ILogger logger) { _db = db; + _logger = logger; } /// @@ -52,17 +60,7 @@ namespace NCC.Extend { var monthStr = $"{input.Year}{input.Month:D2}"; - // 1. 检查当月是否已生成工资数据 - var exists = await _db.Queryable() - .AnyAsync(x => x.StatisticsMonth == monthStr); - - // 2. 如果没有数据,则进行计算 - if (!exists) - { - await CalculateMajorProjectDirectorSalary(input.Year, input.Month); - } - - // 3. 查询数据 + // 查询数据 var query = _db.Queryable() .Where(x => x.StatisticsMonth == monthStr); @@ -128,6 +126,71 @@ namespace NCC.Extend } /// + /// 通过月份和员工ID查询工资 + /// + [HttpGet("query-by-employee")] + public async Task GetSalaryByEmployee([FromQuery] SalaryQueryByEmployeeInput input) + { + if (input.Year <= 0 || input.Month <= 0 || input.Month > 12) + throw NCCException.Oh("年份和月份参数不正确"); + if (string.IsNullOrWhiteSpace(input.EmployeeId)) + throw NCCException.Oh("员工ID不能为空"); + var monthStr = $"{input.Year}{input.Month:D2}"; + var salary = await _db.Queryable() + .Where(x => x.StatisticsMonth == monthStr && x.EmployeeId == input.EmployeeId) + .Select(x => new MajorProjectDirectorSalaryOutput + { + Id = x.Id, + StatisticsMonth = x.StatisticsMonth, + Position = x.Position, + EmployeeName = x.EmployeeName, + EmployeeId = x.EmployeeId, + EmployeeAccount = x.EmployeeAccount, + IsTerminated = x.IsTerminated, + StoreDetail = x.StoreDetail, + TotalPerformance = x.TotalPerformance, + BillingAmount = x.BillingAmount, + RefundAmount = x.RefundAmount, + BaseSalary = x.BaseSalary, + CommissionRate = x.CommissionRate, + CommissionAmount = x.CommissionAmount, + WorkingDays = x.WorkingDays, + LeaveDays = x.LeaveDays, + CalculatedGrossSalary = x.CalculatedGrossSalary, + FinalGrossSalary = x.FinalGrossSalary, + MonthlyTrainingSubsidy = x.MonthlyTrainingSubsidy, + MonthlyTransportSubsidy = x.MonthlyTransportSubsidy, + LastMonthTrainingSubsidy = x.LastMonthTrainingSubsidy, + LastMonthTransportSubsidy = x.LastMonthTransportSubsidy, + TotalSubsidy = x.TotalSubsidy, + MissingCard = x.MissingCard, + LateArrival = x.LateArrival, + LeaveDeduction = x.LeaveDeduction, + SocialInsuranceDeduction = x.SocialInsuranceDeduction, + RewardDeduction = x.RewardDeduction, + AccommodationDeduction = x.AccommodationDeduction, + StudyPeriodDeduction = x.StudyPeriodDeduction, + WorkClothesDeduction = x.WorkClothesDeduction, + TotalDeduction = x.TotalDeduction, + Bonus = x.Bonus, + ReturnPhoneDeposit = x.ReturnPhoneDeposit, + ReturnAccommodationDeposit = x.ReturnAccommodationDeposit, + ActualSalary = x.ActualSalary, + MonthlyPaymentStatus = x.MonthlyPaymentStatus, + PaidAmount = x.PaidAmount, + PendingAmount = x.PendingAmount, + LastMonthSupplement = x.LastMonthSupplement, + MonthlyTotalPayment = x.MonthlyTotalPayment, + IsLocked = x.IsLocked, + UpdateTime = x.UpdateTime + }) + .FirstAsync(); + if (salary == null) + throw NCCException.Oh($"未找到员工{input.EmployeeId}在{input.Year}年{input.Month}月的工资记录"); + return salary; + } + + /// /// 计算大项目主管工资 /// /// 年份 @@ -362,12 +425,41 @@ namespace NCC.Extend // 3. 保存数据 if (directorStats.Any()) { - // 先删除当月旧数据 (防止重复) - await _db.Deleteable() - .Where(x => x.StatisticsMonth == monthStr) - .ExecuteCommandAsync(); - - await _db.Insertable(directorStats.Values.ToList()).ExecuteCommandAsync(); + var existingRecords = await _db.Queryable() + .Where(x => x.StatisticsMonth == monthStr).ToListAsync(); + var existingDict = existingRecords.Where(x => !string.IsNullOrEmpty(x.EmployeeId)) + .GroupBy(x => x.EmployeeId).ToDictionary(g => g.Key, g => g.First()); + var recordsToInsert = new List(); + var recordsToUpdate = new List(); + var skippedCount = 0; + foreach (var salary in directorStats.Values) + { + if (existingDict.ContainsKey(salary.EmployeeId)) + { + var existing = existingDict[salary.EmployeeId]; + if (existing.IsLocked == 1 || existing.EmployeeConfirmStatus == 1) { skippedCount++; continue; } + salary.Id = existing.Id; + salary.EmployeeConfirmStatus = existing.EmployeeConfirmStatus; + salary.EmployeeConfirmTime = existing.EmployeeConfirmTime; + salary.EmployeeConfirmRemark = existing.EmployeeConfirmRemark; + salary.IsLocked = existing.IsLocked; + salary.CreateTime = existing.CreateTime; + salary.CreateUser = existing.CreateUser; + recordsToUpdate.Add(salary); + } + else + { + salary.Id = YitIdHelper.NextId().ToString(); + salary.EmployeeConfirmStatus = 0; + salary.IsLocked = 0; + salary.CreateTime = DateTime.Now; + salary.CreateUser = ""; + recordsToInsert.Add(salary); + } + } + if (recordsToInsert.Any()) await _db.Insertable(recordsToInsert).ExecuteCommandAsync(); + if (recordsToUpdate.Any()) await _db.Updateable(recordsToUpdate).ExecuteCommandAsync(); + if (skippedCount > 0) _logger.LogWarning($"计算工资时跳过了 {skippedCount} 条已锁定或已确认的记录(月份:{monthStr})"); } } @@ -442,6 +534,420 @@ namespace NCC.Extend public decimal RefundAmount { get; set; } public decimal TotalPerformance { get; set; } } + + #region 员工工资确认 + + /// + /// 员工确认工资条 + /// + /// + /// 员工确认自己的工资条,确认后工资数据不可再修改 + /// + /// 示例请求: + /// + /// { + /// "id": "工资记录ID", + /// "employeeId": "员工ID", + /// "remark": "确认备注(可选)" + /// } + /// + /// + /// 参数说明: + /// - id: 工资记录ID(必填) + /// - employeeId: 员工ID(必填) + /// - remark: 确认备注(可选) + /// + /// 注意事项: + /// - 只能确认自己的工资条 + /// - 只能确认已锁定的工资条(IsLocked = 1) + /// - 已确认的工资条不能重复确认 + /// + /// 确认参数 + /// 操作结果 + /// 确认成功 + /// 参数错误或记录不存在 + [HttpPost("confirm")] + public async Task ConfirmSalary([FromBody] SalaryConfirmInput input) + { + try + { + if (string.IsNullOrWhiteSpace(input.Id) || string.IsNullOrWhiteSpace(input.EmployeeId)) + throw NCCException.Oh("工资记录ID和员工ID不能为空"); + var salary = await _db.Queryable() + .Where(s => s.Id == input.Id && s.EmployeeId == input.EmployeeId).FirstAsync(); + if (salary == null) throw NCCException.Oh("工资记录不存在或不属于该员工"); + if (salary.EmployeeConfirmStatus == 1) throw NCCException.Oh("该工资条已确认,不能重复确认"); + if (salary.IsLocked != 1) throw NCCException.Oh("该工资条尚未锁定,请等待管理员锁定后再确认"); + salary.EmployeeConfirmStatus = 1; + salary.EmployeeConfirmTime = DateTime.Now; + salary.EmployeeConfirmRemark = input.Remark; + salary.UpdateTime = DateTime.Now; + await _db.Updateable(salary).ExecuteCommandAsync(); + return "确认成功"; + } + catch (Exception ex) + { + throw NCCException.Oh($"确认工资条失败: {ex.Message}"); + } + } + + #endregion + + #region 工资锁定/解锁 + + /// + /// 批量锁定/解锁工资条 + /// + [HttpPost("lock")] + public async Task LockSalary([FromBody] SalaryLockInput input) + { + try + { + if (input == null || input.Ids == null || !input.Ids.Any()) + throw NCCException.Oh("工资记录ID列表不能为空"); + var salaries = await _db.Queryable() + .Where(s => input.Ids.Contains(s.Id)) + .ToListAsync(); + if (!salaries.Any()) + throw NCCException.Oh("未找到指定的工资记录"); + var lockedCount = 0; + var unlockedCount = 0; + var skippedCount = 0; + foreach (var salary in salaries) + { + if (salary.EmployeeConfirmStatus == 1 && !input.IsLocked) + { + skippedCount++; + continue; + } + salary.IsLocked = input.IsLocked ? 1 : 0; + salary.UpdateTime = DateTime.Now; + if (input.IsLocked) lockedCount++; else unlockedCount++; + } + await _db.Updateable(salaries).ExecuteCommandAsync(); + var action = input.IsLocked ? "锁定" : "解锁"; + var count = input.IsLocked ? lockedCount : unlockedCount; + var message = $"{action}成功:{count}条"; + if (skippedCount > 0) + message += $",跳过{skippedCount}条(已确认的记录不能解锁)"; + return message; + } + catch (Exception ex) + { + throw NCCException.Oh($"锁定/解锁工资条失败: {ex.Message}"); + } + } + + /// + /// 批量锁定当月所有工资 + /// + /// 批量锁定输入参数 + /// 锁定结果 + [HttpPost("lock-by-month")] + public async Task LockSalaryByMonth([FromBody] SalaryLockByMonthInput input) + { + try + { + if (input == null) + throw NCCException.Oh("参数不能为空"); + + if (input.Year <= 0 || input.Month <= 0 || input.Month > 12) + throw NCCException.Oh("年份和月份参数不正确"); + + var monthStr = $"{input.Year}{input.Month:D2}"; + + var salaries = await _db.Queryable() + .Where(s => s.StatisticsMonth == monthStr) + .ToListAsync(); + + if (!salaries.Any()) + throw NCCException.Oh($"未找到{input.Year}年{input.Month}月的工资记录"); + + var lockedCount = 0; + var unlockedCount = 0; + var skippedCount = 0; + var alreadyLockedCount = 0; + + foreach (var salary in salaries) + { + if (salary.EmployeeConfirmStatus == 1 && !input.IsLocked) + { + skippedCount++; + continue; + } + + if (salary.IsLocked == 1 && input.IsLocked) + { + alreadyLockedCount++; + continue; + } + + if (salary.IsLocked == 0 && !input.IsLocked) + { + alreadyLockedCount++; + continue; + } + + salary.IsLocked = input.IsLocked ? 1 : 0; + salary.UpdateTime = DateTime.Now; + + if (input.IsLocked) + lockedCount++; + else + unlockedCount++; + } + + if (lockedCount > 0 || unlockedCount > 0) + { + var salariesToUpdate = salaries.Where(s => + (input.IsLocked && s.IsLocked == 0) || + (!input.IsLocked && s.IsLocked == 1 && s.EmployeeConfirmStatus != 1) + ).ToList(); + + if (salariesToUpdate.Any()) + { + await _db.Updateable(salariesToUpdate) + .UpdateColumns(s => new { s.IsLocked, s.UpdateTime }) + .ExecuteCommandAsync(); + } + } + + var action = input.IsLocked ? "锁定" : "解锁"; + var count = input.IsLocked ? lockedCount : unlockedCount; + var message = $"{action}成功:{count}条"; + + if (alreadyLockedCount > 0) + message += $",跳过{alreadyLockedCount}条(已是{action}状态)"; + + if (skippedCount > 0) + message += $",跳过{skippedCount}条(已确认的记录不能解锁)"; + + return new + { + success = true, + message = message, + total = salaries.Count, + locked = lockedCount, + unlocked = unlockedCount, + skipped = skippedCount, + alreadyLocked = alreadyLockedCount + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "批量锁定当月工资失败"); + var action = input?.IsLocked == true ? "锁定" : "解锁"; + throw NCCException.Oh($"批量{action}当月工资失败: {ex.Message}"); + } + } + + #endregion + + #region 导入工资 + + /// + /// 从Excel导入大项目主管工资数据 + /// + /// Excel文件 + /// 导入结果 + [HttpPost("import")] + public async Task ImportSalaryFromExcel(IFormFile file) + { + try + { + if (file == null || file.Length == 0) + throw NCCException.Oh("请选择要上传的Excel文件"); + + var allowedExtensions = new[] { ".xlsx", ".xls" }; + var fileExtension = Path.GetExtension(file.FileName).ToLowerInvariant(); + if (!allowedExtensions.Contains(fileExtension)) + throw NCCException.Oh("只支持.xlsx和.xls格式的Excel文件"); + + var recordsToInsert = new List(); + var recordsToUpdate = new List(); + var errorMessages = new List(); + var successCount = 0; + var failCount = 0; + var skippedCount = 0; + + var tempFilePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString() + Path.GetExtension(file.FileName)); + try + { + using (var stream = new FileStream(tempFilePath, FileMode.Create)) + { + await file.CopyToAsync(stream); + } + + var dataTable = ExcelImportHelper.ToDataTable(tempFilePath, 0, 0); + if (dataTable.Rows.Count == 0) + throw NCCException.Oh("Excel文件中没有数据行"); + + Func ParseDecimal = (str) => + { + if (string.IsNullOrWhiteSpace(str)) return 0; + var cleaned = str.Trim().Replace(",", "").Replace(",", "").Replace("¥", "").Replace("$", "").Replace("元", "").Replace("%", "").Replace(" ", ""); + return decimal.TryParse(cleaned, out decimal result) ? result : 0; + }; + + Func ParseInt = (str) => + { + if (string.IsNullOrWhiteSpace(str)) return 0; + var cleaned = str.Trim().Replace(",", "").Replace(",", "").Replace(" ", ""); + return int.TryParse(cleaned, out int result) ? result : 0; + }; + + for (int i = 1; i < dataTable.Rows.Count; i++) + { + try + { + var row = dataTable.Rows[i]; + Func GetColumnValue = (colIndex) => colIndex < row.ItemArray.Length && row[colIndex] != null ? row[colIndex].ToString().Trim() : ""; + + var firstColumnValue = GetColumnValue(0); + bool isOldFormat = !string.IsNullOrWhiteSpace(firstColumnValue) && (firstColumnValue == "员工姓名" || (!long.TryParse(firstColumnValue, out _) && firstColumnValue.Length > 20)); + + int employeeNameIndex = isOldFormat ? 0 : 1; + int offset = isOldFormat ? 0 : 1; + + var id = isOldFormat ? "" : GetColumnValue(0); + var employeeName = GetColumnValue(employeeNameIndex); + + if (string.IsNullOrWhiteSpace(id) && string.IsNullOrWhiteSpace(employeeName)) + continue; + + if (string.IsNullOrWhiteSpace(id) && !string.IsNullOrWhiteSpace(employeeName)) + { + var matchedRecord = await _db.Queryable() + .Where(x => x.EmployeeName == employeeName) + .OrderBy(x => x.CreateTime, OrderByType.Desc) + .FirstAsync(); + if (matchedRecord != null) id = matchedRecord.Id; + } + + if (string.IsNullOrWhiteSpace(employeeName)) + { + errorMessages.Add($"第{i + 1}行:员工姓名不能为空"); + failCount++; + continue; + } + + LqMajorProjectDirectorSalaryStatisticsEntity existing = null; + if (!string.IsNullOrWhiteSpace(id)) + { + existing = await _db.Queryable() + .Where(x => x.Id == id).FirstAsync(); + + if (existing != null && (existing.IsLocked == 1 || existing.EmployeeConfirmStatus == 1)) + { + skippedCount++; + failCount++; + continue; + } + } + + var entity = existing ?? new LqMajorProjectDirectorSalaryStatisticsEntity + { + Id = string.IsNullOrWhiteSpace(id) ? YitIdHelper.NextId().ToString() : id, + EmployeeConfirmStatus = 0, + IsLocked = 0, + CreateTime = DateTime.Now, + CreateUser = "" + }; + + // Excel字段映射(大项目主管工资39列:员工姓名,员工账号,核算岗位,统计月份,是否离职,开单金额,退卡金额,总业绩,底薪,提成比例,提成金额...) + entity.EmployeeName = employeeName; + entity.EmployeeAccount = GetColumnValue(1 + offset); + entity.Position = GetColumnValue(2 + offset); + entity.StatisticsMonth = GetColumnValue(3 + offset); + entity.IsTerminated = GetColumnValue(4 + offset) == "离职" || GetColumnValue(4 + offset) == "1" ? 1 : 0; + entity.BillingAmount = ParseDecimal(GetColumnValue(5 + offset)); + entity.RefundAmount = ParseDecimal(GetColumnValue(6 + offset)); + entity.TotalPerformance = ParseDecimal(GetColumnValue(7 + offset)); + entity.BaseSalary = ParseDecimal(GetColumnValue(8 + offset)); + entity.CommissionRate = ParseDecimal(GetColumnValue(9 + offset)); + entity.CommissionAmount = ParseDecimal(GetColumnValue(10 + offset)); + entity.WorkingDays = ParseDecimal(GetColumnValue(11 + offset)); + entity.LeaveDays = ParseDecimal(GetColumnValue(12 + offset)); + entity.CalculatedGrossSalary = ParseDecimal(GetColumnValue(13 + offset)); + entity.FinalGrossSalary = ParseDecimal(GetColumnValue(14 + offset)); + entity.MonthlyTrainingSubsidy = ParseDecimal(GetColumnValue(15 + offset)); + entity.MonthlyTransportSubsidy = ParseDecimal(GetColumnValue(16 + offset)); + entity.LastMonthTrainingSubsidy = ParseDecimal(GetColumnValue(17 + offset)); + entity.LastMonthTransportSubsidy = ParseDecimal(GetColumnValue(18 + offset)); + entity.TotalSubsidy = ParseDecimal(GetColumnValue(19 + offset)); + entity.MissingCard = ParseDecimal(GetColumnValue(20 + offset)); + entity.LateArrival = ParseDecimal(GetColumnValue(21 + offset)); + entity.LeaveDeduction = ParseDecimal(GetColumnValue(22 + offset)); + entity.SocialInsuranceDeduction = ParseDecimal(GetColumnValue(23 + offset)); + entity.RewardDeduction = ParseDecimal(GetColumnValue(24 + offset)); + entity.AccommodationDeduction = ParseDecimal(GetColumnValue(25 + offset)); + entity.StudyPeriodDeduction = ParseDecimal(GetColumnValue(26 + offset)); + entity.WorkClothesDeduction = ParseDecimal(GetColumnValue(27 + offset)); + entity.TotalDeduction = ParseDecimal(GetColumnValue(28 + offset)); + entity.Bonus = ParseDecimal(GetColumnValue(29 + offset)); + entity.ReturnPhoneDeposit = ParseDecimal(GetColumnValue(30 + offset)); + entity.ReturnAccommodationDeposit = ParseDecimal(GetColumnValue(31 + offset)); + entity.LastMonthSupplement = ParseDecimal(GetColumnValue(32 + offset)); + entity.ActualSalary = ParseDecimal(GetColumnValue(33 + offset)); + entity.MonthlyPaymentStatus = GetColumnValue(34 + offset); + entity.PaidAmount = ParseDecimal(GetColumnValue(35 + offset)); + entity.PendingAmount = ParseDecimal(GetColumnValue(36 + offset)); + entity.MonthlyTotalPayment = ParseDecimal(GetColumnValue(37 + offset)); + var isLockedStr = GetColumnValue(38 + offset); + entity.IsLocked = isLockedStr == "已锁定" || isLockedStr == "1" || isLockedStr == "锁定" ? 1 : 0; + + if (existing != null) + { + entity.EmployeeId = existing.EmployeeId; + entity.StoreDetail = existing.StoreDetail; + } + else + { + if (!string.IsNullOrWhiteSpace(employeeName)) + { + var user = await _db.Queryable() + .Where(u => u.RealName == employeeName).FirstAsync(); + if (user != null) entity.EmployeeId = user.Id; + } + } + + entity.UpdateTime = DateTime.Now; + if (existing != null) recordsToUpdate.Add(entity); + else recordsToInsert.Add(entity); + successCount++; + } + catch (Exception ex) + { + errorMessages.Add($"第{i + 1}行数据处理失败: {ex.Message}"); + failCount++; + } + } + } + finally + { + if (File.Exists(tempFilePath)) File.Delete(tempFilePath); + } + + if (recordsToInsert.Any()) await _db.Insertable(recordsToInsert).ExecuteCommandAsync(); + if (recordsToUpdate.Any()) await _db.Updateable(recordsToUpdate).ExecuteCommandAsync(); + + return new + { + success = true, + message = $"导入完成:成功 {successCount} 条,失败 {failCount} 条,跳过 {skippedCount} 条(已锁定或已确认)", + successCount, + failCount, + skippedCount, + errors = errorMessages + }; + } + catch (Exception ex) + { + throw NCCException.Oh($"导入大项目主管工资数据失败: {ex.Message}"); + } + } + + #endregion } } diff --git a/netcore/src/Modularity/Extend/NCC.Extend/LqMajorProjectTeacherSalaryService.cs b/netcore/src/Modularity/Extend/NCC.Extend/LqMajorProjectTeacherSalaryService.cs index 4aebcb7..a2bb0dc 100644 --- a/netcore/src/Modularity/Extend/NCC.Extend/LqMajorProjectTeacherSalaryService.cs +++ b/netcore/src/Modularity/Extend/NCC.Extend/LqMajorProjectTeacherSalaryService.cs @@ -1,10 +1,12 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; using NCC.Common.Filter; using NCC.Common.Helper; using NCC.Dependency; using NCC.DynamicApiController; using NCC.Extend.Entitys.Dto.LqMajorProjectTeacherSalary; +using NCC.Extend.Entitys.Dto.LqSalary; using NCC.Extend.Entitys.lq_attendance_summary; using NCC.Extend.Entitys.lq_hytk_hytk; using NCC.Extend.Entitys.lq_hytk_mx; @@ -15,13 +17,17 @@ using NCC.Extend.Entitys.lq_md_major_project_teacher_assignment; using NCC.Extend.Entitys.lq_md_xdbhsj; using NCC.Extend.Entitys.lq_mdxx; using NCC.Extend.Entitys.lq_major_project_teacher_salary_statistics; +using NCC.FriendlyException; using NCC.System.Entitys.Permission; using SqlSugar; using System; using System.Collections.Generic; +using System.Data; +using System.IO; using System.Linq; using System.Threading.Tasks; using Yitter.IdGenerator; +using Microsoft.AspNetCore.Http; namespace NCC.Extend { @@ -33,13 +39,15 @@ namespace NCC.Extend public class LqMajorProjectTeacherSalaryService : IDynamicApiController, ITransient { private readonly ISqlSugarClient _db; + private readonly ILogger _logger; /// /// 初始化一个类型的新实例 /// - public LqMajorProjectTeacherSalaryService(ISqlSugarClient db) + public LqMajorProjectTeacherSalaryService(ISqlSugarClient db, ILogger logger) { _db = db; + _logger = logger; } /// @@ -52,17 +60,7 @@ namespace NCC.Extend { var monthStr = $"{input.Year}{input.Month:D2}"; - // 1. 检查当月是否已生成工资数据 - var exists = await _db.Queryable() - .AnyAsync(x => x.StatisticsMonth == monthStr); - - // 2. 如果没有数据,则进行计算 - if (!exists) - { - await CalculateMajorProjectTeacherSalary(input.Year, input.Month); - } - - // 3. 查询数据 + // 查询数据 var query = _db.Queryable() .Where(x => x.StatisticsMonth == monthStr); @@ -143,6 +141,86 @@ namespace NCC.Extend } /// + /// 通过月份和员工ID查询工资 + /// + [HttpGet("query-by-employee")] + public async Task GetSalaryByEmployee([FromQuery] SalaryQueryByEmployeeInput input) + { + if (input.Year <= 0 || input.Month <= 0 || input.Month > 12) + throw NCCException.Oh("年份和月份参数不正确"); + if (string.IsNullOrWhiteSpace(input.EmployeeId)) + throw NCCException.Oh("员工ID不能为空"); + var monthStr = $"{input.Year}{input.Month:D2}"; + var salary = await _db.Queryable() + .Where(x => x.StatisticsMonth == monthStr && x.EmployeeId == input.EmployeeId) + .Select(x => new MajorProjectTeacherSalaryOutput + { + Id = x.Id, + StatisticsMonth = x.StatisticsMonth, + StoreId = x.StoreId, + StoreName = x.StoreName, + Position = x.Position, + EmployeeName = x.EmployeeName, + EmployeeId = x.EmployeeId, + EmployeeAccount = x.EmployeeAccount, + OrderAchievement = x.OrderAchievement, + ConsumeAchievement = x.ConsumeAchievement, + RefundAchievement = x.RefundAchievement, + TotalPerformance = x.TotalPerformance, + BaseSalary = x.BaseSalary, + PerformanceCommissionRate = x.PerformanceCommissionRate, + PerformanceCommissionAmount = x.PerformanceCommissionAmount, + TotalCommission = x.TotalCommission, + HandworkFee = x.HandworkFee, + WorkingDays = x.WorkingDays, + LeaveDays = x.LeaveDays, + TransportationAllowance = x.TransportationAllowance, + LessRest = x.LessRest, + FullAttendance = x.FullAttendance, + CalculatedGrossSalary = x.CalculatedGrossSalary, + GuaranteedSalary = x.GuaranteedSalary, + GuaranteedLeaveDeduction = x.GuaranteedLeaveDeduction, + GuaranteedBaseSalary = x.GuaranteedBaseSalary, + GuaranteedSupplement = x.GuaranteedSupplement, + FinalGrossSalary = x.FinalGrossSalary, + MonthlyTrainingSubsidy = x.MonthlyTrainingSubsidy, + MonthlyTransportSubsidy = x.MonthlyTransportSubsidy, + LastMonthTrainingSubsidy = x.LastMonthTrainingSubsidy, + LastMonthTransportSubsidy = x.LastMonthTransportSubsidy, + TotalSubsidy = x.TotalSubsidy, + MissingCard = x.MissingCard, + LateArrival = x.LateArrival, + LeaveDeduction = x.LeaveDeduction, + SocialInsuranceDeduction = x.SocialInsuranceDeduction, + RewardDeduction = x.RewardDeduction, + AccommodationDeduction = x.AccommodationDeduction, + StudyPeriodDeduction = x.StudyPeriodDeduction, + WorkClothesDeduction = x.WorkClothesDeduction, + TotalDeduction = x.TotalDeduction, + Bonus = x.Bonus, + ReturnPhoneDeposit = x.ReturnPhoneDeposit, + ReturnAccommodationDeposit = x.ReturnAccommodationDeposit, + ActualSalary = x.ActualSalary, + MonthlyPaymentStatus = x.MonthlyPaymentStatus, + PaidAmount = x.PaidAmount, + PendingAmount = x.PendingAmount, + LastMonthSupplement = x.LastMonthSupplement, + MonthlyTotalPayment = x.MonthlyTotalPayment, + IsLocked = x.IsLocked, + IsTerminated = x.IsTerminated, + UpdateTime = x.UpdateTime, + StoreType = x.StoreType, + StoreCategory = x.StoreCategory, + IsNewStore = x.IsNewStore, + NewStoreProtectionStage = x.NewStoreProtectionStage + }) + .FirstAsync(); + if (salary == null) + throw NCCException.Oh($"未找到员工{input.EmployeeId}在{input.Year}年{input.Month}月的工资记录"); + return salary; + } + + /// /// 计算大项目部老师工资 /// /// 年份 @@ -418,12 +496,41 @@ namespace NCC.Extend // 5. 保存数据 if (teacherStats.Any()) { - // 先删除当月旧数据 (防止重复) - await _db.Deleteable() - .Where(x => x.StatisticsMonth == monthStr) - .ExecuteCommandAsync(); - - await _db.Insertable(teacherStats.Values.ToList()).ExecuteCommandAsync(); + var existingRecords = await _db.Queryable() + .Where(x => x.StatisticsMonth == monthStr).ToListAsync(); + var existingDict = existingRecords.Where(x => !string.IsNullOrEmpty(x.EmployeeId)) + .GroupBy(x => x.EmployeeId).ToDictionary(g => g.Key, g => g.First()); + var recordsToInsert = new List(); + var recordsToUpdate = new List(); + var skippedCount = 0; + foreach (var salary in teacherStats.Values) + { + if (existingDict.ContainsKey(salary.EmployeeId)) + { + var existing = existingDict[salary.EmployeeId]; + if (existing.IsLocked == 1 || existing.EmployeeConfirmStatus == 1) { skippedCount++; continue; } + salary.Id = existing.Id; + salary.EmployeeConfirmStatus = existing.EmployeeConfirmStatus; + salary.EmployeeConfirmTime = existing.EmployeeConfirmTime; + salary.EmployeeConfirmRemark = existing.EmployeeConfirmRemark; + salary.IsLocked = existing.IsLocked; + salary.CreateTime = existing.CreateTime; + salary.CreateUser = existing.CreateUser; + recordsToUpdate.Add(salary); + } + else + { + salary.Id = YitIdHelper.NextId().ToString(); + salary.EmployeeConfirmStatus = 0; + salary.IsLocked = 0; + salary.CreateTime = DateTime.Now; + salary.CreateUser = ""; + recordsToInsert.Add(salary); + } + } + if (recordsToInsert.Any()) await _db.Insertable(recordsToInsert).ExecuteCommandAsync(); + if (recordsToUpdate.Any()) await _db.Updateable(recordsToUpdate).ExecuteCommandAsync(); + if (skippedCount > 0) _logger.LogWarning($"计算工资时跳过了 {skippedCount} 条已锁定或已确认的记录(月份:{monthStr})"); } } @@ -450,6 +557,445 @@ namespace NCC.Extend return (2.5m, totalPerformance * 0.025m); } } + + #region 员工工资确认 + + /// + /// 员工确认工资条 + /// + /// + /// 员工确认自己的工资条,确认后工资数据不可再修改 + /// + /// 示例请求: + /// + /// { + /// "id": "工资记录ID", + /// "employeeId": "员工ID", + /// "remark": "确认备注(可选)" + /// } + /// + /// + /// 参数说明: + /// - id: 工资记录ID(必填) + /// - employeeId: 员工ID(必填) + /// - remark: 确认备注(可选) + /// + /// 注意事项: + /// - 只能确认自己的工资条 + /// - 只能确认已锁定的工资条(IsLocked = 1) + /// - 已确认的工资条不能重复确认 + /// + /// 确认参数 + /// 操作结果 + /// 确认成功 + /// 参数错误或记录不存在 + [HttpPost("confirm")] + public async Task ConfirmSalary([FromBody] SalaryConfirmInput input) + { + try + { + if (string.IsNullOrWhiteSpace(input.Id) || string.IsNullOrWhiteSpace(input.EmployeeId)) + throw NCCException.Oh("工资记录ID和员工ID不能为空"); + var salary = await _db.Queryable() + .Where(s => s.Id == input.Id && s.EmployeeId == input.EmployeeId).FirstAsync(); + if (salary == null) throw NCCException.Oh("工资记录不存在或不属于该员工"); + if (salary.EmployeeConfirmStatus == 1) throw NCCException.Oh("该工资条已确认,不能重复确认"); + if (salary.IsLocked != 1) throw NCCException.Oh("该工资条尚未锁定,请等待管理员锁定后再确认"); + salary.EmployeeConfirmStatus = 1; + salary.EmployeeConfirmTime = DateTime.Now; + salary.EmployeeConfirmRemark = input.Remark; + salary.UpdateTime = DateTime.Now; + await _db.Updateable(salary).ExecuteCommandAsync(); + return "确认成功"; + } + catch (Exception ex) + { + throw NCCException.Oh($"确认工资条失败: {ex.Message}"); + } + } + + #endregion + + #region 工资锁定/解锁 + + /// + /// 批量锁定/解锁工资条 + /// + [HttpPost("lock")] + public async Task LockSalary([FromBody] SalaryLockInput input) + { + try + { + if (input == null || input.Ids == null || !input.Ids.Any()) + throw NCCException.Oh("工资记录ID列表不能为空"); + var salaries = await _db.Queryable() + .Where(s => input.Ids.Contains(s.Id)) + .ToListAsync(); + if (!salaries.Any()) + throw NCCException.Oh("未找到指定的工资记录"); + var lockedCount = 0; + var unlockedCount = 0; + var skippedCount = 0; + foreach (var salary in salaries) + { + if (salary.EmployeeConfirmStatus == 1 && !input.IsLocked) + { + skippedCount++; + continue; + } + salary.IsLocked = input.IsLocked ? 1 : 0; + salary.UpdateTime = DateTime.Now; + if (input.IsLocked) lockedCount++; else unlockedCount++; + } + await _db.Updateable(salaries).ExecuteCommandAsync(); + var action = input.IsLocked ? "锁定" : "解锁"; + var count = input.IsLocked ? lockedCount : unlockedCount; + var message = $"{action}成功:{count}条"; + if (skippedCount > 0) + message += $",跳过{skippedCount}条(已确认的记录不能解锁)"; + return message; + } + catch (Exception ex) + { + throw NCCException.Oh($"锁定/解锁工资条失败: {ex.Message}"); + } + } + + /// + /// 批量锁定当月所有工资 + /// + /// 批量锁定输入参数 + /// 锁定结果 + [HttpPost("lock-by-month")] + public async Task LockSalaryByMonth([FromBody] SalaryLockByMonthInput input) + { + try + { + if (input == null) + throw NCCException.Oh("参数不能为空"); + + if (input.Year <= 0 || input.Month <= 0 || input.Month > 12) + throw NCCException.Oh("年份和月份参数不正确"); + + var monthStr = $"{input.Year}{input.Month:D2}"; + + var salaries = await _db.Queryable() + .Where(s => s.StatisticsMonth == monthStr) + .ToListAsync(); + + if (!salaries.Any()) + throw NCCException.Oh($"未找到{input.Year}年{input.Month}月的工资记录"); + + var lockedCount = 0; + var unlockedCount = 0; + var skippedCount = 0; + var alreadyLockedCount = 0; + + foreach (var salary in salaries) + { + if (salary.EmployeeConfirmStatus == 1 && !input.IsLocked) + { + skippedCount++; + continue; + } + + if (salary.IsLocked == 1 && input.IsLocked) + { + alreadyLockedCount++; + continue; + } + + if (salary.IsLocked == 0 && !input.IsLocked) + { + alreadyLockedCount++; + continue; + } + + salary.IsLocked = input.IsLocked ? 1 : 0; + salary.UpdateTime = DateTime.Now; + + if (input.IsLocked) + lockedCount++; + else + unlockedCount++; + } + + if (lockedCount > 0 || unlockedCount > 0) + { + var salariesToUpdate = salaries.Where(s => + (input.IsLocked && s.IsLocked == 0) || + (!input.IsLocked && s.IsLocked == 1 && s.EmployeeConfirmStatus != 1) + ).ToList(); + + if (salariesToUpdate.Any()) + { + await _db.Updateable(salariesToUpdate) + .UpdateColumns(s => new { s.IsLocked, s.UpdateTime }) + .ExecuteCommandAsync(); + } + } + + var action = input.IsLocked ? "锁定" : "解锁"; + var count = input.IsLocked ? lockedCount : unlockedCount; + var message = $"{action}成功:{count}条"; + + if (alreadyLockedCount > 0) + message += $",跳过{alreadyLockedCount}条(已是{action}状态)"; + + if (skippedCount > 0) + message += $",跳过{skippedCount}条(已确认的记录不能解锁)"; + + return new + { + success = true, + message = message, + total = salaries.Count, + locked = lockedCount, + unlocked = unlockedCount, + skipped = skippedCount, + alreadyLocked = alreadyLockedCount + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "批量锁定当月工资失败"); + var action = input?.IsLocked == true ? "锁定" : "解锁"; + throw NCCException.Oh($"批量{action}当月工资失败: {ex.Message}"); + } + } + + #endregion + + #region 导入工资 + + /// + /// 从Excel导入大项目老师工资数据 + /// + /// Excel文件 + /// 导入结果 + [HttpPost("import")] + public async Task ImportSalaryFromExcel(IFormFile file) + { + try + { + if (file == null || file.Length == 0) + throw NCCException.Oh("请选择要上传的Excel文件"); + + var allowedExtensions = new[] { ".xlsx", ".xls" }; + var fileExtension = Path.GetExtension(file.FileName).ToLowerInvariant(); + if (!allowedExtensions.Contains(fileExtension)) + throw NCCException.Oh("只支持.xlsx和.xls格式的Excel文件"); + + var recordsToInsert = new List(); + var recordsToUpdate = new List(); + var errorMessages = new List(); + var successCount = 0; + var failCount = 0; + var skippedCount = 0; + + var tempFilePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString() + Path.GetExtension(file.FileName)); + try + { + using (var stream = new FileStream(tempFilePath, FileMode.Create)) + { + await file.CopyToAsync(stream); + } + + var dataTable = ExcelImportHelper.ToDataTable(tempFilePath, 0, 0); + if (dataTable.Rows.Count == 0) + throw NCCException.Oh("Excel文件中没有数据行"); + + Func ParseDecimal = (str) => + { + if (string.IsNullOrWhiteSpace(str)) return 0; + var cleaned = str.Trim().Replace(",", "").Replace(",", "").Replace("¥", "").Replace("$", "").Replace("元", "").Replace("%", "").Replace(" ", ""); + return decimal.TryParse(cleaned, out decimal result) ? result : 0; + }; + + Func ParseInt = (str) => + { + if (string.IsNullOrWhiteSpace(str)) return 0; + var cleaned = str.Trim().Replace(",", "").Replace(",", "").Replace(" ", ""); + return int.TryParse(cleaned, out int result) ? result : 0; + }; + + for (int i = 1; i < dataTable.Rows.Count; i++) + { + try + { + var row = dataTable.Rows[i]; + Func GetColumnValue = (colIndex) => colIndex < row.ItemArray.Length && row[colIndex] != null ? row[colIndex].ToString().Trim() : ""; + + var firstColumnValue = GetColumnValue(0); + bool isOldFormat = !string.IsNullOrWhiteSpace(firstColumnValue) && (firstColumnValue == "门店名称" || (!long.TryParse(firstColumnValue, out _) && firstColumnValue.Length > 20)); + + int storeNameIndex = isOldFormat ? 0 : 1; + int employeeNameIndex = isOldFormat ? 1 : 2; + int offset = isOldFormat ? 0 : 1; + + var id = isOldFormat ? "" : GetColumnValue(0); + var employeeName = GetColumnValue(employeeNameIndex); + var storeName = GetColumnValue(storeNameIndex); + + if (string.IsNullOrWhiteSpace(id) && string.IsNullOrWhiteSpace(employeeName)) + continue; + + if (string.IsNullOrWhiteSpace(id) && !string.IsNullOrWhiteSpace(employeeName)) + { + var matchedRecord = await _db.Queryable() + .Where(x => x.EmployeeName == employeeName) + .WhereIF(!string.IsNullOrWhiteSpace(storeName), x => x.StoreName == storeName) + .OrderBy(x => x.CreateTime, OrderByType.Desc) + .FirstAsync(); + if (matchedRecord != null) id = matchedRecord.Id; + } + + if (string.IsNullOrWhiteSpace(employeeName)) + { + errorMessages.Add($"第{i + 1}行:员工姓名不能为空"); + failCount++; + continue; + } + + LqMajorProjectTeacherSalaryStatisticsEntity existing = null; + if (!string.IsNullOrWhiteSpace(id)) + { + existing = await _db.Queryable() + .Where(x => x.Id == id).FirstAsync(); + + if (existing != null && (existing.IsLocked == 1 || existing.EmployeeConfirmStatus == 1)) + { + skippedCount++; + failCount++; + continue; + } + } + + var entity = existing ?? new LqMajorProjectTeacherSalaryStatisticsEntity + { + Id = string.IsNullOrWhiteSpace(id) ? YitIdHelper.NextId().ToString() : id, + EmployeeConfirmStatus = 0, + IsLocked = 0, + CreateTime = DateTime.Now, + CreateUser = "" + }; + + // Excel字段映射(大项目老师工资49列:门店名称,员工姓名,岗位,员工账号,开单业绩,消耗业绩,退卡业绩,总业绩,业绩提成比例,业绩提成金额,提成合计,手工费,底薪...) + entity.StoreName = storeName; + entity.EmployeeName = employeeName; + entity.Position = GetColumnValue(2 + offset); + entity.EmployeeAccount = GetColumnValue(3 + offset); + entity.OrderAchievement = ParseDecimal(GetColumnValue(4 + offset)); + entity.ConsumeAchievement = ParseDecimal(GetColumnValue(5 + offset)); + entity.RefundAchievement = ParseDecimal(GetColumnValue(6 + offset)); + entity.TotalPerformance = ParseDecimal(GetColumnValue(7 + offset)); + entity.PerformanceCommissionRate = ParseDecimal(GetColumnValue(8 + offset)); + entity.PerformanceCommissionAmount = ParseDecimal(GetColumnValue(9 + offset)); + entity.TotalCommission = ParseDecimal(GetColumnValue(10 + offset)); + entity.HandworkFee = ParseDecimal(GetColumnValue(11 + offset)); + entity.BaseSalary = ParseDecimal(GetColumnValue(12 + offset)); + entity.WorkingDays = ParseDecimal(GetColumnValue(13 + offset)); + entity.LeaveDays = ParseDecimal(GetColumnValue(14 + offset)); + entity.TransportationAllowance = ParseDecimal(GetColumnValue(15 + offset)); + entity.LessRest = ParseDecimal(GetColumnValue(16 + offset)); + entity.FullAttendance = ParseDecimal(GetColumnValue(17 + offset)); + entity.CalculatedGrossSalary = ParseDecimal(GetColumnValue(18 + offset)); + entity.GuaranteedSalary = ParseDecimal(GetColumnValue(19 + offset)); + entity.GuaranteedLeaveDeduction = ParseDecimal(GetColumnValue(20 + offset)); + entity.GuaranteedBaseSalary = ParseDecimal(GetColumnValue(21 + offset)); + entity.GuaranteedSupplement = ParseDecimal(GetColumnValue(22 + offset)); + entity.FinalGrossSalary = ParseDecimal(GetColumnValue(23 + offset)); + entity.MonthlyTrainingSubsidy = ParseDecimal(GetColumnValue(24 + offset)); + entity.MonthlyTransportSubsidy = ParseDecimal(GetColumnValue(25 + offset)); + entity.LastMonthTrainingSubsidy = ParseDecimal(GetColumnValue(26 + offset)); + entity.LastMonthTransportSubsidy = ParseDecimal(GetColumnValue(27 + offset)); + entity.TotalSubsidy = ParseDecimal(GetColumnValue(28 + offset)); + entity.MissingCard = ParseDecimal(GetColumnValue(29 + offset)); + entity.LateArrival = ParseDecimal(GetColumnValue(30 + offset)); + entity.LeaveDeduction = ParseDecimal(GetColumnValue(31 + offset)); + entity.SocialInsuranceDeduction = ParseDecimal(GetColumnValue(32 + offset)); + entity.RewardDeduction = ParseDecimal(GetColumnValue(33 + offset)); + entity.AccommodationDeduction = ParseDecimal(GetColumnValue(34 + offset)); + entity.StudyPeriodDeduction = ParseDecimal(GetColumnValue(35 + offset)); + entity.WorkClothesDeduction = ParseDecimal(GetColumnValue(36 + offset)); + entity.TotalDeduction = ParseDecimal(GetColumnValue(37 + offset)); + entity.Bonus = ParseDecimal(GetColumnValue(38 + offset)); + entity.ReturnPhoneDeposit = ParseDecimal(GetColumnValue(39 + offset)); + entity.ReturnAccommodationDeposit = ParseDecimal(GetColumnValue(40 + offset)); + entity.LastMonthSupplement = ParseDecimal(GetColumnValue(41 + offset)); + entity.ActualSalary = ParseDecimal(GetColumnValue(42 + offset)); + entity.MonthlyPaymentStatus = GetColumnValue(43 + offset); + entity.PaidAmount = ParseDecimal(GetColumnValue(44 + offset)); + entity.PendingAmount = ParseDecimal(GetColumnValue(45 + offset)); + entity.MonthlyTotalPayment = ParseDecimal(GetColumnValue(46 + offset)); + entity.IsNewStore = GetColumnValue(47 + offset) == "是" ? "是" : "否"; + entity.NewStoreProtectionStage = ParseInt(GetColumnValue(48 + offset)); + + if (existing != null) + { + entity.StoreId = existing.StoreId; + entity.EmployeeId = existing.EmployeeId; + entity.StatisticsMonth = existing.StatisticsMonth; + } + else + { + if (!string.IsNullOrWhiteSpace(employeeName)) + { + var user = await _db.Queryable() + .Where(u => u.RealName == employeeName).FirstAsync(); + if (user != null) + { + entity.EmployeeId = user.Id; + if (!string.IsNullOrEmpty(user.Mdid) && string.IsNullOrWhiteSpace(storeName)) + entity.StoreId = user.Mdid; + } + + if (!string.IsNullOrWhiteSpace(storeName) && string.IsNullOrEmpty(entity.StoreId)) + { + var store = await _db.Queryable() + .Where(s => s.Dm == storeName).FirstAsync(); + if (store != null) entity.StoreId = store.Id; + } + } + } + + entity.UpdateTime = DateTime.Now; + if (existing != null) recordsToUpdate.Add(entity); + else recordsToInsert.Add(entity); + successCount++; + } + catch (Exception ex) + { + errorMessages.Add($"第{i + 1}行数据处理失败: {ex.Message}"); + failCount++; + } + } + } + finally + { + if (File.Exists(tempFilePath)) File.Delete(tempFilePath); + } + + if (recordsToInsert.Any()) await _db.Insertable(recordsToInsert).ExecuteCommandAsync(); + if (recordsToUpdate.Any()) await _db.Updateable(recordsToUpdate).ExecuteCommandAsync(); + + return new + { + success = true, + message = $"导入完成:成功 {successCount} 条,失败 {failCount} 条,跳过 {skippedCount} 条(已锁定或已确认)", + successCount, + failCount, + skippedCount, + errors = errorMessages + }; + } + catch (Exception ex) + { + throw NCCException.Oh($"导入大项目老师工资数据失败: {ex.Message}"); + } + } + + #endregion } } diff --git a/netcore/src/Modularity/Extend/NCC.Extend/LqSalaryService.cs b/netcore/src/Modularity/Extend/NCC.Extend/LqSalaryService.cs index 4a22406..44f0635 100644 --- a/netcore/src/Modularity/Extend/NCC.Extend/LqSalaryService.cs +++ b/netcore/src/Modularity/Extend/NCC.Extend/LqSalaryService.cs @@ -1,11 +1,15 @@ using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; using NCC.Common.Enum; using NCC.Common.Filter; using NCC.Common.Helper; using NCC.Dependency; using NCC.DynamicApiController; using NCC.Extend.Entitys.Dto.LqSalary; +using NCC.FriendlyException; +using System.IO; using Yitter.IdGenerator; using NCC.Extend.Entitys.lq_attendance_summary; using NCC.Extend.Entitys.lq_jinsanjiao_user; @@ -38,13 +42,15 @@ namespace NCC.Extend public class LqSalaryService : IDynamicApiController, ITransient { private readonly ISqlSugarClient _db; + private readonly ILogger _logger; /// /// 初始化一个类型的新实例 /// - public LqSalaryService(ISqlSugarClient db) + public LqSalaryService(ISqlSugarClient db, ILogger logger) { _db = db; + _logger = logger; } /// @@ -57,17 +63,7 @@ namespace NCC.Extend { var monthStr = $"{input.Year}{input.Month:D2}"; - // 1. 检查当月是否已生成工资数据 - var exists = await _db.Queryable() - .AnyAsync(x => x.StatisticsMonth == monthStr); - - // 2. 如果没有数据,则进行计算 - if (!exists) - { - await CalculateHealthCoachSalary(input.Year, input.Month); - } - - // 3. 查询数据 + // 查询数据 var query = _db.Queryable() .Where(x => x.StatisticsMonth == monthStr); @@ -178,6 +174,146 @@ namespace NCC.Extend } /// + /// 通过月份和员工ID查询工资 + /// + /// + /// 根据年份、月份和员工ID查询对应员工的工资记录 + /// + /// 示例请求: + /// ``` + /// GET /api/Extend/lqsalary/query-by-employee?Year=2026&Month=1&EmployeeId=员工ID + /// ``` + /// + /// 参数说明: + /// - Year: 年份(必填) + /// - Month: 月份(必填) + /// - EmployeeId: 员工ID(必填) + /// + /// 查询参数 + /// 工资记录详情 + /// 查询成功,返回工资记录 + /// 未找到对应的工资记录 + [HttpGet("query-by-employee")] + public async Task GetSalaryByEmployee([FromQuery] SalaryQueryByEmployeeInput input) + { + if (input.Year <= 0 || input.Month <= 0 || input.Month > 12) + { + throw NCCException.Oh("年份和月份参数不正确"); + } + + if (string.IsNullOrWhiteSpace(input.EmployeeId)) + { + throw NCCException.Oh("员工ID不能为空"); + } + + var monthStr = $"{input.Year}{input.Month:D2}"; + + var salary = await _db.Queryable() + .Where(x => x.StatisticsMonth == monthStr && x.EmployeeId == input.EmployeeId) + .Select(x => new HealthCoachSalaryOutput + { + Id = x.Id, + StoreId = x.StoreId, + StoreName = x.StoreName, + EmployeeId = x.EmployeeId, + EmployeeName = x.EmployeeName, + Position = x.Position, + GoldTriangleId = x.GoldTriangleId, + GoldTriangleTeam = x.GoldTriangleTeam, + TotalPerformance = x.TotalPerformance, + BasePerformance = x.BasePerformance, + CooperationPerformance = x.CooperationPerformance, + BaseRewardPerformance = x.BaseRewardPerformance, + CooperationRewardPerformance = x.CooperationRewardPerformance, + ActualBasePerformance = x.ActualBasePerformance, + ActualCooperationPerformance = x.ActualCooperationPerformance, + RewardPerformance = x.RewardPerformance, + StoreTotalPerformance = x.StoreTotalPerformance, + TeamPerformance = x.TeamPerformance, + Percentage = x.Percentage, + NewCustomerPerformance = x.NewCustomerPerformance, + NewCustomerConversionRate = x.NewCustomerConversionRate, + NewCustomerPoint = x.NewCustomerPoint, + UpgradeCustomerCount = x.UpgradeCustomerCount, + UpgradePerformance = x.UpgradePerformance, + UpgradePoint = x.UpgradePoint, + NewCustomerCommission = x.NewCustomerPerformanceCommission, + UpgradeCommission = x.UpgradePerformanceCommission, + OtherPerformanceAdd = x.OtherPerformanceAdd, + OtherPerformanceSubtract = x.OtherPerformanceSubtract, + Consumption = x.Consumption, + ProjectCount = x.ProjectCount, + CustomerCount = x.CustomerCount, + WorkingDays = x.WorkingDays, + LeaveDays = x.LeaveDays, + CommissionPoint = x.CommissionPoint, + BasePerformanceCommission = x.BasePerformanceCommission, + CooperationPerformanceCommission = x.CooperationPerformanceCommission, + ConsultantCommission = x.ConsultantCommission, + StoreTZoneCommission = x.StoreTZoneCommission, + TotalCommission = x.TotalCommission, + HealthCoachBaseSalary = x.HealthCoachBaseSalary, + HandworkFee = x.HandworkFee, + OutherHandworkFee = x.OutherHandworkFee, + TransportationAllowance = x.TransportationAllowance, + LessRest = x.LessRest, + FullAttendance = x.FullAttendance, + CalculatedGrossSalary = x.CalculatedGrossSalary, + GuaranteedSalary = x.GuaranteedSalary, + GuaranteedLeaveDeduction = x.GuaranteedLeaveDeduction, + GuaranteedBaseSalary = x.GuaranteedBaseSalary, + GuaranteedSupplement = x.GuaranteedSupplement, + FinalGrossSalary = x.FinalGrossSalary, + MonthlyTrainingSubsidy = x.MonthlyTrainingSubsidy, + MonthlyTransportSubsidy = x.MonthlyTransportSubsidy, + LastMonthTrainingSubsidy = x.LastMonthTrainingSubsidy, + LastMonthTransportSubsidy = x.LastMonthTransportSubsidy, + TotalSubsidy = x.TotalSubsidy, + MissingCard = x.MissingCard, + LateArrival = x.LateArrival, + LeaveDeduction = x.LeaveDeduction, + SocialInsuranceDeduction = x.SocialInsuranceDeduction, + RewardDeduction = x.RewardDeduction, + AccommodationDeduction = x.AccommodationDeduction, + StudyPeriodDeduction = x.StudyPeriodDeduction, + WorkClothesDeduction = x.WorkClothesDeduction, + TotalDeduction = x.TotalDeduction, + Bonus = x.Bonus, + ReturnPhoneDeposit = x.ReturnPhoneDeposit, + ReturnAccommodationDeposit = x.ReturnAccommodationDeposit, + ActualSalary = x.ActualSalary, + MonthlyPaymentStatus = x.MonthlyPaymentStatus, + PaidAmount = x.PaidAmount, + PendingAmount = x.PendingAmount, + LastMonthSupplement = x.LastMonthSupplement, + MonthlyTotalPayment = x.MonthlyTotalPayment, + StatisticsMonth = x.StatisticsMonth, + IsLocked = x.IsLocked, + CreateTime = x.CreateTime, + CreateUser = x.CreateUser, + UpdateTime = x.UpdateTime, + UpdateUser = x.UpdateUser, + IsNewStore = x.IsNewStore, + NewStoreProtectionStage = x.NewStoreProtectionStage, + StoreType = x.StoreType, + StoreCategory = x.StoreCategory, + DailyAverageConsumption = x.DailyAverageConsumption, + DailyAverageProjectCount = x.DailyAverageProjectCount, + TeamTotalConsumption = x.TeamTotalConsumption + }) + .FirstAsync(); + + if (salary == null) + { + throw NCCException.Oh($"未找到员工{input.EmployeeId}在{input.Year}年{input.Month}月的工资记录"); + } + + return salary; + } + + #region 计算工资 + + /// /// 计算健康师工资 /// /// 年份 @@ -710,7 +846,7 @@ namespace NCC.Extend // 获取战队人数 var teamMemberList = employeeStats.Values.Where(x => x.GoldTriangleId == salary.GoldTriangleId).ToList(); var teamMemberCount = teamMemberList.Count; - + // 单人或者一个人的战队就没有顾问 if (teamMemberCount > 1) { @@ -769,13 +905,789 @@ namespace NCC.Extend // 5. 保存数据 if (employeeStats.Any()) { - // 先删除当月旧数据 (防止重复) - await _db.Deleteable().Where(x => x.StatisticsMonth == monthStr).ExecuteCommandAsync(); - await _db.Insertable(employeeStats.Values.ToList()).ExecuteCommandAsync(); + // 查询当月已存在的记录(用于检查是否已锁定或已确认) + var existingRecords = await _db.Queryable() + .Where(x => x.StatisticsMonth == monthStr) + .ToListAsync(); + + var existingDict = existingRecords + .Where(x => !string.IsNullOrEmpty(x.EmployeeId)) + .GroupBy(x => x.EmployeeId) + .ToDictionary(g => g.Key, g => g.First()); + + // 分离需要插入的新记录和需要更新的记录,以及需要跳过的记录 + var recordsToInsert = new List(); + var recordsToUpdate = new List(); + var skippedCount = 0; + + foreach (var salary in employeeStats.Values) + { + if (existingDict.ContainsKey(salary.EmployeeId)) + { + var existing = existingDict[salary.EmployeeId]; + // 如果已锁定或已确认,则跳过,不更新 + if (existing.IsLocked == 1 || existing.EmployeeConfirmStatus == 1) + { + skippedCount++; + continue; + } + + // 更新现有记录(保留确认状态相关字段) + salary.Id = existing.Id; + salary.EmployeeConfirmStatus = existing.EmployeeConfirmStatus; + salary.EmployeeConfirmTime = existing.EmployeeConfirmTime; + salary.EmployeeConfirmRemark = existing.EmployeeConfirmRemark; + salary.IsLocked = existing.IsLocked; // 保留锁定状态 + salary.CreateTime = existing.CreateTime; + salary.CreateUser = existing.CreateUser; + recordsToUpdate.Add(salary); + } + else + { + // 新记录 + salary.Id = YitIdHelper.NextId().ToString(); + salary.EmployeeConfirmStatus = 0; + salary.IsLocked = 0; + salary.CreateTime = DateTime.Now; + salary.CreateUser = ""; // 新记录,创建人为空或系统 + recordsToInsert.Add(salary); + } + } + + // 批量插入新记录 + if (recordsToInsert.Any()) + { + await _db.Insertable(recordsToInsert).ExecuteCommandAsync(); + } + + // 批量更新现有记录 + if (recordsToUpdate.Any()) + { + await _db.Updateable(recordsToUpdate).ExecuteCommandAsync(); + } + + if (skippedCount > 0) + { + _logger.LogWarning($"计算工资时跳过了 {skippedCount} 条已锁定或已确认的记录(月份:{monthStr})"); + } + } + } + + #endregion + + #region 员工工资确认 + + /// + /// 员工确认工资条 + /// + /// + /// 员工确认自己的工资条,确认后工资数据不可再修改 + /// + /// 示例请求: + /// + /// { + /// "id": "工资记录ID", + /// "employeeId": "员工ID", + /// "remark": "确认备注(可选)" + /// } + /// + /// + /// 参数说明: + /// - id: 工资记录ID(必填) + /// - employeeId: 员工ID(必填) + /// - remark: 确认备注(可选) + /// + /// 注意事项: + /// - 只能确认自己的工资条 + /// - 只能确认已锁定的工资条(IsLocked = 1) + /// - 已确认的工资条不能重复确认 + /// + /// 确认参数 + /// 操作结果 + /// 确认成功 + /// 参数错误或记录不存在 + [HttpPost("confirm")] + public async Task ConfirmSalary([FromBody] SalaryConfirmInput input) + { + try + { + if (string.IsNullOrWhiteSpace(input.Id)) + { + throw NCCException.Oh("工资记录ID不能为空"); + } + + if (string.IsNullOrWhiteSpace(input.EmployeeId)) + { + throw NCCException.Oh("员工ID不能为空"); + } + + // 查询工资记录 + var salary = await _db.Queryable() + .Where(s => s.Id == input.Id && s.EmployeeId == input.EmployeeId) + .FirstAsync(); + + if (salary == null) + { + throw NCCException.Oh("工资记录不存在或不属于该员工"); + } + + // 检查是否已确认 + if (salary.EmployeeConfirmStatus == 1) + { + throw NCCException.Oh("该工资条已确认,不能重复确认"); + } + + // 检查是否已锁定(员工只能确认已锁定的工资条) + if (salary.IsLocked != 1) + { + throw NCCException.Oh("该工资条尚未锁定,请等待管理员锁定后再确认"); + } + + // 更新确认状态 + salary.EmployeeConfirmStatus = 1; + salary.EmployeeConfirmTime = DateTime.Now; + salary.EmployeeConfirmRemark = input.Remark; + // 注意:IsLocked 保持为 1(因为本来就是管理员锁定的) + salary.UpdateTime = DateTime.Now; + + await _db.Updateable(salary).ExecuteCommandAsync(); + + return "确认成功"; + } + catch (Exception ex) + { + throw NCCException.Oh($"确认工资条失败: {ex.Message}"); + } + } + + #endregion + + #region 工资锁定/解锁 + + /// + /// 批量锁定/解锁工资条 + /// + /// + /// 管理员批量锁定或解锁工资条 + /// + /// 示例请求: + /// ```json + /// { + /// "ids": ["工资记录ID1", "工资记录ID2"], + /// "isLocked": true + /// } + /// ``` + /// + /// 参数说明: + /// - ids: 工资记录ID列表(必填) + /// - isLocked: 是否锁定(true=锁定,false=解锁) + /// + /// 锁定参数 + /// 操作结果 + /// 锁定/解锁成功 + /// 参数错误或记录不存在 + [HttpPost("lock")] + public async Task LockSalary([FromBody] SalaryLockInput input) + { + try + { + if (input == null || input.Ids == null || !input.Ids.Any()) + { + throw NCCException.Oh("工资记录ID列表不能为空"); + } + + // 查询工资记录 + var salaries = await _db.Queryable() + .Where(s => input.Ids.Contains(s.Id)) + .ToListAsync(); + + if (!salaries.Any()) + { + throw NCCException.Oh("未找到指定的工资记录"); + } + + var lockedCount = 0; + var unlockedCount = 0; + var skippedCount = 0; + + foreach (var salary in salaries) + { + // 如果已确认,不能解锁 + if (salary.EmployeeConfirmStatus == 1 && !input.IsLocked) + { + skippedCount++; + continue; + } + + salary.IsLocked = input.IsLocked ? 1 : 0; + salary.UpdateTime = DateTime.Now; + + if (input.IsLocked) + { + lockedCount++; + } + else + { + unlockedCount++; + } + } + + // 批量更新 + await _db.Updateable(salaries).ExecuteCommandAsync(); + + var action = input.IsLocked ? "锁定" : "解锁"; + var count = input.IsLocked ? lockedCount : unlockedCount; + var message = $"{action}成功:{count}条"; + if (skippedCount > 0) + { + message += $",跳过{skippedCount}条(已确认的记录不能解锁)"; + } + + return message; + } + catch (Exception ex) + { + throw NCCException.Oh($"锁定/解锁工资条失败: {ex.Message}"); } } /// + /// 批量锁定当月所有工资 + /// + /// + /// 批量锁定指定月份的所有健康师工资记录 + /// + /// 示例请求: + /// ```json + /// { + /// "year": 2025, + /// "month": 12, + /// "isLocked": true + /// } + /// ``` + /// + /// 批量锁定输入参数 + /// 锁定结果 + [HttpPost("lock-by-month")] + public async Task LockSalaryByMonth([FromBody] SalaryLockByMonthInput input) + { + try + { + if (input == null) + throw NCCException.Oh("参数不能为空"); + + if (input.Year <= 0 || input.Month <= 0 || input.Month > 12) + throw NCCException.Oh("年份和月份参数不正确"); + + var monthStr = $"{input.Year}{input.Month:D2}"; + + var salaries = await _db.Queryable() + .Where(s => s.StatisticsMonth == monthStr) + .ToListAsync(); + + if (!salaries.Any()) + throw NCCException.Oh($"未找到{input.Year}年{input.Month}月的工资记录"); + + var lockedCount = 0; + var unlockedCount = 0; + var skippedCount = 0; + var alreadyLockedCount = 0; + + foreach (var salary in salaries) + { + if (salary.EmployeeConfirmStatus == 1 && !input.IsLocked) + { + skippedCount++; + continue; + } + + if (salary.IsLocked == 1 && input.IsLocked) + { + alreadyLockedCount++; + continue; + } + + if (salary.IsLocked == 0 && !input.IsLocked) + { + alreadyLockedCount++; + continue; + } + + salary.IsLocked = input.IsLocked ? 1 : 0; + salary.UpdateTime = DateTime.Now; + + if (input.IsLocked) + lockedCount++; + else + unlockedCount++; + } + + if (lockedCount > 0 || unlockedCount > 0) + { + var salariesToUpdate = salaries.Where(s => + (input.IsLocked && s.IsLocked == 0) || + (!input.IsLocked && s.IsLocked == 1 && s.EmployeeConfirmStatus != 1) + ).ToList(); + + if (salariesToUpdate.Any()) + { + await _db.Updateable(salariesToUpdate) + .UpdateColumns(s => new { s.IsLocked, s.UpdateTime }) + .ExecuteCommandAsync(); + } + } + + var action = input.IsLocked ? "锁定" : "解锁"; + var count = input.IsLocked ? lockedCount : unlockedCount; + var message = $"{action}成功:{count}条"; + + if (alreadyLockedCount > 0) + message += $",跳过{alreadyLockedCount}条(已是{action}状态)"; + + if (skippedCount > 0) + message += $",跳过{skippedCount}条(已确认的记录不能解锁)"; + + return new + { + success = true, + message = message, + total = salaries.Count, + locked = lockedCount, + unlocked = unlockedCount, + skipped = skippedCount, + alreadyLocked = alreadyLockedCount + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "批量锁定当月工资失败"); + var action = input?.IsLocked == true ? "锁定" : "解锁"; + throw NCCException.Oh($"批量{action}当月工资失败: {ex.Message}"); + } + } + + #endregion + + #region 导入工资 + + /// + /// 从Excel导入健康师工资数据 + /// + /// + /// 从Excel文件导入健康师工资数据,Excel第一列必须是ID(主键) + /// + /// 导入规则: + /// 1. Excel第一列是ID(主键),如果为空则自动生成新ID + /// 2. 如果ID在数据库中存在,则更新记录(需检查是否已锁定或已确认) + /// 3. 如果ID在数据库中不存在,则新增记录 + /// 4. 已锁定(IsLocked=1)或已确认(EmployeeConfirmStatus=1)的记录不能导入覆盖 + /// + /// Excel字段顺序(第一列为ID): + /// ID, 门店名称, 员工姓名, 岗位, 金三角战队, ...(共77列) + /// + /// Excel文件 + /// 导入结果 + /// 导入成功 + /// 文件格式错误或数据验证失败 + [HttpPost("import")] + public async Task ImportSalaryFromExcel(IFormFile file) + { + try + { + if (file == null || file.Length == 0) + { + throw NCCException.Oh("请选择要上传的Excel文件"); + } + + // 检查文件格式 + var allowedExtensions = new[] { ".xlsx", ".xls" }; + var fileExtension = Path.GetExtension(file.FileName).ToLowerInvariant(); + if (!allowedExtensions.Contains(fileExtension)) + { + throw NCCException.Oh("只支持.xlsx和.xls格式的Excel文件"); + } + + var recordsToInsert = new List(); + var recordsToUpdate = new List(); + var errorMessages = new List(); + var successCount = 0; + var failCount = 0; + var skippedCount = 0; + + // 保存临时文件 + var tempFilePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString() + Path.GetExtension(file.FileName)); + try + { + using (var stream = new FileStream(tempFilePath, FileMode.Create)) + { + await file.CopyToAsync(stream); + } + + // 使用ExcelImportHelper读取Excel文件 + // 参数说明:0表示第一个工作表,0表示第一行是标题行 + var dataTable = ExcelImportHelper.ToDataTable(tempFilePath, 0, 0); + + if (dataTable.Rows.Count == 0) + { + throw NCCException.Oh("Excel文件中没有数据行"); + } + + // Excel第一列是ID,第二列开始是业务字段 + // 从第1行开始读取数据(跳过标题行) + for (int i = 1; i < dataTable.Rows.Count; i++) + { + try + { + var row = dataTable.Rows[i]; + + // 安全获取列值 + Func GetColumnValue = (colIndex) => + { + if (colIndex < row.ItemArray.Length) + { + return row[colIndex]?.ToString()?.Trim() ?? ""; + } + return ""; + }; + + // 第一列是ID(Excel第一列应该是ID) + var id = GetColumnValue(0); + + // 判断Excel格式:如果第一列是"门店名称"等中文,说明是旧格式(没有ID列) + var firstColumnValue = GetColumnValue(0); + bool isOldFormat = false; + if (!string.IsNullOrWhiteSpace(firstColumnValue) && + (firstColumnValue == "门店名称" || firstColumnValue.Contains("门店") || (!string.IsNullOrWhiteSpace(firstColumnValue) && !long.TryParse(firstColumnValue, out _) && firstColumnValue.Length > 20))) + { + // 旧格式:第一列是"门店名称"标题行或数据,不是ID + isOldFormat = true; + id = ""; // ID为空,需要根据员工姓名和门店名称匹配 + } + + // 根据Excel格式确定字段索引 + int storeNameIndex = isOldFormat ? 0 : 1; // 门店名称索引 + int employeeNameIndex = isOldFormat ? 1 : 2; // 员工姓名索引 + + // 跳过空行(ID、员工姓名都为空) + var employeeName = GetColumnValue(employeeNameIndex); + var storeName = GetColumnValue(storeNameIndex); + + if (string.IsNullOrWhiteSpace(id) && string.IsNullOrWhiteSpace(employeeName)) + { + continue; + } + + // 如果ID为空,尝试根据员工姓名和门店名称匹配现有记录的ID + if (string.IsNullOrWhiteSpace(id) && !string.IsNullOrWhiteSpace(employeeName)) + { + var matchedRecord = await _db.Queryable() + .Where(x => x.EmployeeName == employeeName) + .WhereIF(!string.IsNullOrWhiteSpace(storeName), x => x.StoreName == storeName) + .OrderBy(x => x.CreateTime, OrderByType.Desc) + .FirstAsync(); + + if (matchedRecord != null) + { + id = matchedRecord.Id; + } + } + + // 验证必填字段 + if (string.IsNullOrWhiteSpace(employeeName)) + { + errorMessages.Add($"第{i + 1}行:员工姓名不能为空"); + failCount++; + continue; + } + + // 辅助方法:清理数值字符串 + Func ParseDecimal = (str) => + { + if (string.IsNullOrWhiteSpace(str)) + return 0; + var cleaned = str.Trim() + .Replace(",", "") + .Replace(",", "") + .Replace("¥", "") + .Replace("$", "") + .Replace("元", "") + .Replace("%", "") + .Replace(" ", ""); + if (decimal.TryParse(cleaned, out decimal result)) + return result; + return 0; + }; + + Func ParseInt = (str) => + { + if (string.IsNullOrWhiteSpace(str)) + return 0; + var cleaned = str.Trim().Replace(",", "").Replace(",", "").Replace(" ", ""); + if (int.TryParse(cleaned, out int result)) + return result; + return 0; + }; + + // 如果Excel中有ID,查找现有记录 + LqSalaryStatisticsEntity existing = null; + if (!string.IsNullOrWhiteSpace(id)) + { + existing = await _db.Queryable() + .Where(x => x.Id == id) + .FirstAsync(); + + if (existing != null) + { + // 检查是否已锁定或已确认 + if (existing.IsLocked == 1) + { + errorMessages.Add($"第{i + 1}行:员工 {existing.EmployeeName} (ID: {id}) 的工资已锁定,不能导入覆盖"); + skippedCount++; + failCount++; + continue; + } + + if (existing.EmployeeConfirmStatus == 1) + { + errorMessages.Add($"第{i + 1}行:员工 {existing.EmployeeName} (ID: {id}) 的工资已确认,不能导入覆盖"); + skippedCount++; + failCount++; + continue; + } + } + } + + // 创建或更新实体 + LqSalaryStatisticsEntity entity; + if (existing != null) + { + // 更新现有记录 + entity = existing; + + // 导入后重置确认状态(如果被覆盖) + entity.EmployeeConfirmStatus = 0; + entity.EmployeeConfirmTime = null; + entity.EmployeeConfirmRemark = null; + } + else + { + // 新增记录 + entity = new LqSalaryStatisticsEntity + { + Id = string.IsNullOrWhiteSpace(id) ? YitIdHelper.NextId().ToString() : id, + EmployeeConfirmStatus = 0, + IsLocked = 0, + CreateTime = DateTime.Now, + UpdateTime = DateTime.Now + }; + } + + // 映射Excel字段到实体属性(根据Excel列顺序) + // 注意:Excel第一列是ID(索引0),第二列是门店名称(索引1),第三列是员工姓名(索引2)... + entity.StoreName = storeName; + entity.EmployeeName = employeeName; + entity.Position = GetColumnValue(2); // Excel第3列(索引2) + entity.GoldTriangleTeam = GetColumnValue(3); // Excel第4列(索引3) + // 根据Excel格式计算字段索引偏移量 + int offset = isOldFormat ? 0 : 1; // 如果是新格式(第一列是ID),所有业务字段索引+1 + + // 映射Excel字段到实体属性 + // Excel列顺序(新格式):ID, 门店名称, 员工姓名, 岗位, 金三角战队, 总业绩(5+offset), ... + entity.Position = GetColumnValue(2 + offset); // 岗位 + entity.GoldTriangleTeam = GetColumnValue(3 + offset); // 金三角战队 + entity.TotalPerformance = ParseDecimal(GetColumnValue(4 + offset)); // 总业绩 + entity.BasePerformance = ParseDecimal(GetColumnValue(5 + offset)); + entity.CooperationPerformance = ParseDecimal(GetColumnValue(6 + offset)); + // 跳过索引7+offset(奖励业绩),实际基础业绩和实际合作业绩在索引8+offset和9+offset + entity.ActualBasePerformance = ParseDecimal(GetColumnValue(8 + offset)); + entity.ActualCooperationPerformance = ParseDecimal(GetColumnValue(9 + offset)); + entity.NewCustomerPerformance = ParseDecimal(GetColumnValue(10 + offset)); + entity.UpgradePerformance = ParseDecimal(GetColumnValue(11 + offset)); + entity.BaseRewardPerformance = ParseDecimal(GetColumnValue(12 + offset)); + entity.CooperationRewardPerformance = ParseDecimal(GetColumnValue(13 + offset)); + entity.OtherPerformanceAdd = ParseDecimal(GetColumnValue(14 + offset)); + entity.OtherPerformanceSubtract = ParseDecimal(GetColumnValue(15 + offset)); + entity.StoreTotalPerformance = ParseDecimal(GetColumnValue(16 + offset)); + entity.TeamPerformance = ParseDecimal(GetColumnValue(17 + offset)); + entity.Percentage = ParseDecimal(GetColumnValue(18 + offset)); + entity.Consumption = ParseDecimal(GetColumnValue(19 + offset)); + entity.ProjectCount = ParseInt(GetColumnValue(20 + offset)); + entity.CustomerCount = ParseInt(GetColumnValue(21 + offset)); + entity.WorkingDays = ParseDecimal(GetColumnValue(22 + offset)); + entity.LeaveDays = ParseDecimal(GetColumnValue(23 + offset)); + entity.DailyAverageConsumption = ParseDecimal(GetColumnValue(24 + offset)); + entity.DailyAverageProjectCount = ParseDecimal(GetColumnValue(25 + offset)); + entity.TeamTotalConsumption = ParseDecimal(GetColumnValue(26 + offset)); + entity.NewCustomerConversionRate = ParseDecimal(GetColumnValue(27 + offset)); + entity.NewCustomerPoint = ParseDecimal(GetColumnValue(28 + offset)); + entity.NewCustomerPerformanceCommission = ParseDecimal(GetColumnValue(29 + offset)); + entity.UpgradeCustomerCount = ParseInt(GetColumnValue(30 + offset)); + entity.UpgradePoint = ParseDecimal(GetColumnValue(31 + offset)); + entity.UpgradePerformanceCommission = ParseDecimal(GetColumnValue(32 + offset)); + entity.CommissionPoint = ParseDecimal(GetColumnValue(33 + offset)); + entity.BasePerformanceCommission = ParseDecimal(GetColumnValue(34 + offset)); + entity.CooperationPerformanceCommission = ParseDecimal(GetColumnValue(35 + offset)); + entity.ConsultantCommission = ParseDecimal(GetColumnValue(36 + offset)); + entity.StoreTZoneCommission = ParseDecimal(GetColumnValue(37 + offset)); + entity.TotalCommission = ParseDecimal(GetColumnValue(38 + offset)); + entity.HealthCoachBaseSalary = ParseDecimal(GetColumnValue(39 + offset)); + entity.HandworkFee = ParseDecimal(GetColumnValue(40 + offset)); + entity.OutherHandworkFee = ParseDecimal(GetColumnValue(41 + offset)); + entity.CalculatedGrossSalary = ParseDecimal(GetColumnValue(42 + offset)); + entity.GuaranteedSalary = ParseDecimal(GetColumnValue(43 + offset)); + entity.GuaranteedLeaveDeduction = ParseDecimal(GetColumnValue(44 + offset)); + entity.GuaranteedBaseSalary = ParseDecimal(GetColumnValue(45 + offset)); + entity.GuaranteedSupplement = ParseDecimal(GetColumnValue(46 + offset)); + entity.FinalGrossSalary = ParseDecimal(GetColumnValue(47 + offset)); + entity.TransportationAllowance = ParseDecimal(GetColumnValue(48 + offset)); + entity.LessRest = ParseDecimal(GetColumnValue(49 + offset)); + entity.FullAttendance = ParseDecimal(GetColumnValue(50 + offset)); + entity.MonthlyTrainingSubsidy = ParseDecimal(GetColumnValue(51 + offset)); + entity.MonthlyTransportSubsidy = ParseDecimal(GetColumnValue(52 + offset)); + entity.LastMonthTrainingSubsidy = ParseDecimal(GetColumnValue(53 + offset)); + entity.LastMonthTransportSubsidy = ParseDecimal(GetColumnValue(54 + offset)); + entity.TotalSubsidy = ParseDecimal(GetColumnValue(55 + offset)); + entity.MissingCard = ParseDecimal(GetColumnValue(56 + offset)); + entity.LateArrival = ParseDecimal(GetColumnValue(57 + offset)); + entity.LeaveDeduction = ParseDecimal(GetColumnValue(58 + offset)); + entity.SocialInsuranceDeduction = ParseDecimal(GetColumnValue(59 + offset)); + entity.RewardDeduction = ParseDecimal(GetColumnValue(60 + offset)); + entity.AccommodationDeduction = ParseDecimal(GetColumnValue(61 + offset)); + entity.StudyPeriodDeduction = ParseDecimal(GetColumnValue(62 + offset)); + entity.WorkClothesDeduction = ParseDecimal(GetColumnValue(63 + offset)); + entity.TotalDeduction = ParseDecimal(GetColumnValue(64 + offset)); + entity.Bonus = ParseDecimal(GetColumnValue(65 + offset)); + entity.ReturnPhoneDeposit = ParseDecimal(GetColumnValue(66 + offset)); + entity.ReturnAccommodationDeposit = ParseDecimal(GetColumnValue(67 + offset)); + entity.LastMonthSupplement = ParseDecimal(GetColumnValue(68 + offset)); + entity.ActualSalary = ParseDecimal(GetColumnValue(69 + offset)); + entity.MonthlyPaymentStatus = GetColumnValue(70 + offset); + entity.PaidAmount = ParseDecimal(GetColumnValue(71 + offset)); + entity.PendingAmount = ParseDecimal(GetColumnValue(72 + offset)); + entity.MonthlyTotalPayment = ParseDecimal(GetColumnValue(73 + offset)); + + // 处理"是否新店"字段 + var isNewStoreStr = GetColumnValue(74 + offset); + entity.IsNewStore = (isNewStoreStr == "是" || isNewStoreStr == "1" || isNewStoreStr.ToLower() == "true") ? "是" : "否"; + + // 处理"新店保护阶段"字段 + var newStoreProtectionStageStr = GetColumnValue(75 + offset); + if (int.TryParse(newStoreProtectionStageStr, out int protectionStage)) + { + entity.NewStoreProtectionStage = protectionStage; + } + + // 注意:Excel中的"锁定状态"字段不应该从Excel导入,因为锁定状态应该由管理员操作 + // 导入时只读取,但不更新到数据库 + + // 处理Excel中没有的字段:StoreId、EmployeeId、StatisticsMonth等 + if (existing != null) + { + // 更新记录:保留原有的StoreId、EmployeeId、StatisticsMonth等 + entity.StoreId = existing.StoreId; + entity.EmployeeId = existing.EmployeeId; + entity.StatisticsMonth = existing.StatisticsMonth; + } + else + { + // 新增记录:尝试根据员工姓名和门店名称查找EmployeeId和StoreId + // 如果找不到,这些字段会保持为空,需要后续通过计算工资时填充 + if (!string.IsNullOrWhiteSpace(employeeName)) + { + // 尝试查找用户(通过员工姓名) + var user = await _db.Queryable() + .Where(u => u.RealName == employeeName) + .FirstAsync(); + + if (user != null) + { + entity.EmployeeId = user.Id; + + // 尝试从用户的门店ID获取 + if (!string.IsNullOrEmpty(user.Mdid) && string.IsNullOrWhiteSpace(storeName)) + { + entity.StoreId = user.Mdid; + } + } + + // 如果门店名称不为空,尝试查找门店ID + if (!string.IsNullOrWhiteSpace(storeName) && string.IsNullOrEmpty(entity.StoreId)) + { + var store = await _db.Queryable() + .Where(s => s.Dm == storeName) + .FirstAsync(); + + if (store != null) + { + entity.StoreId = store.Id; + } + } + } + + // StatisticsMonth需要从Excel文件名或其他地方获取 + // 这里暂时留空,后续可以根据业务需求补充(例如从Excel文件名中提取月份) + } + + entity.UpdateTime = DateTime.Now; + + if (existing != null) + { + recordsToUpdate.Add(entity); + } + else + { + recordsToInsert.Add(entity); + } + + successCount++; + } + catch (Exception ex) + { + errorMessages.Add($"第{i + 1}行数据处理失败: {ex.Message}"); + failCount++; + } + } + } + finally + { + // 清理临时文件 + if (File.Exists(tempFilePath)) + { + File.Delete(tempFilePath); + } + } + + // 批量插入新记录 + if (recordsToInsert.Any()) + { + await _db.Insertable(recordsToInsert).ExecuteCommandAsync(); + } + + // 批量更新现有记录 + if (recordsToUpdate.Any()) + { + await _db.Updateable(recordsToUpdate).ExecuteCommandAsync(); + } + + return new + { + success = true, + message = $"导入完成:成功 {successCount} 条,失败 {failCount} 条,跳过 {skippedCount} 条(已锁定或已确认)", + successCount = successCount, + failCount = failCount, + skippedCount = skippedCount, + errors = errorMessages + }; + } + catch (Exception ex) + { + throw NCCException.Oh($"导入健康师工资数据失败: {ex.Message}"); + } + } + + #endregion + + /// /// 计算底薪 /// private decimal CalculateBaseSalary(decimal dailyAvgConsumption, decimal dailyAvgProjectCount, int daysInMonth, decimal workingDays, bool isNewStore) diff --git a/netcore/src/Modularity/Extend/NCC.Extend/LqStoreManagerSalaryService.cs b/netcore/src/Modularity/Extend/NCC.Extend/LqStoreManagerSalaryService.cs index 75708d2..7f62b4c 100644 --- a/netcore/src/Modularity/Extend/NCC.Extend/LqStoreManagerSalaryService.cs +++ b/netcore/src/Modularity/Extend/NCC.Extend/LqStoreManagerSalaryService.cs @@ -1,9 +1,12 @@ using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; using NCC.Common.Filter; using NCC.Common.Helper; using NCC.Dependency; using NCC.DynamicApiController; +using NCC.Extend.Entitys.Dto.LqSalary; using NCC.Extend.Entitys.Dto.LqStoreManagerSalary; using NCC.Extend.Entitys.Enum; using NCC.Extend.Entitys.lq_attendance_summary; @@ -19,10 +22,13 @@ using NCC.Extend.Entitys.lq_store_expense; using NCC.Extend.Entitys.lq_store_manager_salary_statistics; using NCC.Extend.Entitys.lq_xh_hyhk; using NCC.Extend.Entitys.lq_xh_jksyj; +using NCC.FriendlyException; using NCC.System.Entitys.Permission; using SqlSugar; using System; using System.Collections.Generic; +using System.Data; +using System.IO; using System.Linq; using System.Threading.Tasks; using Yitter.IdGenerator; @@ -37,13 +43,15 @@ namespace NCC.Extend public class LqStoreManagerSalaryService : IDynamicApiController, ITransient { private readonly ISqlSugarClient _db; + private readonly ILogger _logger; /// /// 初始化一个类型的新实例 /// - public LqStoreManagerSalaryService(ISqlSugarClient db) + public LqStoreManagerSalaryService(ISqlSugarClient db, ILogger logger) { _db = db; + _logger = logger; } /// @@ -56,17 +64,7 @@ namespace NCC.Extend { var monthStr = $"{input.Year}{input.Month:D2}"; - // 1. 检查当月是否已生成工资数据 - var exists = await _db.Queryable() - .AnyAsync(x => x.StatisticsMonth == monthStr); - - // 2. 如果没有数据,则进行计算 - if (!exists) - { - await CalculateStoreManagerSalary(input.Year, input.Month); - } - - // 3. 查询数据 + // 查询数据 var query = _db.Queryable() .Where(x => x.StatisticsMonth == monthStr); @@ -134,6 +132,73 @@ namespace NCC.Extend } /// + /// 通过月份和员工ID查询工资 + /// + [HttpGet("query-by-employee")] + public async Task GetSalaryByEmployee([FromQuery] SalaryQueryByEmployeeInput input) + { + if (input.Year <= 0 || input.Month <= 0 || input.Month > 12) + throw NCCException.Oh("年份和月份参数不正确"); + if (string.IsNullOrWhiteSpace(input.EmployeeId)) + throw NCCException.Oh("员工ID不能为空"); + var monthStr = $"{input.Year}{input.Month:D2}"; + var salary = await _db.Queryable() + .Where(x => x.StatisticsMonth == monthStr && x.EmployeeId == input.EmployeeId) + .Select(x => new StoreManagerSalaryOutput + { + Id = x.Id, + StoreName = x.StoreName, + EmployeeName = x.EmployeeName, + Position = x.Position, + StoreTotalPerformance = x.StoreTotalPerformance, + StoreBillingPerformance = x.StoreBillingPerformance, + StoreRefundPerformance = x.StoreRefundPerformance, + StoreLifeline = x.StoreLifeline, + PerformanceCompletionRate = x.PerformanceCompletionRate, + PerformanceReached = x.PerformanceReached, + HeadCountReached = x.HeadCountReached, + ConsumeReached = x.ConsumeReached, + AssessmentDeduction = x.AssessmentDeduction, + UnreachedIndicatorCount = x.UnreachedIndicatorCount, + HeadCount = x.HeadCount, + TargetHeadCount = x.TargetHeadCount, + StoreConsume = x.StoreConsume, + TargetConsume = x.TargetConsume, + SalesPerformance = x.SalesPerformance, + ProductMaterial = x.ProductMaterial, + CooperationCost = x.CooperationCost, + StoreExpense = x.StoreExpense, + LaundryCost = x.LaundryCost, + GrossProfit = x.GrossProfit, + CommissionRate = x.CommissionRate, + CommissionAmount = x.CommissionAmount, + BaseSalary = x.BaseSalary, + FlagshipStoreDeduction = x.FlagshipStoreDeduction, + ActualBaseSalary = x.ActualBaseSalary, + WorkingDays = x.WorkingDays, + LeaveDays = x.LeaveDays, + GrossSalary = x.GrossSalary, + ActualSalary = x.ActualSalary, + TotalDeduction = x.TotalDeduction, + TotalSubsidy = x.TotalSubsidy, + Bonus = x.Bonus, + MonthlyPaymentStatus = x.MonthlyPaymentStatus, + PaidAmount = x.PaidAmount, + PendingAmount = x.PendingAmount, + IsLocked = x.IsLocked, + StoreType = x.StoreType, + StoreCategory = x.StoreCategory, + IsNewStore = x.IsNewStore, + NewStoreProtectionStage = x.NewStoreProtectionStage, + UpdateTime = x.UpdateTime + }) + .FirstAsync(); + if (salary == null) + throw NCCException.Oh($"未找到员工{input.EmployeeId}在{input.Year}年{input.Month}月的工资记录"); + return salary; + } + + /// /// 计算店长工资 /// /// 年份 @@ -546,12 +611,61 @@ namespace NCC.Extend // 3. 保存数据 if (storeManagerSalaryList.Any()) { - // 先删除当月旧数据 (防止重复) - await _db.Deleteable() + var existingRecords = await _db.Queryable() .Where(x => x.StatisticsMonth == monthStr) - .ExecuteCommandAsync(); + .ToListAsync(); + + var existingDict = existingRecords + .Where(x => !string.IsNullOrEmpty(x.EmployeeId)) + .GroupBy(x => x.EmployeeId) + .ToDictionary(g => g.Key, g => g.First()); + + var recordsToInsert = new List(); + var recordsToUpdate = new List(); + var skippedCount = 0; + + foreach (var salary in storeManagerSalaryList) + { + if (existingDict.ContainsKey(salary.EmployeeId)) + { + var existing = existingDict[salary.EmployeeId]; + if (existing.IsLocked == 1 || existing.EmployeeConfirmStatus == 1) + { + skippedCount++; + continue; + } + salary.Id = existing.Id; + salary.EmployeeConfirmStatus = existing.EmployeeConfirmStatus; + salary.EmployeeConfirmTime = existing.EmployeeConfirmTime; + salary.EmployeeConfirmRemark = existing.EmployeeConfirmRemark; + salary.IsLocked = existing.IsLocked; + salary.CreateTime = existing.CreateTime; + salary.CreateUser = existing.CreateUser; + recordsToUpdate.Add(salary); + } + else + { + salary.Id = YitIdHelper.NextId().ToString(); + salary.EmployeeConfirmStatus = 0; + salary.IsLocked = 0; + salary.CreateTime = DateTime.Now; + salary.CreateUser = ""; + recordsToInsert.Add(salary); + } + } - await _db.Insertable(storeManagerSalaryList).ExecuteCommandAsync(); + if (recordsToInsert.Any()) + { + await _db.Insertable(recordsToInsert).ExecuteCommandAsync(); + } + if (recordsToUpdate.Any()) + { + await _db.Updateable(recordsToUpdate).ExecuteCommandAsync(); + } + if (skippedCount > 0) + { + _logger.LogWarning($"计算工资时跳过了 {skippedCount} 条已锁定或已确认的记录(月份:{monthStr})"); + } } } @@ -628,6 +742,496 @@ namespace NCC.Extend salary.CommissionRate = commissionRate; salary.CommissionAmount = grossProfit * commissionRate; } + + #region 员工工资确认 + + /// + /// 员工确认工资条 + /// + /// + /// 员工确认自己的工资条,确认后工资数据不可再修改 + /// + /// 示例请求: + /// + /// { + /// "id": "工资记录ID", + /// "employeeId": "员工ID", + /// "remark": "确认备注(可选)" + /// } + /// + /// + /// 参数说明: + /// - id: 工资记录ID(必填) + /// - employeeId: 员工ID(必填) + /// - remark: 确认备注(可选) + /// + /// 注意事项: + /// - 只能确认自己的工资条 + /// - 只能确认已锁定的工资条(IsLocked = 1) + /// - 已确认的工资条不能重复确认 + /// + /// 确认参数 + /// 操作结果 + /// 确认成功 + /// 参数错误或记录不存在 + [HttpPost("confirm")] + public async Task ConfirmSalary([FromBody] SalaryConfirmInput input) + { + try + { + if (string.IsNullOrWhiteSpace(input.Id) || string.IsNullOrWhiteSpace(input.EmployeeId)) + { + throw NCCException.Oh("工资记录ID和员工ID不能为空"); + } + + var salary = await _db.Queryable() + .Where(s => s.Id == input.Id && s.EmployeeId == input.EmployeeId) + .FirstAsync(); + + if (salary == null) + throw NCCException.Oh("工资记录不存在或不属于该员工"); + if (salary.EmployeeConfirmStatus == 1) + throw NCCException.Oh("该工资条已确认,不能重复确认"); + if (salary.IsLocked != 1) + throw NCCException.Oh("该工资条尚未锁定,请等待管理员锁定后再确认"); + + salary.EmployeeConfirmStatus = 1; + salary.EmployeeConfirmTime = DateTime.Now; + salary.EmployeeConfirmRemark = input.Remark; + salary.UpdateTime = DateTime.Now; + await _db.Updateable(salary).ExecuteCommandAsync(); + return "确认成功"; + } + catch (Exception ex) + { + throw NCCException.Oh($"确认工资条失败: {ex.Message}"); + } + } + + #endregion + + #region 工资锁定/解锁 + + /// + /// 批量锁定/解锁工资条 + /// + [HttpPost("lock")] + public async Task LockSalary([FromBody] SalaryLockInput input) + { + try + { + if (input == null || input.Ids == null || !input.Ids.Any()) + throw NCCException.Oh("工资记录ID列表不能为空"); + var salaries = await _db.Queryable() + .Where(s => input.Ids.Contains(s.Id)) + .ToListAsync(); + if (!salaries.Any()) + throw NCCException.Oh("未找到指定的工资记录"); + var lockedCount = 0; + var unlockedCount = 0; + var skippedCount = 0; + foreach (var salary in salaries) + { + if (salary.EmployeeConfirmStatus == 1 && !input.IsLocked) + { + skippedCount++; + continue; + } + salary.IsLocked = input.IsLocked ? 1 : 0; + salary.UpdateTime = DateTime.Now; + if (input.IsLocked) lockedCount++; else unlockedCount++; + } + await _db.Updateable(salaries).ExecuteCommandAsync(); + var action = input.IsLocked ? "锁定" : "解锁"; + var count = input.IsLocked ? lockedCount : unlockedCount; + var message = $"{action}成功:{count}条"; + if (skippedCount > 0) + message += $",跳过{skippedCount}条(已确认的记录不能解锁)"; + return message; + } + catch (Exception ex) + { + throw NCCException.Oh($"锁定/解锁工资条失败: {ex.Message}"); + } + } + + /// + /// 批量锁定当月所有工资 + /// + /// + /// 批量锁定指定月份的所有店长工资记录 + /// + /// 示例请求: + /// ```json + /// { + /// "year": 2025, + /// "month": 12, + /// "isLocked": true + /// } + /// ``` + /// + /// 参数说明: + /// - year: 年份(必填) + /// - month: 月份(1-12,必填) + /// - isLocked: 是否锁定(true=锁定,false=解锁,默认true) + /// + /// 注意事项: + /// - 已确认的记录不能解锁 + /// - 已锁定的记录再次锁定会跳过 + /// + /// 批量锁定输入参数 + /// 锁定结果 + /// 锁定成功 + /// 参数错误 + [HttpPost("lock-by-month")] + public async Task LockSalaryByMonth([FromBody] SalaryLockByMonthInput input) + { + try + { + if (input == null) + throw NCCException.Oh("参数不能为空"); + + if (input.Year <= 0 || input.Month <= 0 || input.Month > 12) + throw NCCException.Oh("年份和月份参数不正确"); + + var monthStr = $"{input.Year}{input.Month:D2}"; + + // 查询当月所有工资记录 + var salaries = await _db.Queryable() + .Where(s => s.StatisticsMonth == monthStr) + .ToListAsync(); + + if (!salaries.Any()) + throw NCCException.Oh($"未找到{input.Year}年{input.Month}月的工资记录"); + + var lockedCount = 0; + var unlockedCount = 0; + var skippedCount = 0; + var alreadyLockedCount = 0; + + foreach (var salary in salaries) + { + // 如果已确认,不能解锁 + if (salary.EmployeeConfirmStatus == 1 && !input.IsLocked) + { + skippedCount++; + continue; + } + + // 如果已经是锁定状态,再次锁定时跳过 + if (salary.IsLocked == 1 && input.IsLocked) + { + alreadyLockedCount++; + continue; + } + + // 如果已经是解锁状态,再次解锁时跳过 + if (salary.IsLocked == 0 && !input.IsLocked) + { + alreadyLockedCount++; + continue; + } + + salary.IsLocked = input.IsLocked ? 1 : 0; + salary.UpdateTime = DateTime.Now; + + if (input.IsLocked) + lockedCount++; + else + unlockedCount++; + } + + // 批量更新 + if (lockedCount > 0 || unlockedCount > 0) + { + var salariesToUpdate = salaries.Where(s => + (input.IsLocked && s.IsLocked == 0) || + (!input.IsLocked && s.IsLocked == 1 && s.EmployeeConfirmStatus != 1) + ).ToList(); + + if (salariesToUpdate.Any()) + { + await _db.Updateable(salariesToUpdate) + .UpdateColumns(s => new { s.IsLocked, s.UpdateTime }) + .ExecuteCommandAsync(); + } + } + + var action = input.IsLocked ? "锁定" : "解锁"; + var count = input.IsLocked ? lockedCount : unlockedCount; + var message = $"{action}成功:{count}条"; + + if (alreadyLockedCount > 0) + message += $",跳过{alreadyLockedCount}条(已是{action}状态)"; + + if (skippedCount > 0) + message += $",跳过{skippedCount}条(已确认的记录不能解锁)"; + + return new + { + success = true, + message = message, + total = salaries.Count, + locked = lockedCount, + unlocked = unlockedCount, + skipped = skippedCount, + alreadyLocked = alreadyLockedCount + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "批量锁定当月工资失败"); + var action = input?.IsLocked == true ? "锁定" : "解锁"; + throw NCCException.Oh($"批量{action}当月工资失败: {ex.Message}"); + } + } + + #endregion + + #region 导入工资 + + /// + /// 从Excel导入店长工资数据 + /// + /// Excel文件 + /// 导入结果 + [HttpPost("import")] + public async Task ImportSalaryFromExcel(IFormFile file) + { + try + { + if (file == null || file.Length == 0) + throw NCCException.Oh("请选择要上传的Excel文件"); + + var allowedExtensions = new[] { ".xlsx", ".xls" }; + var fileExtension = Path.GetExtension(file.FileName).ToLowerInvariant(); + if (!allowedExtensions.Contains(fileExtension)) + throw NCCException.Oh("只支持.xlsx和.xls格式的Excel文件"); + + var recordsToInsert = new List(); + var recordsToUpdate = new List(); + var errorMessages = new List(); + var successCount = 0; + var failCount = 0; + var skippedCount = 0; + + var tempFilePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString() + Path.GetExtension(file.FileName)); + try + { + using (var stream = new FileStream(tempFilePath, FileMode.Create)) + { + await file.CopyToAsync(stream); + } + + var dataTable = ExcelImportHelper.ToDataTable(tempFilePath, 0, 0); + if (dataTable.Rows.Count == 0) + throw NCCException.Oh("Excel文件中没有数据行"); + + Func ParseDecimal = (str) => + { + if (string.IsNullOrWhiteSpace(str)) return 0; + var cleaned = str.Trim().Replace(",", "").Replace(",", "").Replace("¥", "").Replace("$", "").Replace("元", "").Replace("%", "").Replace(" ", ""); + return decimal.TryParse(cleaned, out decimal result) ? result : 0; + }; + + Func ParseInt = (str) => + { + if (string.IsNullOrWhiteSpace(str)) return 0; + var cleaned = str.Trim().Replace(",", "").Replace(",", "").Replace(" ", ""); + return int.TryParse(cleaned, out int result) ? result : 0; + }; + + for (int i = 1; i < dataTable.Rows.Count; i++) + { + try + { + var row = dataTable.Rows[i]; + Func GetColumnValue = (colIndex) => colIndex < row.ItemArray.Length && row[colIndex] != null ? row[colIndex].ToString().Trim() : ""; + + var firstColumnValue = GetColumnValue(0); + bool isOldFormat = !string.IsNullOrWhiteSpace(firstColumnValue) && (firstColumnValue == "门店名称" || (!long.TryParse(firstColumnValue, out _) && firstColumnValue.Length > 20)); + + int storeNameIndex = isOldFormat ? 0 : 1; + int employeeNameIndex = isOldFormat ? 1 : 2; + int offset = isOldFormat ? 0 : 1; + + var id = isOldFormat ? "" : GetColumnValue(0); + var employeeName = GetColumnValue(employeeNameIndex); + var storeName = GetColumnValue(storeNameIndex); + + if (string.IsNullOrWhiteSpace(id) && string.IsNullOrWhiteSpace(employeeName)) + continue; + + if (string.IsNullOrWhiteSpace(id) && !string.IsNullOrWhiteSpace(employeeName)) + { + var matchedRecord = await _db.Queryable() + .Where(x => x.EmployeeName == employeeName) + .WhereIF(!string.IsNullOrWhiteSpace(storeName), x => x.StoreName == storeName) + .OrderBy(x => x.CreateTime, OrderByType.Desc) + .FirstAsync(); + if (matchedRecord != null) id = matchedRecord.Id; + } + + if (string.IsNullOrWhiteSpace(employeeName)) + { + errorMessages.Add($"第{i + 1}行:员工姓名不能为空"); + failCount++; + continue; + } + + LqStoreManagerSalaryStatisticsEntity existing = null; + if (!string.IsNullOrWhiteSpace(id)) + { + existing = await _db.Queryable() + .Where(x => x.Id == id).FirstAsync(); + + if (existing != null && (existing.IsLocked == 1 || existing.EmployeeConfirmStatus == 1)) + { + skippedCount++; + failCount++; + continue; + } + } + + var entity = existing ?? new LqStoreManagerSalaryStatisticsEntity + { + Id = string.IsNullOrWhiteSpace(id) ? YitIdHelper.NextId().ToString() : id, + EmployeeConfirmStatus = 0, + IsLocked = 0, + CreateTime = DateTime.Now, + CreateUser = "" + }; + + // Excel字段映射(店长工资55列,第一列为ID) + entity.StoreName = storeName; + entity.EmployeeName = employeeName; + entity.Position = GetColumnValue(2 + offset); + entity.StoreBillingPerformance = ParseDecimal(GetColumnValue(3 + offset)); + entity.StoreRefundPerformance = ParseDecimal(GetColumnValue(4 + offset)); + entity.StoreTotalPerformance = ParseDecimal(GetColumnValue(5 + offset)); + entity.StoreLifeline = ParseDecimal(GetColumnValue(6 + offset)); + entity.PerformanceCompletionRate = ParseDecimal(GetColumnValue(7 + offset)); + entity.PerformanceReached = GetColumnValue(8 + offset); + entity.HeadCount = ParseInt(GetColumnValue(9 + offset)); + entity.TargetHeadCount = ParseDecimal(GetColumnValue(10 + offset)); + entity.HeadCountReached = GetColumnValue(11 + offset); + entity.StoreConsume = ParseDecimal(GetColumnValue(12 + offset)); + entity.TargetConsume = ParseDecimal(GetColumnValue(13 + offset)); + entity.ConsumeReached = GetColumnValue(14 + offset); + entity.UnreachedIndicatorCount = ParseInt(GetColumnValue(15 + offset)); + entity.AssessmentDeduction = ParseDecimal(GetColumnValue(16 + offset)); + entity.GrossProfit = ParseDecimal(GetColumnValue(17 + offset)); + entity.CommissionRate = ParseDecimal(GetColumnValue(18 + offset)); + entity.CommissionAmount = ParseDecimal(GetColumnValue(19 + offset)); + entity.BaseSalary = ParseDecimal(GetColumnValue(20 + offset)); + entity.ActualBaseSalary = ParseDecimal(GetColumnValue(21 + offset)); + entity.GrossSalary = ParseDecimal(GetColumnValue(22 + offset)); + entity.WorkingDays = ParseInt(GetColumnValue(23 + offset)); + entity.LeaveDays = ParseInt(GetColumnValue(24 + offset)); + // Excel中的车补(25)、少休费(26)、全勤奖(27)字段在实体类中没有对应字段,跳过 + entity.MonthlyTrainingSubsidy = ParseDecimal(GetColumnValue(28 + offset)); + entity.MonthlyTransportSubsidy = ParseDecimal(GetColumnValue(29 + offset)); + entity.LastMonthTrainingSubsidy = ParseDecimal(GetColumnValue(30 + offset)); + entity.LastMonthTransportSubsidy = ParseDecimal(GetColumnValue(31 + offset)); + entity.TotalSubsidy = ParseDecimal(GetColumnValue(32 + offset)); + entity.MissingCard = ParseDecimal(GetColumnValue(33 + offset)); + entity.LateArrival = ParseDecimal(GetColumnValue(34 + offset)); + entity.LeaveDeduction = ParseDecimal(GetColumnValue(35 + offset)); + entity.SocialInsuranceDeduction = ParseDecimal(GetColumnValue(36 + offset)); + entity.RewardDeduction = ParseDecimal(GetColumnValue(37 + offset)); + entity.AccommodationDeduction = ParseDecimal(GetColumnValue(38 + offset)); + entity.StudyPeriodDeduction = ParseDecimal(GetColumnValue(39 + offset)); + entity.WorkClothesDeduction = ParseDecimal(GetColumnValue(40 + offset)); + entity.TotalDeduction = ParseDecimal(GetColumnValue(41 + offset)); + entity.Bonus = ParseDecimal(GetColumnValue(42 + offset)); + entity.ReturnPhoneDeposit = ParseDecimal(GetColumnValue(43 + offset)); + entity.ReturnAccommodationDeposit = ParseDecimal(GetColumnValue(44 + offset)); + entity.LastMonthSupplement = ParseDecimal(GetColumnValue(45 + offset)); + entity.ActualSalary = ParseDecimal(GetColumnValue(46 + offset)); + entity.MonthlyPaymentStatus = GetColumnValue(47 + offset); + entity.PaidAmount = ParseDecimal(GetColumnValue(48 + offset)); + entity.PendingAmount = ParseDecimal(GetColumnValue(49 + offset)); + entity.MonthlyTotalPayment = ParseDecimal(GetColumnValue(50 + offset)); + // 处理门店分类(Excel中可能是"A类"、"B类"、"C类") + var storeCategoryStr = GetColumnValue(51 + offset); + if (!string.IsNullOrWhiteSpace(storeCategoryStr)) + { + if (storeCategoryStr.Contains("A") || storeCategoryStr == "1") entity.StoreCategory = 1; + else if (storeCategoryStr.Contains("B") || storeCategoryStr == "2") entity.StoreCategory = 2; + else if (storeCategoryStr.Contains("C") || storeCategoryStr == "3") entity.StoreCategory = 3; + } + entity.IsNewStore = GetColumnValue(52 + offset) == "是" ? "是" : "否"; + entity.NewStoreProtectionStage = ParseInt(GetColumnValue(53 + offset)); + // 处理锁定状态(第55列,索引54) + var isLockedStr = GetColumnValue(54 + offset); + if (isLockedStr == "已锁定" || isLockedStr == "1") entity.IsLocked = 1; + else entity.IsLocked = 0; + + if (existing != null) + { + entity.StoreId = existing.StoreId; + entity.EmployeeId = existing.EmployeeId; + entity.StatisticsMonth = existing.StatisticsMonth; + } + else + { + if (!string.IsNullOrWhiteSpace(employeeName)) + { + var user = await _db.Queryable() + .Where(u => u.RealName == employeeName).FirstAsync(); + if (user != null) + { + entity.EmployeeId = user.Id; + if (!string.IsNullOrEmpty(user.Mdid) && string.IsNullOrWhiteSpace(storeName)) + entity.StoreId = user.Mdid; + } + + if (!string.IsNullOrWhiteSpace(storeName) && string.IsNullOrEmpty(entity.StoreId)) + { + var store = await _db.Queryable() + .Where(s => s.Dm == storeName).FirstAsync(); + if (store != null) entity.StoreId = store.Id; + } + } + } + + entity.UpdateTime = DateTime.Now; + if (existing != null) recordsToUpdate.Add(entity); + else recordsToInsert.Add(entity); + successCount++; + } + catch (Exception ex) + { + errorMessages.Add($"第{i + 1}行数据处理失败: {ex.Message}"); + failCount++; + } + } + } + finally + { + if (File.Exists(tempFilePath)) File.Delete(tempFilePath); + } + + if (recordsToInsert.Any()) await _db.Insertable(recordsToInsert).ExecuteCommandAsync(); + if (recordsToUpdate.Any()) await _db.Updateable(recordsToUpdate).ExecuteCommandAsync(); + + return new + { + success = true, + message = $"导入完成:成功 {successCount} 条,失败 {failCount} 条,跳过 {skippedCount} 条(已锁定或已确认)", + successCount, + failCount, + skippedCount, + errors = errorMessages + }; + } + catch (Exception ex) + { + throw NCCException.Oh($"导入店长工资数据失败: {ex.Message}"); + } + } + + #endregion } } diff --git a/netcore/src/Modularity/Extend/NCC.Extend/LqTechGeneralManagerSalaryService.cs b/netcore/src/Modularity/Extend/NCC.Extend/LqTechGeneralManagerSalaryService.cs index 561f7af..e62c8b0 100644 --- a/netcore/src/Modularity/Extend/NCC.Extend/LqTechGeneralManagerSalaryService.cs +++ b/netcore/src/Modularity/Extend/NCC.Extend/LqTechGeneralManagerSalaryService.cs @@ -1,9 +1,11 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; using NCC.Common.Filter; using NCC.Common.Helper; using NCC.Dependency; using NCC.DynamicApiController; +using NCC.Extend.Entitys.Dto.LqSalary; using NCC.Extend.Entitys.Dto.LqTechGeneralManagerSalary; using NCC.Extend.Entitys.lq_attendance_summary; using NCC.Extend.Entitys.lq_hytk_hytk; @@ -16,14 +18,18 @@ using NCC.Extend.Entitys.lq_md_general_manager_lifeline; using NCC.Extend.Entitys.lq_mdxx; using NCC.Extend.Entitys.lq_tech_general_manager_salary_statistics; using NCC.Extend.Entitys.lq_xmzl; +using NCC.FriendlyException; using NCC.System.Entitys.Permission; using SqlSugar; using System; using System.Collections.Generic; +using System.Data; +using System.IO; using System.Linq; using System.Threading.Tasks; using Yitter.IdGenerator; using Newtonsoft.Json; +using Microsoft.AspNetCore.Http; namespace NCC.Extend { @@ -35,13 +41,15 @@ namespace NCC.Extend public class LqTechGeneralManagerSalaryService : IDynamicApiController, ITransient { private readonly ISqlSugarClient _db; + private readonly ILogger _logger; /// /// 初始化一个类型的新实例 /// - public LqTechGeneralManagerSalaryService(ISqlSugarClient db) + public LqTechGeneralManagerSalaryService(ISqlSugarClient db, ILogger logger) { _db = db; + _logger = logger; } /// @@ -54,17 +62,7 @@ namespace NCC.Extend { var monthStr = $"{input.Year}{input.Month:D2}"; - // 1. 检查当月是否已生成工资数据 - var exists = await _db.Queryable() - .AnyAsync(x => x.StatisticsMonth == monthStr); - - // 2. 如果没有数据,则进行计算 - if (!exists) - { - await CalculateTechGeneralManagerSalary(input.Year, input.Month); - } - - // 3. 查询数据 + // 查询数据 var query = _db.Queryable() .Where(x => x.StatisticsMonth == monthStr); @@ -132,6 +130,73 @@ namespace NCC.Extend } /// + /// 通过月份和员工ID查询工资 + /// + [HttpGet("query-by-employee")] + public async Task GetSalaryByEmployee([FromQuery] SalaryQueryByEmployeeInput input) + { + if (input.Year <= 0 || input.Month <= 0 || input.Month > 12) + throw NCCException.Oh("年份和月份参数不正确"); + if (string.IsNullOrWhiteSpace(input.EmployeeId)) + throw NCCException.Oh("员工ID不能为空"); + var monthStr = $"{input.Year}{input.Month:D2}"; + var salary = await _db.Queryable() + .Where(x => x.StatisticsMonth == monthStr && x.EmployeeId == input.EmployeeId) + .Select(x => new TechGeneralManagerSalaryOutput + { + Id = x.Id, + StatisticsMonth = x.StatisticsMonth, + Position = x.Position, + EmployeeName = x.EmployeeName, + EmployeeId = x.EmployeeId, + EmployeeAccount = x.EmployeeAccount, + IsTerminated = x.IsTerminated, + StoreDetail = x.StoreDetail, + TraceabilityAmount = x.TraceabilityAmount, + CellAmount = x.CellAmount, + BaseSalary = x.BaseSalary, + TraceabilityCommissionRate = x.TraceabilityCommissionRate, + TraceabilityCommissionAmount = x.TraceabilityCommissionAmount, + CellCommissionRate = x.CellCommissionRate, + CellCommissionAmount = x.CellCommissionAmount, + TotalCommission = x.TotalCommission, + WorkingDays = x.WorkingDays, + LeaveDays = x.LeaveDays, + CalculatedGrossSalary = x.CalculatedGrossSalary, + FinalGrossSalary = x.FinalGrossSalary, + MonthlyTrainingSubsidy = x.MonthlyTrainingSubsidy, + MonthlyTransportSubsidy = x.MonthlyTransportSubsidy, + LastMonthTrainingSubsidy = x.LastMonthTrainingSubsidy, + LastMonthTransportSubsidy = x.LastMonthTransportSubsidy, + TotalSubsidy = x.TotalSubsidy, + MissingCard = x.MissingCard, + LateArrival = x.LateArrival, + LeaveDeduction = x.LeaveDeduction, + SocialInsuranceDeduction = x.SocialInsuranceDeduction, + RewardDeduction = x.RewardDeduction, + AccommodationDeduction = x.AccommodationDeduction, + StudyPeriodDeduction = x.StudyPeriodDeduction, + WorkClothesDeduction = x.WorkClothesDeduction, + TotalDeduction = x.TotalDeduction, + Bonus = x.Bonus, + ReturnPhoneDeposit = x.ReturnPhoneDeposit, + ReturnAccommodationDeposit = x.ReturnAccommodationDeposit, + ActualSalary = x.ActualSalary, + MonthlyPaymentStatus = x.MonthlyPaymentStatus, + PaidAmount = x.PaidAmount, + PendingAmount = x.PendingAmount, + LastMonthSupplement = x.LastMonthSupplement, + MonthlyTotalPayment = x.MonthlyTotalPayment, + IsLocked = x.IsLocked, + UpdateTime = x.UpdateTime + }) + .FirstAsync(); + if (salary == null) + throw NCCException.Oh($"未找到员工{input.EmployeeId}在{input.Year}年{input.Month}月的工资记录"); + return salary; + } + + /// /// 计算科技部总经理工资 /// /// 年份 @@ -429,12 +494,41 @@ namespace NCC.Extend // 3. 保存数据 if (managerStats.Any()) { - // 先删除当月旧数据 (防止重复) - await _db.Deleteable() - .Where(x => x.StatisticsMonth == monthStr) - .ExecuteCommandAsync(); - - await _db.Insertable(managerStats.Values.ToList()).ExecuteCommandAsync(); + var existingRecords = await _db.Queryable() + .Where(x => x.StatisticsMonth == monthStr).ToListAsync(); + var existingDict = existingRecords.Where(x => !string.IsNullOrEmpty(x.EmployeeId)) + .GroupBy(x => x.EmployeeId).ToDictionary(g => g.Key, g => g.First()); + var recordsToInsert = new List(); + var recordsToUpdate = new List(); + var skippedCount = 0; + foreach (var salary in managerStats.Values) + { + if (existingDict.ContainsKey(salary.EmployeeId)) + { + var existing = existingDict[salary.EmployeeId]; + if (existing.IsLocked == 1 || existing.EmployeeConfirmStatus == 1) { skippedCount++; continue; } + salary.Id = existing.Id; + salary.EmployeeConfirmStatus = existing.EmployeeConfirmStatus; + salary.EmployeeConfirmTime = existing.EmployeeConfirmTime; + salary.EmployeeConfirmRemark = existing.EmployeeConfirmRemark; + salary.IsLocked = existing.IsLocked; + salary.CreateTime = existing.CreateTime; + salary.CreateUser = existing.CreateUser; + recordsToUpdate.Add(salary); + } + else + { + salary.Id = YitIdHelper.NextId().ToString(); + salary.EmployeeConfirmStatus = 0; + salary.IsLocked = 0; + salary.CreateTime = DateTime.Now; + salary.CreateUser = ""; + recordsToInsert.Add(salary); + } + } + if (recordsToInsert.Any()) await _db.Insertable(recordsToInsert).ExecuteCommandAsync(); + if (recordsToUpdate.Any()) await _db.Updateable(recordsToUpdate).ExecuteCommandAsync(); + if (skippedCount > 0) _logger.LogWarning($"计算工资时跳过了 {skippedCount} 条已锁定或已确认的记录(月份:{monthStr})"); } } @@ -530,8 +624,424 @@ namespace NCC.Extend public decimal TraceabilityAmount { get; set; } public decimal CellBillingAmount { get; set; } public decimal CellRefundAmount { get; set; } - public decimal CellAmount { get; set; } + public decimal CellAmount { get; set; } + } + + #region 员工工资确认 + + /// + /// 员工确认工资条 + /// + /// + /// 员工确认自己的工资条,确认后工资数据不可再修改 + /// + /// 示例请求: + /// + /// { + /// "id": "工资记录ID", + /// "employeeId": "员工ID", + /// "remark": "确认备注(可选)" + /// } + /// + /// + /// 参数说明: + /// - id: 工资记录ID(必填) + /// - employeeId: 员工ID(必填) + /// - remark: 确认备注(可选) + /// + /// 注意事项: + /// - 只能确认自己的工资条 + /// - 只能确认已锁定的工资条(IsLocked = 1) + /// - 已确认的工资条不能重复确认 + /// + /// 确认参数 + /// 操作结果 + /// 确认成功 + /// 参数错误或记录不存在 + [HttpPost("confirm")] + public async Task ConfirmSalary([FromBody] SalaryConfirmInput input) + { + try + { + if (string.IsNullOrWhiteSpace(input.Id) || string.IsNullOrWhiteSpace(input.EmployeeId)) + throw NCCException.Oh("工资记录ID和员工ID不能为空"); + var salary = await _db.Queryable() + .Where(s => s.Id == input.Id && s.EmployeeId == input.EmployeeId).FirstAsync(); + if (salary == null) throw NCCException.Oh("工资记录不存在或不属于该员工"); + if (salary.EmployeeConfirmStatus == 1) throw NCCException.Oh("该工资条已确认,不能重复确认"); + if (salary.IsLocked != 1) throw NCCException.Oh("该工资条尚未锁定,请等待管理员锁定后再确认"); + salary.EmployeeConfirmStatus = 1; + salary.EmployeeConfirmTime = DateTime.Now; + salary.EmployeeConfirmRemark = input.Remark; + salary.UpdateTime = DateTime.Now; + await _db.Updateable(salary).ExecuteCommandAsync(); + return "确认成功"; + } + catch (Exception ex) + { + throw NCCException.Oh($"确认工资条失败: {ex.Message}"); + } + } + + #endregion + + #region 工资锁定/解锁 + + /// + /// 批量锁定/解锁工资条 + /// + [HttpPost("lock")] + public async Task LockSalary([FromBody] SalaryLockInput input) + { + try + { + if (input == null || input.Ids == null || !input.Ids.Any()) + throw NCCException.Oh("工资记录ID列表不能为空"); + var salaries = await _db.Queryable() + .Where(s => input.Ids.Contains(s.Id)) + .ToListAsync(); + if (!salaries.Any()) + throw NCCException.Oh("未找到指定的工资记录"); + var lockedCount = 0; + var unlockedCount = 0; + var skippedCount = 0; + foreach (var salary in salaries) + { + if (salary.EmployeeConfirmStatus == 1 && !input.IsLocked) + { + skippedCount++; + continue; + } + salary.IsLocked = input.IsLocked ? 1 : 0; + salary.UpdateTime = DateTime.Now; + if (input.IsLocked) lockedCount++; else unlockedCount++; + } + await _db.Updateable(salaries).ExecuteCommandAsync(); + var action = input.IsLocked ? "锁定" : "解锁"; + var count = input.IsLocked ? lockedCount : unlockedCount; + var message = $"{action}成功:{count}条"; + if (skippedCount > 0) + message += $",跳过{skippedCount}条(已确认的记录不能解锁)"; + return message; + } + catch (Exception ex) + { + throw NCCException.Oh($"锁定/解锁工资条失败: {ex.Message}"); + } + } + + /// + /// 批量锁定当月所有工资 + /// + /// 批量锁定输入参数 + /// 锁定结果 + [HttpPost("lock-by-month")] + public async Task LockSalaryByMonth([FromBody] SalaryLockByMonthInput input) + { + try + { + if (input == null) + throw NCCException.Oh("参数不能为空"); + + if (input.Year <= 0 || input.Month <= 0 || input.Month > 12) + throw NCCException.Oh("年份和月份参数不正确"); + + var monthStr = $"{input.Year}{input.Month:D2}"; + + var salaries = await _db.Queryable() + .Where(s => s.StatisticsMonth == monthStr) + .ToListAsync(); + + if (!salaries.Any()) + throw NCCException.Oh($"未找到{input.Year}年{input.Month}月的工资记录"); + + var lockedCount = 0; + var unlockedCount = 0; + var skippedCount = 0; + var alreadyLockedCount = 0; + + foreach (var salary in salaries) + { + if (salary.EmployeeConfirmStatus == 1 && !input.IsLocked) + { + skippedCount++; + continue; + } + + if (salary.IsLocked == 1 && input.IsLocked) + { + alreadyLockedCount++; + continue; + } + + if (salary.IsLocked == 0 && !input.IsLocked) + { + alreadyLockedCount++; + continue; + } + + salary.IsLocked = input.IsLocked ? 1 : 0; + salary.UpdateTime = DateTime.Now; + + if (input.IsLocked) + lockedCount++; + else + unlockedCount++; + } + + if (lockedCount > 0 || unlockedCount > 0) + { + var salariesToUpdate = salaries.Where(s => + (input.IsLocked && s.IsLocked == 0) || + (!input.IsLocked && s.IsLocked == 1 && s.EmployeeConfirmStatus != 1) + ).ToList(); + + if (salariesToUpdate.Any()) + { + await _db.Updateable(salariesToUpdate) + .UpdateColumns(s => new { s.IsLocked, s.UpdateTime }) + .ExecuteCommandAsync(); + } + } + + var action = input.IsLocked ? "锁定" : "解锁"; + var count = input.IsLocked ? lockedCount : unlockedCount; + var message = $"{action}成功:{count}条"; + + if (alreadyLockedCount > 0) + message += $",跳过{alreadyLockedCount}条(已是{action}状态)"; + + if (skippedCount > 0) + message += $",跳过{skippedCount}条(已确认的记录不能解锁)"; + + return new + { + success = true, + message = message, + total = salaries.Count, + locked = lockedCount, + unlocked = unlockedCount, + skipped = skippedCount, + alreadyLocked = alreadyLockedCount + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "批量锁定当月工资失败"); + var action = input?.IsLocked == true ? "锁定" : "解锁"; + throw NCCException.Oh($"批量{action}当月工资失败: {ex.Message}"); + } } + + #endregion + + #region 导入工资 + + /// + /// 从Excel导入科技部总经理工资数据 + /// + /// Excel文件 + /// 导入结果 + [HttpPost("import")] + public async Task ImportSalaryFromExcel(IFormFile file) + { + try + { + if (file == null || file.Length == 0) + throw NCCException.Oh("请选择要上传的Excel文件"); + + var allowedExtensions = new[] { ".xlsx", ".xls" }; + var fileExtension = Path.GetExtension(file.FileName).ToLowerInvariant(); + if (!allowedExtensions.Contains(fileExtension)) + throw NCCException.Oh("只支持.xlsx和.xls格式的Excel文件"); + + var recordsToInsert = new List(); + var recordsToUpdate = new List(); + var errorMessages = new List(); + var successCount = 0; + var failCount = 0; + var skippedCount = 0; + + var tempFilePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString() + Path.GetExtension(file.FileName)); + try + { + using (var stream = new FileStream(tempFilePath, FileMode.Create)) + { + await file.CopyToAsync(stream); + } + + var dataTable = ExcelImportHelper.ToDataTable(tempFilePath, 0, 0); + if (dataTable.Rows.Count == 0) + throw NCCException.Oh("Excel文件中没有数据行"); + + Func ParseDecimal = (str) => + { + if (string.IsNullOrWhiteSpace(str)) return 0; + var cleaned = str.Trim().Replace(",", "").Replace(",", "").Replace("¥", "").Replace("$", "").Replace("元", "").Replace("%", "").Replace(" ", ""); + return decimal.TryParse(cleaned, out decimal result) ? result : 0; + }; + + Func ParseInt = (str) => + { + if (string.IsNullOrWhiteSpace(str)) return 0; + var cleaned = str.Trim().Replace(",", "").Replace(",", "").Replace(" ", ""); + return int.TryParse(cleaned, out int result) ? result : 0; + }; + + for (int i = 1; i < dataTable.Rows.Count; i++) + { + try + { + var row = dataTable.Rows[i]; + Func GetColumnValue = (colIndex) => colIndex < row.ItemArray.Length && row[colIndex] != null ? row[colIndex].ToString().Trim() : ""; + + var firstColumnValue = GetColumnValue(0); + bool isOldFormat = !string.IsNullOrWhiteSpace(firstColumnValue) && (firstColumnValue == "员工姓名" || (!long.TryParse(firstColumnValue, out _) && firstColumnValue.Length > 20)); + + int employeeNameIndex = isOldFormat ? 0 : 1; + int offset = isOldFormat ? 0 : 1; + + var id = isOldFormat ? "" : GetColumnValue(0); + var employeeName = GetColumnValue(employeeNameIndex); + + if (string.IsNullOrWhiteSpace(id) && string.IsNullOrWhiteSpace(employeeName)) + continue; + + if (string.IsNullOrWhiteSpace(id) && !string.IsNullOrWhiteSpace(employeeName)) + { + var matchedRecord = await _db.Queryable() + .Where(x => x.EmployeeName == employeeName) + .OrderBy(x => x.CreateTime, OrderByType.Desc) + .FirstAsync(); + if (matchedRecord != null) id = matchedRecord.Id; + } + + if (string.IsNullOrWhiteSpace(employeeName)) + { + errorMessages.Add($"第{i + 1}行:员工姓名不能为空"); + failCount++; + continue; + } + + LqTechGeneralManagerSalaryStatisticsEntity existing = null; + if (!string.IsNullOrWhiteSpace(id)) + { + existing = await _db.Queryable() + .Where(x => x.Id == id).FirstAsync(); + + if (existing != null && (existing.IsLocked == 1 || existing.EmployeeConfirmStatus == 1)) + { + skippedCount++; + failCount++; + continue; + } + } + + var entity = existing ?? new LqTechGeneralManagerSalaryStatisticsEntity + { + Id = string.IsNullOrWhiteSpace(id) ? YitIdHelper.NextId().ToString() : id, + EmployeeConfirmStatus = 0, + IsLocked = 0, + CreateTime = DateTime.Now, + CreateUser = "" + }; + + // Excel字段映射(科技部总经理工资41列:员工姓名,员工账号,核算岗位,统计月份,是否离职,溯源金额,Cell金额,底薪,溯源金额提成比例,溯源金额提成金额,Cell金额提成比例,Cell金额提成金额,提成合计...) + entity.EmployeeName = employeeName; + entity.EmployeeAccount = GetColumnValue(1 + offset); + entity.Position = GetColumnValue(2 + offset); + entity.StatisticsMonth = GetColumnValue(3 + offset); + entity.IsTerminated = GetColumnValue(4 + offset) == "离职" || GetColumnValue(4 + offset) == "1" ? 1 : 0; + entity.TraceabilityAmount = ParseDecimal(GetColumnValue(5 + offset)); + entity.CellAmount = ParseDecimal(GetColumnValue(6 + offset)); + entity.BaseSalary = ParseDecimal(GetColumnValue(7 + offset)); + entity.TraceabilityCommissionRate = ParseDecimal(GetColumnValue(8 + offset)); + entity.TraceabilityCommissionAmount = ParseDecimal(GetColumnValue(9 + offset)); + entity.CellCommissionRate = ParseDecimal(GetColumnValue(10 + offset)); + entity.CellCommissionAmount = ParseDecimal(GetColumnValue(11 + offset)); + entity.TotalCommission = ParseDecimal(GetColumnValue(12 + offset)); + entity.WorkingDays = ParseDecimal(GetColumnValue(13 + offset)); + entity.LeaveDays = ParseDecimal(GetColumnValue(14 + offset)); + entity.CalculatedGrossSalary = ParseDecimal(GetColumnValue(15 + offset)); + entity.FinalGrossSalary = ParseDecimal(GetColumnValue(16 + offset)); + entity.MonthlyTrainingSubsidy = ParseDecimal(GetColumnValue(17 + offset)); + entity.MonthlyTransportSubsidy = ParseDecimal(GetColumnValue(18 + offset)); + entity.LastMonthTrainingSubsidy = ParseDecimal(GetColumnValue(19 + offset)); + entity.LastMonthTransportSubsidy = ParseDecimal(GetColumnValue(20 + offset)); + entity.TotalSubsidy = ParseDecimal(GetColumnValue(21 + offset)); + entity.MissingCard = ParseDecimal(GetColumnValue(22 + offset)); + entity.LateArrival = ParseDecimal(GetColumnValue(23 + offset)); + entity.LeaveDeduction = ParseDecimal(GetColumnValue(24 + offset)); + entity.SocialInsuranceDeduction = ParseDecimal(GetColumnValue(25 + offset)); + entity.RewardDeduction = ParseDecimal(GetColumnValue(26 + offset)); + entity.AccommodationDeduction = ParseDecimal(GetColumnValue(27 + offset)); + entity.StudyPeriodDeduction = ParseDecimal(GetColumnValue(28 + offset)); + entity.WorkClothesDeduction = ParseDecimal(GetColumnValue(29 + offset)); + entity.TotalDeduction = ParseDecimal(GetColumnValue(30 + offset)); + entity.Bonus = ParseDecimal(GetColumnValue(31 + offset)); + entity.ReturnPhoneDeposit = ParseDecimal(GetColumnValue(32 + offset)); + entity.ReturnAccommodationDeposit = ParseDecimal(GetColumnValue(33 + offset)); + entity.LastMonthSupplement = ParseDecimal(GetColumnValue(34 + offset)); + entity.ActualSalary = ParseDecimal(GetColumnValue(35 + offset)); + entity.MonthlyPaymentStatus = GetColumnValue(36 + offset); + entity.PaidAmount = ParseDecimal(GetColumnValue(37 + offset)); + entity.PendingAmount = ParseDecimal(GetColumnValue(38 + offset)); + entity.MonthlyTotalPayment = ParseDecimal(GetColumnValue(39 + offset)); + var isLockedStr = GetColumnValue(40 + offset); + entity.IsLocked = isLockedStr == "已锁定" || isLockedStr == "1" || isLockedStr == "锁定" ? 1 : 0; + + if (existing != null) + { + entity.EmployeeId = existing.EmployeeId; + entity.StoreDetail = existing.StoreDetail; + } + else + { + if (!string.IsNullOrWhiteSpace(employeeName)) + { + var user = await _db.Queryable() + .Where(u => u.RealName == employeeName).FirstAsync(); + if (user != null) entity.EmployeeId = user.Id; + } + } + + entity.UpdateTime = DateTime.Now; + if (existing != null) recordsToUpdate.Add(entity); + else recordsToInsert.Add(entity); + successCount++; + } + catch (Exception ex) + { + errorMessages.Add($"第{i + 1}行数据处理失败: {ex.Message}"); + failCount++; + } + } + } + finally + { + if (File.Exists(tempFilePath)) File.Delete(tempFilePath); + } + + if (recordsToInsert.Any()) await _db.Insertable(recordsToInsert).ExecuteCommandAsync(); + if (recordsToUpdate.Any()) await _db.Updateable(recordsToUpdate).ExecuteCommandAsync(); + + return new + { + success = true, + message = $"导入完成:成功 {successCount} 条,失败 {failCount} 条,跳过 {skippedCount} 条(已锁定或已确认)", + successCount, + failCount, + skippedCount, + errors = errorMessages + }; + } + catch (Exception ex) + { + throw NCCException.Oh($"导入科技部总经理工资数据失败: {ex.Message}"); + } + } + + #endregion } } diff --git a/netcore/src/Modularity/Extend/NCC.Extend/LqTechTeacherSalaryService.cs b/netcore/src/Modularity/Extend/NCC.Extend/LqTechTeacherSalaryService.cs index 77bb597..5deeff5 100644 --- a/netcore/src/Modularity/Extend/NCC.Extend/LqTechTeacherSalaryService.cs +++ b/netcore/src/Modularity/Extend/NCC.Extend/LqTechTeacherSalaryService.cs @@ -1,9 +1,12 @@ using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; using NCC.Common.Filter; using NCC.Common.Helper; using NCC.Dependency; using NCC.DynamicApiController; +using NCC.Extend.Entitys.Dto.LqSalary; using NCC.Extend.Entitys.Dto.LqTechTeacherSalary; using NCC.Extend.Entitys.lq_hytk_kjbsyj; using NCC.Extend.Entitys.lq_kd_kjbsyj; @@ -14,10 +17,13 @@ using NCC.Extend.Entitys.lq_tech_teacher_salary_statistics; using NCC.Extend.Entitys.lq_xh_hyhk; using NCC.Extend.Entitys.lq_xh_kjbsyj; using NCC.Extend.Entitys.lq_person_times_record; +using NCC.FriendlyException; using NCC.System.Entitys.Permission; using SqlSugar; using System; using System.Collections.Generic; +using System.Data; +using System.IO; using System.Linq; using System.Threading.Tasks; using Yitter.IdGenerator; @@ -32,13 +38,15 @@ namespace NCC.Extend public class LqTechTeacherSalaryService : IDynamicApiController, ITransient { private readonly ISqlSugarClient _db; + private readonly ILogger _logger; /// /// 初始化一个类型的新实例 /// - public LqTechTeacherSalaryService(ISqlSugarClient db) + public LqTechTeacherSalaryService(ISqlSugarClient db, ILogger logger) { _db = db; + _logger = logger; } /// @@ -51,17 +59,7 @@ namespace NCC.Extend { var monthStr = $"{input.Year}{input.Month:D2}"; - // 1. 检查当月是否已生成工资数据 - var exists = await _db.Queryable() - .AnyAsync(x => x.StatisticsMonth == monthStr); - - // 2. 如果没有数据,则进行计算 - if (!exists) - { - await CalculateTechTeacherSalary(input.Year, input.Month); - } - - // 3. 查询数据 + // 查询数据 var query = _db.Queryable() .Where(x => x.StatisticsMonth == monthStr); @@ -146,6 +144,90 @@ namespace NCC.Extend } /// + /// 通过月份和员工ID查询工资 + /// + [HttpGet("query-by-employee")] + public async Task GetSalaryByEmployee([FromQuery] SalaryQueryByEmployeeInput input) + { + if (input.Year <= 0 || input.Month <= 0 || input.Month > 12) + throw NCCException.Oh("年份和月份参数不正确"); + if (string.IsNullOrWhiteSpace(input.EmployeeId)) + throw NCCException.Oh("员工ID不能为空"); + var monthStr = $"{input.Year}{input.Month:D2}"; + var salary = await _db.Queryable() + .Where(x => x.StatisticsMonth == monthStr && x.EmployeeId == input.EmployeeId) + .Select(x => new TechTeacherSalaryOutput + { + Id = x.Id, + StatisticsMonth = x.StatisticsMonth, + StoreId = x.StoreId, + StoreName = x.StoreName, + Position = x.Position, + EmployeeName = x.EmployeeName, + EmployeeId = x.EmployeeId, + EmployeeAccount = x.EmployeeAccount, + OrderAchievement = x.OrderAchievement, + ConsumeAchievement = x.ConsumeAchievement, + RefundAchievement = x.RefundAchievement, + TotalPerformance = x.TotalPerformance, + ProjectCount = x.ProjectCount, + BaseSalaryLevel = x.BaseSalaryLevel, + BaseSalary = x.BaseSalary, + PerformanceCommissionRate = x.PerformanceCommissionRate, + PerformanceCommissionAmount = x.PerformanceCommissionAmount, + ConsumeCommissionRate = x.ConsumeCommissionRate, + ConsumeCommissionAmount = x.ConsumeCommissionAmount, + HandworkFee = x.HandworkFee, + WorkingDays = x.WorkingDays, + LeaveDays = x.LeaveDays, + TotalCommission = x.TotalCommission, + TransportationAllowance = x.TransportationAllowance, + LessRest = x.LessRest, + FullAttendance = x.FullAttendance, + CalculatedGrossSalary = x.CalculatedGrossSalary, + GuaranteedSalary = x.GuaranteedSalary, + GuaranteedLeaveDeduction = x.GuaranteedLeaveDeduction, + GuaranteedBaseSalary = x.GuaranteedBaseSalary, + GuaranteedSupplement = x.GuaranteedSupplement, + FinalGrossSalary = x.FinalGrossSalary, + MonthlyTrainingSubsidy = x.MonthlyTrainingSubsidy, + MonthlyTransportSubsidy = x.MonthlyTransportSubsidy, + LastMonthTrainingSubsidy = x.LastMonthTrainingSubsidy, + LastMonthTransportSubsidy = x.LastMonthTransportSubsidy, + TotalSubsidy = x.TotalSubsidy, + MissingCard = x.MissingCard, + LateArrival = x.LateArrival, + LeaveDeduction = x.LeaveDeduction, + SocialInsuranceDeduction = x.SocialInsuranceDeduction, + RewardDeduction = x.RewardDeduction, + AccommodationDeduction = x.AccommodationDeduction, + StudyPeriodDeduction = x.StudyPeriodDeduction, + WorkClothesDeduction = x.WorkClothesDeduction, + TotalDeduction = x.TotalDeduction, + Bonus = x.Bonus, + ReturnPhoneDeposit = x.ReturnPhoneDeposit, + ReturnAccommodationDeposit = x.ReturnAccommodationDeposit, + ActualSalary = x.ActualSalary, + MonthlyPaymentStatus = x.MonthlyPaymentStatus, + PaidAmount = x.PaidAmount, + PendingAmount = x.PendingAmount, + LastMonthSupplement = x.LastMonthSupplement, + MonthlyTotalPayment = x.MonthlyTotalPayment, + IsLocked = x.IsLocked, + IsTerminated = x.IsTerminated, + UpdateTime = x.UpdateTime, + StoreType = x.StoreType, + StoreCategory = x.StoreCategory, + IsNewStore = x.IsNewStore, + NewStoreProtectionStage = x.NewStoreProtectionStage + }) + .FirstAsync(); + if (salary == null) + throw NCCException.Oh($"未找到员工{input.EmployeeId}在{input.Year}年{input.Month}月的工资记录"); + return salary; + } + + /// /// 计算科技老师工资 /// /// 年份 @@ -412,12 +494,71 @@ namespace NCC.Extend // 4. 保存数据 if (techTeacherStats.Any()) { - // 先删除当月旧数据 (防止重复) - await _db.Deleteable() + // 查询当月已存在的记录(用于检查是否已锁定或已确认) + var existingRecords = await _db.Queryable() .Where(x => x.StatisticsMonth == monthStr) - .ExecuteCommandAsync(); + .ToListAsync(); + + var existingDict = existingRecords + .Where(x => !string.IsNullOrEmpty(x.EmployeeId)) + .GroupBy(x => x.EmployeeId) + .ToDictionary(g => g.Key, g => g.First()); + + // 分离需要插入的新记录和需要更新的记录,以及需要跳过的记录 + var recordsToInsert = new List(); + var recordsToUpdate = new List(); + var skippedCount = 0; + + foreach (var salary in techTeacherStats.Values) + { + if (existingDict.ContainsKey(salary.EmployeeId)) + { + var existing = existingDict[salary.EmployeeId]; + // 如果已锁定或已确认,则跳过,不更新 + if (existing.IsLocked == 1 || existing.EmployeeConfirmStatus == 1) + { + skippedCount++; + continue; // 跳过,不更新 + } + + // 更新现有记录(保留确认状态相关字段) + salary.Id = existing.Id; + salary.EmployeeConfirmStatus = existing.EmployeeConfirmStatus; + salary.EmployeeConfirmTime = existing.EmployeeConfirmTime; + salary.EmployeeConfirmRemark = existing.EmployeeConfirmRemark; + salary.IsLocked = existing.IsLocked; // 保留锁定状态 + salary.CreateTime = existing.CreateTime; + salary.CreateUser = existing.CreateUser; + recordsToUpdate.Add(salary); + } + else + { + // 新记录 + salary.Id = YitIdHelper.NextId().ToString(); + salary.EmployeeConfirmStatus = 0; + salary.IsLocked = 0; + salary.CreateTime = DateTime.Now; + salary.CreateUser = ""; // 新记录,创建人为空或系统 + recordsToInsert.Add(salary); + } + } - await _db.Insertable(techTeacherStats.Values.ToList()).ExecuteCommandAsync(); + // 批量插入新记录 + if (recordsToInsert.Any()) + { + await _db.Insertable(recordsToInsert).ExecuteCommandAsync(); + } + + // 批量更新现有记录 + if (recordsToUpdate.Any()) + { + await _db.Updateable(recordsToUpdate).ExecuteCommandAsync(); + } + + if (skippedCount > 0) + { + _logger.LogWarning($"计算工资时跳过了 {skippedCount} 条已锁定或已确认的记录(月份:{monthStr})"); + } } } @@ -770,14 +911,632 @@ namespace NCC.Extend PersonTimes = personTimesStat?.PersonTimes ?? 0m, LaborCost = consumeStat?.LaborCost ?? 0m, // 手工费只统计耗卡中的手工费 DepartmentId = teacher.OrganizeId, - DepartmentName = !string.IsNullOrEmpty(teacher.DepartmentName) - ? teacher.DepartmentName + DepartmentName = !string.IsNullOrEmpty(teacher.DepartmentName) + ? teacher.DepartmentName : (techOrganizeDict.ContainsKey(teacher.OrganizeId) ? techOrganizeDict[teacher.OrganizeId] : "") }); } return result; } + + #region 员工工资确认 + + /// + /// 员工确认工资条 + /// + /// + /// 员工确认自己的工资条,确认后工资数据不可再修改 + /// + /// 示例请求: + /// + /// { + /// "id": "工资记录ID", + /// "employeeId": "员工ID", + /// "remark": "确认备注(可选)" + /// } + /// + /// + /// 参数说明: + /// - id: 工资记录ID(必填) + /// - employeeId: 员工ID(必填) + /// - remark: 确认备注(可选) + /// + /// 注意事项: + /// - 只能确认自己的工资条 + /// - 只能确认已锁定的工资条(IsLocked = 1) + /// - 已确认的工资条不能重复确认 + /// + /// 确认参数 + /// 操作结果 + /// 确认成功 + /// 参数错误或记录不存在 + [HttpPost("confirm")] + public async Task ConfirmSalary([FromBody] SalaryConfirmInput input) + { + try + { + if (string.IsNullOrWhiteSpace(input.Id)) + { + throw NCCException.Oh("工资记录ID不能为空"); + } + + if (string.IsNullOrWhiteSpace(input.EmployeeId)) + { + throw NCCException.Oh("员工ID不能为空"); + } + + // 查询工资记录 + var salary = await _db.Queryable() + .Where(s => s.Id == input.Id && s.EmployeeId == input.EmployeeId) + .FirstAsync(); + + if (salary == null) + { + throw NCCException.Oh("工资记录不存在或不属于该员工"); + } + + // 检查是否已确认 + if (salary.EmployeeConfirmStatus == 1) + { + throw NCCException.Oh("该工资条已确认,不能重复确认"); + } + + // 检查是否已锁定(员工只能确认已锁定的工资条) + if (salary.IsLocked != 1) + { + throw NCCException.Oh("该工资条尚未锁定,请等待管理员锁定后再确认"); + } + + // 更新确认状态 + salary.EmployeeConfirmStatus = 1; + salary.EmployeeConfirmTime = DateTime.Now; + salary.EmployeeConfirmRemark = input.Remark; + // 注意:IsLocked 保持为 1(因为本来就是管理员锁定的) + salary.UpdateTime = DateTime.Now; + + await _db.Updateable(salary).ExecuteCommandAsync(); + + return "确认成功"; + } + catch (Exception ex) + { + throw NCCException.Oh($"确认工资条失败: {ex.Message}"); + } + } + + #endregion + + #region 工资锁定/解锁 + + /// + /// 批量锁定/解锁工资条 + /// + [HttpPost("lock")] + public async Task LockSalary([FromBody] SalaryLockInput input) + { + try + { + if (input == null || input.Ids == null || !input.Ids.Any()) + throw NCCException.Oh("工资记录ID列表不能为空"); + var salaries = await _db.Queryable() + .Where(s => input.Ids.Contains(s.Id)) + .ToListAsync(); + if (!salaries.Any()) + throw NCCException.Oh("未找到指定的工资记录"); + var lockedCount = 0; + var unlockedCount = 0; + var skippedCount = 0; + foreach (var salary in salaries) + { + if (salary.EmployeeConfirmStatus == 1 && !input.IsLocked) + { + skippedCount++; + continue; + } + salary.IsLocked = input.IsLocked ? 1 : 0; + salary.UpdateTime = DateTime.Now; + if (input.IsLocked) lockedCount++; else unlockedCount++; + } + await _db.Updateable(salaries).ExecuteCommandAsync(); + var action = input.IsLocked ? "锁定" : "解锁"; + var count = input.IsLocked ? lockedCount : unlockedCount; + var message = $"{action}成功:{count}条"; + if (skippedCount > 0) + message += $",跳过{skippedCount}条(已确认的记录不能解锁)"; + return message; + } + catch (Exception ex) + { + throw NCCException.Oh($"锁定/解锁工资条失败: {ex.Message}"); + } + } + + /// + /// 批量锁定当月所有工资 + /// + /// + /// 批量锁定指定月份的所有科技部老师工资记录 + /// + /// 批量锁定输入参数 + /// 锁定结果 + [HttpPost("lock-by-month")] + public async Task LockSalaryByMonth([FromBody] SalaryLockByMonthInput input) + { + try + { + if (input == null) + throw NCCException.Oh("参数不能为空"); + + if (input.Year <= 0 || input.Month <= 0 || input.Month > 12) + throw NCCException.Oh("年份和月份参数不正确"); + + var monthStr = $"{input.Year}{input.Month:D2}"; + + var salaries = await _db.Queryable() + .Where(s => s.StatisticsMonth == monthStr) + .ToListAsync(); + + if (!salaries.Any()) + throw NCCException.Oh($"未找到{input.Year}年{input.Month}月的工资记录"); + + var lockedCount = 0; + var unlockedCount = 0; + var skippedCount = 0; + var alreadyLockedCount = 0; + + foreach (var salary in salaries) + { + if (salary.EmployeeConfirmStatus == 1 && !input.IsLocked) + { + skippedCount++; + continue; + } + + if (salary.IsLocked == 1 && input.IsLocked) + { + alreadyLockedCount++; + continue; + } + + if (salary.IsLocked == 0 && !input.IsLocked) + { + alreadyLockedCount++; + continue; + } + + salary.IsLocked = input.IsLocked ? 1 : 0; + salary.UpdateTime = DateTime.Now; + + if (input.IsLocked) + lockedCount++; + else + unlockedCount++; + } + + if (lockedCount > 0 || unlockedCount > 0) + { + var salariesToUpdate = salaries.Where(s => + (input.IsLocked && s.IsLocked == 0) || + (!input.IsLocked && s.IsLocked == 1 && s.EmployeeConfirmStatus != 1) + ).ToList(); + + if (salariesToUpdate.Any()) + { + await _db.Updateable(salariesToUpdate) + .UpdateColumns(s => new { s.IsLocked, s.UpdateTime }) + .ExecuteCommandAsync(); + } + } + + var action = input.IsLocked ? "锁定" : "解锁"; + var count = input.IsLocked ? lockedCount : unlockedCount; + var message = $"{action}成功:{count}条"; + + if (alreadyLockedCount > 0) + message += $",跳过{alreadyLockedCount}条(已是{action}状态)"; + + if (skippedCount > 0) + message += $",跳过{skippedCount}条(已确认的记录不能解锁)"; + + return new + { + success = true, + message = message, + total = salaries.Count, + locked = lockedCount, + unlocked = unlockedCount, + skipped = skippedCount, + alreadyLocked = alreadyLockedCount + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "批量锁定当月工资失败"); + var action = input?.IsLocked == true ? "锁定" : "解锁"; + throw NCCException.Oh($"批量{action}当月工资失败: {ex.Message}"); + } + } + + #endregion + + #region 导入工资 + + /// + /// 从Excel导入科技部老师工资数据 + /// + /// + /// 从Excel文件导入科技部老师工资数据,Excel第一列必须是ID(主键) + /// + /// 导入规则: + /// 1. Excel第一列是ID(主键),如果为空则自动生成新ID + /// 2. 如果ID在数据库中存在,则更新记录(需检查是否已锁定或已确认) + /// 3. 如果ID在数据库中不存在,则新增记录 + /// 4. 已锁定(IsLocked=1)或已确认(EmployeeConfirmStatus=1)的记录不能导入覆盖 + /// + /// Excel字段顺序(第一列为ID): + /// ID, 门店名称, 员工姓名, 岗位, 开单业绩, ...(共54列) + /// + /// Excel文件 + /// 导入结果 + /// 导入成功 + /// 文件格式错误或数据验证失败 + [HttpPost("import")] + public async Task ImportSalaryFromExcel(IFormFile file) + { + try + { + if (file == null || file.Length == 0) + { + throw NCCException.Oh("请选择要上传的Excel文件"); + } + + // 检查文件格式 + var allowedExtensions = new[] { ".xlsx", ".xls" }; + var fileExtension = Path.GetExtension(file.FileName).ToLowerInvariant(); + if (!allowedExtensions.Contains(fileExtension)) + { + throw NCCException.Oh("只支持.xlsx和.xls格式的Excel文件"); + } + + var recordsToInsert = new List(); + var recordsToUpdate = new List(); + var errorMessages = new List(); + var successCount = 0; + var failCount = 0; + var skippedCount = 0; + + // 保存临时文件 + var tempFilePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString() + Path.GetExtension(file.FileName)); + try + { + using (var stream = new FileStream(tempFilePath, FileMode.Create)) + { + await file.CopyToAsync(stream); + } + + // 使用ExcelImportHelper读取Excel文件 + var dataTable = ExcelImportHelper.ToDataTable(tempFilePath, 0, 0); + + if (dataTable.Rows.Count == 0) + { + throw NCCException.Oh("Excel文件中没有数据行"); + } + + // Excel第一列是ID,第二列开始是业务字段 + for (int i = 1; i < dataTable.Rows.Count; i++) + { + try + { + var row = dataTable.Rows[i]; + + // 安全获取列值 + Func GetColumnValue = (colIndex) => + { + if (colIndex < row.ItemArray.Length) + { + return row[colIndex]?.ToString()?.Trim() ?? ""; + } + return ""; + }; + + // 第一列是ID + var id = GetColumnValue(0); + + // 判断Excel格式:如果第一列是"门店名称"等中文,说明是旧格式(没有ID列) + var firstColumnValue = GetColumnValue(0); + bool isOldFormat = false; + if (!string.IsNullOrWhiteSpace(firstColumnValue) && + (firstColumnValue == "门店名称" || firstColumnValue.Contains("门店") || (!string.IsNullOrWhiteSpace(firstColumnValue) && !long.TryParse(firstColumnValue, out _) && firstColumnValue.Length > 20))) + { + isOldFormat = true; + id = ""; + } + + // 根据Excel格式确定字段索引 + int storeNameIndex = isOldFormat ? 0 : 1; + int employeeNameIndex = isOldFormat ? 1 : 2; + + // 跳过空行 + var employeeName = GetColumnValue(employeeNameIndex); + var storeName = GetColumnValue(storeNameIndex); + + if (string.IsNullOrWhiteSpace(id) && string.IsNullOrWhiteSpace(employeeName)) + { + continue; + } + + // 如果ID为空,尝试根据员工姓名和门店名称匹配现有记录的ID + if (string.IsNullOrWhiteSpace(id) && !string.IsNullOrWhiteSpace(employeeName)) + { + var matchedRecord = await _db.Queryable() + .Where(x => x.EmployeeName == employeeName) + .WhereIF(!string.IsNullOrWhiteSpace(storeName), x => x.StoreName == storeName) + .OrderBy(x => x.CreateTime, OrderByType.Desc) + .FirstAsync(); + + if (matchedRecord != null) + { + id = matchedRecord.Id; + } + } + + // 验证必填字段 + if (string.IsNullOrWhiteSpace(employeeName)) + { + errorMessages.Add($"第{i + 1}行:员工姓名不能为空"); + failCount++; + continue; + } + + // 辅助方法:清理数值字符串 + Func ParseDecimal = (str) => + { + if (string.IsNullOrWhiteSpace(str)) + return 0; + var cleaned = str.Trim() + .Replace(",", "") + .Replace(",", "") + .Replace("¥", "") + .Replace("$", "") + .Replace("元", "") + .Replace("%", "") + .Replace(" ", ""); + if (decimal.TryParse(cleaned, out decimal result)) + return result; + return 0; + }; + + Func ParseInt = (str) => + { + if (string.IsNullOrWhiteSpace(str)) + return 0; + var cleaned = str.Trim().Replace(",", "").Replace(",", "").Replace(" ", ""); + if (int.TryParse(cleaned, out int result)) + return result; + return 0; + }; + + // 如果Excel中有ID,查找现有记录 + LqTechTeacherSalaryStatisticsEntity existing = null; + if (!string.IsNullOrWhiteSpace(id)) + { + existing = await _db.Queryable() + .Where(x => x.Id == id) + .FirstAsync(); + + if (existing != null) + { + // 检查是否已锁定或已确认 + if (existing.IsLocked == 1) + { + errorMessages.Add($"第{i + 1}行:员工 {existing.EmployeeName} (ID: {id}) 的工资已锁定,不能导入覆盖"); + skippedCount++; + failCount++; + continue; + } + + if (existing.EmployeeConfirmStatus == 1) + { + errorMessages.Add($"第{i + 1}行:员工 {existing.EmployeeName} (ID: {id}) 的工资已确认,不能导入覆盖"); + skippedCount++; + failCount++; + continue; + } + } + } + + // 创建或更新实体 + LqTechTeacherSalaryStatisticsEntity entity; + if (existing != null) + { + entity = existing; + entity.EmployeeConfirmStatus = 0; + entity.EmployeeConfirmTime = null; + entity.EmployeeConfirmRemark = null; + } + else + { + entity = new LqTechTeacherSalaryStatisticsEntity + { + Id = string.IsNullOrWhiteSpace(id) ? YitIdHelper.NextId().ToString() : id, + EmployeeConfirmStatus = 0, + IsLocked = 0, + CreateTime = DateTime.Now, + UpdateTime = DateTime.Now + }; + } + + // 根据Excel格式计算字段索引偏移量 + int offset = isOldFormat ? 0 : 1; + + // 映射Excel字段到实体属性(根据Excel列顺序,第一列是ID,所以业务字段从索引1开始) + // Excel列顺序:ID, 门店名称, 员工姓名, 岗位, 开单业绩, 消耗业绩, 退卡业绩, 总业绩, 项目数, 底薪档位, 底薪, ... + entity.StoreName = storeName; + entity.EmployeeName = employeeName; + entity.Position = GetColumnValue(2 + offset); + entity.OrderAchievement = ParseDecimal(GetColumnValue(3 + offset)); + entity.ConsumeAchievement = ParseDecimal(GetColumnValue(4 + offset)); + entity.RefundAchievement = ParseDecimal(GetColumnValue(5 + offset)); + entity.TotalPerformance = ParseDecimal(GetColumnValue(6 + offset)); + entity.ProjectCount = ParseDecimal(GetColumnValue(7 + offset)); + entity.BaseSalaryLevel = ParseInt(GetColumnValue(8 + offset)); + entity.BaseSalary = ParseDecimal(GetColumnValue(9 + offset)); + entity.PerformanceCommissionRate = ParseDecimal(GetColumnValue(10 + offset)); + entity.PerformanceCommissionAmount = ParseDecimal(GetColumnValue(11 + offset)); + entity.ConsumeCommissionRate = ParseDecimal(GetColumnValue(12 + offset)); + entity.ConsumeCommissionAmount = ParseDecimal(GetColumnValue(13 + offset)); + entity.HandworkFee = ParseDecimal(GetColumnValue(14 + offset)); + entity.TotalCommission = ParseDecimal(GetColumnValue(15 + offset)); + entity.WorkingDays = ParseDecimal(GetColumnValue(16 + offset)); + entity.LeaveDays = ParseDecimal(GetColumnValue(17 + offset)); + entity.TransportationAllowance = ParseDecimal(GetColumnValue(18 + offset)); + entity.LessRest = ParseDecimal(GetColumnValue(19 + offset)); + entity.FullAttendance = ParseDecimal(GetColumnValue(20 + offset)); + entity.MonthlyTrainingSubsidy = ParseDecimal(GetColumnValue(21 + offset)); + entity.MonthlyTransportSubsidy = ParseDecimal(GetColumnValue(22 + offset)); + entity.LastMonthTrainingSubsidy = ParseDecimal(GetColumnValue(23 + offset)); + entity.LastMonthTransportSubsidy = ParseDecimal(GetColumnValue(24 + offset)); + entity.TotalSubsidy = ParseDecimal(GetColumnValue(25 + offset)); + entity.CalculatedGrossSalary = ParseDecimal(GetColumnValue(26 + offset)); + entity.GuaranteedSalary = ParseDecimal(GetColumnValue(27 + offset)); + entity.GuaranteedLeaveDeduction = ParseDecimal(GetColumnValue(28 + offset)); + entity.GuaranteedBaseSalary = ParseDecimal(GetColumnValue(29 + offset)); + entity.GuaranteedSupplement = ParseDecimal(GetColumnValue(30 + offset)); + entity.FinalGrossSalary = ParseDecimal(GetColumnValue(31 + offset)); + entity.MissingCard = ParseDecimal(GetColumnValue(32 + offset)); + entity.LateArrival = ParseDecimal(GetColumnValue(33 + offset)); + entity.LeaveDeduction = ParseDecimal(GetColumnValue(34 + offset)); + entity.SocialInsuranceDeduction = ParseDecimal(GetColumnValue(35 + offset)); + entity.RewardDeduction = ParseDecimal(GetColumnValue(36 + offset)); + entity.AccommodationDeduction = ParseDecimal(GetColumnValue(37 + offset)); + entity.StudyPeriodDeduction = ParseDecimal(GetColumnValue(38 + offset)); + entity.WorkClothesDeduction = ParseDecimal(GetColumnValue(39 + offset)); + entity.TotalDeduction = ParseDecimal(GetColumnValue(40 + offset)); + entity.Bonus = ParseDecimal(GetColumnValue(41 + offset)); + entity.ReturnPhoneDeposit = ParseDecimal(GetColumnValue(42 + offset)); + entity.ReturnAccommodationDeposit = ParseDecimal(GetColumnValue(43 + offset)); + entity.LastMonthSupplement = ParseDecimal(GetColumnValue(44 + offset)); + entity.ActualSalary = ParseDecimal(GetColumnValue(45 + offset)); + entity.MonthlyPaymentStatus = GetColumnValue(46 + offset); + entity.PaidAmount = ParseDecimal(GetColumnValue(47 + offset)); + entity.PendingAmount = ParseDecimal(GetColumnValue(48 + offset)); + entity.MonthlyTotalPayment = ParseDecimal(GetColumnValue(49 + offset)); + + // 处理"是否新店"字段 + var isNewStoreStr = GetColumnValue(50 + offset); + entity.IsNewStore = (isNewStoreStr == "是" || isNewStoreStr == "1" || isNewStoreStr.ToLower() == "true") ? "是" : "否"; + + // 处理"新店保护阶段"字段 + var newStoreProtectionStageStr = GetColumnValue(51 + offset); + if (int.TryParse(newStoreProtectionStageStr, out int protectionStage)) + { + entity.NewStoreProtectionStage = protectionStage; + } + + // 处理"离职状态"字段(第54列,索引53+offset) + var terminatedStatus = GetColumnValue(53 + offset); + entity.IsTerminated = (terminatedStatus == "离职" || terminatedStatus == "1") ? 1 : 0; + + // 处理Excel中没有的字段 + if (existing != null) + { + entity.StoreId = existing.StoreId; + entity.EmployeeId = existing.EmployeeId; + entity.StatisticsMonth = existing.StatisticsMonth; + } + else + { + // 新增记录:尝试根据员工姓名和门店名称查找EmployeeId和StoreId + if (!string.IsNullOrWhiteSpace(employeeName)) + { + var user = await _db.Queryable() + .Where(u => u.RealName == employeeName) + .FirstAsync(); + + if (user != null) + { + entity.EmployeeId = user.Id; + entity.EmployeeAccount = user.Account; + + if (!string.IsNullOrEmpty(user.Mdid) && string.IsNullOrWhiteSpace(storeName)) + { + entity.StoreId = user.Mdid; + } + } + + if (!string.IsNullOrWhiteSpace(storeName) && string.IsNullOrEmpty(entity.StoreId)) + { + var store = await _db.Queryable() + .Where(s => s.Dm == storeName) + .FirstAsync(); + + if (store != null) + { + entity.StoreId = store.Id; + } + } + } + } + + entity.UpdateTime = DateTime.Now; + + if (existing != null) + { + recordsToUpdate.Add(entity); + } + else + { + recordsToInsert.Add(entity); + } + + successCount++; + } + catch (Exception ex) + { + errorMessages.Add($"第{i + 1}行数据处理失败: {ex.Message}"); + failCount++; + } + } + } + finally + { + // 清理临时文件 + if (File.Exists(tempFilePath)) + { + File.Delete(tempFilePath); + } + } + + // 批量插入新记录 + if (recordsToInsert.Any()) + { + await _db.Insertable(recordsToInsert).ExecuteCommandAsync(); + } + + // 批量更新现有记录 + if (recordsToUpdate.Any()) + { + await _db.Updateable(recordsToUpdate).ExecuteCommandAsync(); + } + + return new + { + success = true, + message = $"导入完成:成功 {successCount} 条,失败 {failCount} 条,跳过 {skippedCount} 条(已锁定或已确认)", + successCount = successCount, + failCount = failCount, + skippedCount = skippedCount, + errors = errorMessages + }; + } + catch (Exception ex) + { + throw NCCException.Oh($"导入科技部老师工资数据失败: {ex.Message}"); + } + } + + #endregion } }