797 lines
31 KiB
Go
797 lines
31 KiB
Go
package controllers
|
||
|
||
import (
|
||
"encoding/json"
|
||
"io"
|
||
"log"
|
||
"math"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"server/models"
|
||
"server/pkg/identity"
|
||
"server/pkg/jwtutil"
|
||
|
||
"github.com/beego/beego/v2/client/orm"
|
||
beego "github.com/beego/beego/v2/server/web"
|
||
)
|
||
|
||
// BackendOaCompensationController OA 薪酬管理接口。
|
||
// 数据按租户隔离;工资金额使用薪资单快照,避免组织资料或历史方案变动影响已生成工资。
|
||
type BackendOaCompensationController struct {
|
||
beego.Controller
|
||
}
|
||
|
||
func (c *BackendOaCompensationController) compensationClaims() (*jwtutil.Claims, error) {
|
||
// 调用方一律用 orm.ErrNoRows 判「未登录/无权」,这里保持该约定,
|
||
// 只把令牌解析与判端交给 identity。
|
||
claims, err := identity.Claims(c.Ctx.Request.Header.Get("Authorization"), identity.EndBackend)
|
||
if err != nil {
|
||
return nil, orm.ErrNoRows
|
||
}
|
||
return claims, nil
|
||
}
|
||
|
||
func (c *BackendOaCompensationController) compensationError(httpStatus int, msg string) {
|
||
c.Ctx.Output.SetStatus(httpStatus)
|
||
c.Data["json"] = map[string]interface{}{"code": httpStatus, "msg": msg}
|
||
_ = c.ServeJSON()
|
||
}
|
||
|
||
func (c *BackendOaCompensationController) compensationOK(data interface{}) {
|
||
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": data}
|
||
_ = c.ServeJSON()
|
||
}
|
||
|
||
func compensationID(raw string) (uint64, bool) {
|
||
id, err := strconv.ParseUint(raw, 10, 64)
|
||
return id, err == nil && id > 0
|
||
}
|
||
|
||
func compensationMoney(v float64) float64 {
|
||
return math.Round(v*100) / 100
|
||
}
|
||
|
||
func compensationString(v *string) string {
|
||
if v == nil {
|
||
return ""
|
||
}
|
||
return *v
|
||
}
|
||
|
||
var compensationMonthPattern = regexp.MustCompile(`^\d{4}-(0[1-9]|1[0-2])$`)
|
||
|
||
type compensationItemPayload struct {
|
||
ID uint64 `json:"id"`
|
||
ItemName string `json:"item_name"`
|
||
ItemType int8 `json:"item_type"` // 1 增项;2 扣项
|
||
Amount float64 `json:"amount"`
|
||
Remark string `json:"remark"`
|
||
SortOrder int `json:"sort_order"`
|
||
}
|
||
|
||
type compensationSchemePayload struct {
|
||
EmployeeID uint64 `json:"employee_id"`
|
||
// RankID 职级ID(yz_backend_job_rank.id),0=未指定。
|
||
// 传 0 时后端会按「员工 → 职位 → 职级」自动带出;带上后按职级的
|
||
// 标准薪资区间给出参考薪资(base_salary 未填时自动取下限)。
|
||
RankID uint64 `json:"rank_id"`
|
||
SchemeName string `json:"scheme_name"`
|
||
EffectiveDate string `json:"effective_date"`
|
||
ExpiryDate string `json:"expiry_date"`
|
||
BaseSalary float64 `json:"base_salary"`
|
||
PostAllowance float64 `json:"post_allowance"`
|
||
PerformanceSalary float64 `json:"performance_salary"`
|
||
TransportAllowance float64 `json:"transport_allowance"`
|
||
MealAllowance float64 `json:"meal_allowance"`
|
||
CommunicationAllowance float64 `json:"communication_allowance"`
|
||
SocialInsuranceBase float64 `json:"social_insurance_base"`
|
||
HousingFundBase float64 `json:"housing_fund_base"`
|
||
SocialInsuranceRate float64 `json:"social_insurance_rate"`
|
||
HousingFundRate float64 `json:"housing_fund_rate"`
|
||
Status int8 `json:"status"`
|
||
Remark string `json:"remark"`
|
||
}
|
||
|
||
type compensationPayrollPayload struct {
|
||
EmployeeID uint64 `json:"employee_id"`
|
||
SchemeID *uint64 `json:"scheme_id"`
|
||
PayrollMonth string `json:"payroll_month"`
|
||
BaseSalary float64 `json:"base_salary"`
|
||
PostAllowance float64 `json:"post_allowance"`
|
||
PerformanceSalary float64 `json:"performance_salary"`
|
||
TransportAllowance float64 `json:"transport_allowance"`
|
||
MealAllowance float64 `json:"meal_allowance"`
|
||
CommunicationAllowance float64 `json:"communication_allowance"`
|
||
OvertimePay float64 `json:"overtime_pay"`
|
||
Bonus float64 `json:"bonus"`
|
||
OtherAddition float64 `json:"other_addition"`
|
||
LeaveDeduction float64 `json:"leave_deduction"`
|
||
LateDeduction float64 `json:"late_deduction"`
|
||
OtherDeduction float64 `json:"other_deduction"`
|
||
SocialInsurance float64 `json:"social_insurance"`
|
||
HousingFund float64 `json:"housing_fund"`
|
||
PersonalIncomeTax float64 `json:"personal_income_tax"`
|
||
Remark string `json:"remark"`
|
||
Items []compensationItemPayload `json:"items"`
|
||
}
|
||
|
||
func (c *BackendOaCompensationController) parseBody(target interface{}) bool {
|
||
raw, err := io.ReadAll(c.Ctx.Request.Body)
|
||
if err != nil || json.Unmarshal(raw, target) != nil {
|
||
c.compensationError(400, "参数格式错误")
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
func validCompensationDate(s string) (time.Time, bool) {
|
||
s = strings.TrimSpace(s)
|
||
value, err := time.ParseInLocation("2006-01-02", s, time.Local)
|
||
return value, err == nil
|
||
}
|
||
|
||
func validateNonNegative(values ...float64) bool {
|
||
for _, value := range values {
|
||
if value < 0 || math.IsNaN(value) || math.IsInf(value, 0) {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
func (c *BackendOaCompensationController) employee(tenantID uint64, employeeID uint64) (*models.BackendEmployee, bool) {
|
||
var employee models.BackendEmployee
|
||
err := models.Orm.QueryTable(new(models.BackendEmployee)).
|
||
Filter("id", employeeID).
|
||
Filter("tid", tenantID).
|
||
Filter("delete_time__isnull", true).
|
||
One(&employee)
|
||
if err != nil {
|
||
return nil, false
|
||
}
|
||
return &employee, true
|
||
}
|
||
|
||
func payrollMap(row models.BackendOaPayroll) map[string]interface{} {
|
||
return map[string]interface{}{
|
||
"id": row.ID, "employee_id": row.EmployeeID, "scheme_id": row.SchemeID,
|
||
"payroll_month": row.PayrollMonth, "employee_name": row.EmployeeName,
|
||
"department": row.Department, "position": row.Position,
|
||
"base_salary": row.BaseSalary, "post_allowance": row.PostAllowance,
|
||
"performance_salary": row.PerformanceSalary, "transport_allowance": row.TransportAllowance,
|
||
"meal_allowance": row.MealAllowance, "communication_allowance": row.CommunicationAllowance,
|
||
"overtime_pay": row.OvertimePay, "bonus": row.Bonus, "other_addition": row.OtherAddition,
|
||
"leave_deduction": row.LeaveDeduction, "late_deduction": row.LateDeduction,
|
||
"other_deduction": row.OtherDeduction, "social_insurance": row.SocialInsurance,
|
||
"housing_fund": row.HousingFund, "personal_income_tax": row.PersonalIncomeTax,
|
||
"gross_salary": row.GrossSalary, "total_deduction": row.TotalDeduction,
|
||
"net_salary": row.NetSalary, "status": row.Status, "confirmed_at": row.ConfirmedAt,
|
||
"paid_at": row.PaidAt, "remark": row.Remark, "create_time": row.CreateTime,
|
||
}
|
||
}
|
||
|
||
func (c *BackendOaCompensationController) fillPayroll(row *models.BackendOaPayroll, payload compensationPayrollPayload, employee *models.BackendEmployee) {
|
||
row.SchemeID = payload.SchemeID
|
||
row.BaseSalary = compensationMoney(payload.BaseSalary)
|
||
row.PostAllowance = compensationMoney(payload.PostAllowance)
|
||
row.PerformanceSalary = compensationMoney(payload.PerformanceSalary)
|
||
row.TransportAllowance = compensationMoney(payload.TransportAllowance)
|
||
row.MealAllowance = compensationMoney(payload.MealAllowance)
|
||
row.CommunicationAllowance = compensationMoney(payload.CommunicationAllowance)
|
||
row.OvertimePay = compensationMoney(payload.OvertimePay)
|
||
row.Bonus = compensationMoney(payload.Bonus)
|
||
row.OtherAddition = compensationMoney(payload.OtherAddition)
|
||
row.LeaveDeduction = compensationMoney(payload.LeaveDeduction)
|
||
row.LateDeduction = compensationMoney(payload.LateDeduction)
|
||
row.OtherDeduction = compensationMoney(payload.OtherDeduction)
|
||
row.SocialInsurance = compensationMoney(payload.SocialInsurance)
|
||
row.HousingFund = compensationMoney(payload.HousingFund)
|
||
row.PersonalIncomeTax = compensationMoney(payload.PersonalIncomeTax)
|
||
row.Remark = strings.TrimSpace(payload.Remark)
|
||
row.EmployeeName = employee.Name
|
||
row.Department = compensationString(employee.Department)
|
||
row.Position = compensationString(employee.Position)
|
||
|
||
customAdd, customDeduct := 0.0, 0.0
|
||
for _, item := range payload.Items {
|
||
if item.ItemType == 1 {
|
||
customAdd += item.Amount
|
||
} else if item.ItemType == 2 {
|
||
customDeduct += item.Amount
|
||
}
|
||
}
|
||
row.GrossSalary = compensationMoney(row.BaseSalary + row.PostAllowance + row.PerformanceSalary +
|
||
row.TransportAllowance + row.MealAllowance + row.CommunicationAllowance + row.OvertimePay +
|
||
row.Bonus + row.OtherAddition + customAdd)
|
||
row.TotalDeduction = compensationMoney(row.LeaveDeduction + row.LateDeduction + row.OtherDeduction +
|
||
row.SocialInsurance + row.HousingFund + row.PersonalIncomeTax + customDeduct)
|
||
row.NetSalary = compensationMoney(row.GrossSalary - row.TotalDeduction)
|
||
}
|
||
|
||
func (c *BackendOaCompensationController) saveItems(tenantID, payrollID uint64, payloads []compensationItemPayload) error {
|
||
_, err := models.Orm.QueryTable(new(models.BackendOaPayrollItem)).
|
||
Filter("tid", tenantID).Filter("payroll_id", payrollID).Filter("is_deleted", 0).
|
||
Update(orm.Params{"is_deleted": int8(1), "delete_time": time.Now()})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
for index, payload := range payloads {
|
||
name := strings.TrimSpace(payload.ItemName)
|
||
if name == "" || (payload.ItemType != 1 && payload.ItemType != 2) || !validateNonNegative(payload.Amount) {
|
||
return orm.ErrArgs
|
||
}
|
||
item := &models.BackendOaPayrollItem{
|
||
Tid: tenantID, PayrollID: payrollID, ItemName: name, ItemType: payload.ItemType,
|
||
Amount: compensationMoney(payload.Amount), Remark: strings.TrimSpace(payload.Remark),
|
||
SortOrder: index, IsDeleted: 0,
|
||
}
|
||
if _, err = models.Orm.Insert(item); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Employees GET /backend/oa/compensation/employees
|
||
func (c *BackendOaCompensationController) Employees() {
|
||
claims, err := c.compensationClaims()
|
||
if err != nil {
|
||
c.compensationError(401, "未登录或无权限")
|
||
return
|
||
}
|
||
keyword := strings.TrimSpace(c.GetString("keyword"))
|
||
qs := models.Orm.QueryTable(new(models.BackendEmployee)).Filter("tid", claims.TenantId).Filter("delete_time__isnull", true)
|
||
if keyword != "" {
|
||
qs = qs.Filter("name__icontains", keyword)
|
||
}
|
||
var employees []models.BackendEmployee
|
||
_, err = qs.OrderBy("name", "id").All(&employees)
|
||
if err != nil && err != orm.ErrNoRows {
|
||
c.compensationError(500, "员工查询失败")
|
||
return
|
||
}
|
||
orgNames := compensationOrgNameMap(claims.TenantId)
|
||
list := make([]map[string]interface{}, 0, len(employees))
|
||
for _, employee := range employees {
|
||
department := compensationString(employee.Department)
|
||
list = append(list, map[string]interface{}{
|
||
"id": employee.ID, "name": employee.Name, "account": employee.Account,
|
||
"department": department, "department_name": efOrgNameByID(orgNames, department),
|
||
"position": compensationString(employee.Position),
|
||
})
|
||
}
|
||
c.compensationOK(list)
|
||
}
|
||
|
||
// compensationOrgNameMap 组织ID -> 名称映射,用于把员工 department 字段(存的是组织ID)解析为部门名称展示。
|
||
func compensationOrgNameMap(tid int) map[uint64]string {
|
||
var rows []models.BackendOrganization
|
||
if _, err := models.Orm.QueryTable(new(models.BackendOrganization)).
|
||
Filter("tid", tid).
|
||
Filter("delete_time__isnull", true).
|
||
All(&rows, "ID", "OrgName"); err != nil {
|
||
return map[uint64]string{}
|
||
}
|
||
names := make(map[uint64]string, len(rows))
|
||
for _, row := range rows {
|
||
names[row.ID] = row.OrgName
|
||
}
|
||
return names
|
||
}
|
||
|
||
// SchemeList GET /backend/oa/compensation/schemes
|
||
func (c *BackendOaCompensationController) SchemeList() {
|
||
claims, err := c.compensationClaims()
|
||
if err != nil {
|
||
c.compensationError(401, "未登录或无权限")
|
||
return
|
||
}
|
||
employeeID, _ := c.GetInt64("employee_id", 0)
|
||
qs := models.Orm.QueryTable(new(models.BackendOaCompensationScheme)).Filter("tid", claims.TenantId).Filter("is_deleted", 0)
|
||
if employeeID > 0 {
|
||
qs = qs.Filter("employee_id", employeeID)
|
||
}
|
||
var rows []models.BackendOaCompensationScheme
|
||
_, err = qs.OrderBy("-effective_date", "-id").All(&rows)
|
||
if err != nil && err != orm.ErrNoRows {
|
||
c.compensationError(500, "查询薪酬方案失败")
|
||
return
|
||
}
|
||
if rows == nil {
|
||
rows = []models.BackendOaCompensationScheme{}
|
||
}
|
||
c.compensationOK(compensationSchemeViews(uint64(claims.TenantId), rows))
|
||
}
|
||
|
||
// compensationSchemeView 给单个薪酬方案补上职级信息:
|
||
// 职级名称与该职级的标准薪资区间,前端据此展示「按职级带出的标准薪资」。
|
||
func compensationSchemeView(tid uint64, row models.BackendOaCompensationScheme) map[string]interface{} {
|
||
item := map[string]interface{}{}
|
||
if b, err := json.Marshal(row); err == nil {
|
||
_ = json.Unmarshal(b, &item)
|
||
}
|
||
item["rank_name"] = ""
|
||
item["rank_code"] = ""
|
||
item["rank_level_no"] = 0
|
||
item["rank_salary_min"] = float64(0)
|
||
item["rank_salary_max"] = float64(0)
|
||
if row.RankID > 0 {
|
||
if rank, ok := JobRankMap(tid)[row.RankID]; ok {
|
||
item["rank_name"] = rank.RankName
|
||
item["rank_code"] = rank.RankCode
|
||
item["rank_level_no"] = rank.LevelNo
|
||
item["rank_salary_min"] = rank.SalaryMin
|
||
item["rank_salary_max"] = rank.SalaryMax
|
||
}
|
||
}
|
||
return item
|
||
}
|
||
|
||
// compensationSchemeViews 给薪酬方案列表补上职级信息。
|
||
func compensationSchemeViews(tid uint64, rows []models.BackendOaCompensationScheme) []map[string]interface{} {
|
||
list := make([]map[string]interface{}, 0, len(rows))
|
||
for _, row := range rows {
|
||
list = append(list, compensationSchemeView(tid, row))
|
||
}
|
||
return list
|
||
}
|
||
|
||
// compensationResolveRank 解析薪酬方案使用的职级:
|
||
// - 显式传了 rank_id:必须属于当前租户,且适用部门要与员工部门一致
|
||
// (全公司通用职级不受限);
|
||
// - 没传:按「员工 → 职位 → 职级」自动带出。
|
||
// 第三个返回值为错误提示,空串表示解析成功。
|
||
func (c *BackendOaCompensationController) compensationResolveRank(
|
||
tid uint64, employee *models.BackendEmployee, rankID uint64,
|
||
) (uint64, models.BackendJobRank, string) {
|
||
if employee == nil {
|
||
return 0, models.BackendJobRank{}, "员工不存在或不属于当前租户"
|
||
}
|
||
ranks := JobRankMap(tid)
|
||
|
||
if rankID == 0 {
|
||
// 未指定:按员工所在职位带出职级
|
||
derived := ResolveEmployeeRankID(tid, *employee)
|
||
if derived == 0 {
|
||
return 0, models.BackendJobRank{}, ""
|
||
}
|
||
rank, ok := ranks[derived]
|
||
if !ok {
|
||
return 0, models.BackendJobRank{}, ""
|
||
}
|
||
return derived, rank, ""
|
||
}
|
||
|
||
rank, ok := ranks[rankID]
|
||
if !ok {
|
||
return 0, models.BackendJobRank{}, "职级不存在或不属于当前租户"
|
||
}
|
||
// 职级适用部门必须覆盖员工所在部门
|
||
if rank.DepartmentID != 0 {
|
||
departmentID, err := strconv.ParseUint(strings.TrimSpace(compensationString(employee.Department)), 10, 64)
|
||
if err == nil && departmentID > 0 && rank.DepartmentID != departmentID {
|
||
return 0, models.BackendJobRank{}, "职级「" + rank.RankName + "」不适用于该员工所在部门"
|
||
}
|
||
}
|
||
return rankID, rank, ""
|
||
}
|
||
|
||
// CreateScheme POST /backend/oa/compensation/schemes
|
||
func (c *BackendOaCompensationController) CreateScheme() {
|
||
claims, err := c.compensationClaims()
|
||
if err != nil {
|
||
c.compensationError(401, "未登录或无权限")
|
||
return
|
||
}
|
||
var payload compensationSchemePayload
|
||
if !c.parseBody(&payload) {
|
||
return
|
||
}
|
||
date, ok := validCompensationDate(payload.EffectiveDate)
|
||
if !ok || payload.EmployeeID == 0 || strings.TrimSpace(payload.SchemeName) == "" {
|
||
c.compensationError(400, "请填写员工、方案名称和生效日期")
|
||
return
|
||
}
|
||
employee, ok := c.employee(uint64(claims.TenantId), payload.EmployeeID)
|
||
if !ok || employee == nil {
|
||
c.compensationError(400, "员工不存在或不属于当前租户")
|
||
return
|
||
}
|
||
if !validateNonNegative(payload.BaseSalary, payload.PostAllowance, payload.PerformanceSalary, payload.TransportAllowance,
|
||
payload.MealAllowance, payload.CommunicationAllowance, payload.SocialInsuranceBase, payload.HousingFundBase,
|
||
payload.SocialInsuranceRate, payload.HousingFundRate) || payload.SocialInsuranceRate > 1 || payload.HousingFundRate > 1 {
|
||
c.compensationError(400, "薪酬金额或比例无效")
|
||
return
|
||
}
|
||
var expiry *time.Time
|
||
if strings.TrimSpace(payload.ExpiryDate) != "" {
|
||
value, valid := validCompensationDate(payload.ExpiryDate)
|
||
if !valid || value.Before(date) {
|
||
c.compensationError(400, "失效日期无效")
|
||
return
|
||
}
|
||
expiry = &value
|
||
}
|
||
status := payload.Status
|
||
if status != 0 {
|
||
status = 1
|
||
}
|
||
// 职级:未指定时按「员工 → 职位 → 职级」自动带出;
|
||
// 带出后 base_salary 留空则取职级标准薪资下限作为参考值。
|
||
rankID, rank, rankErr := c.compensationResolveRank(uint64(claims.TenantId), employee, payload.RankID)
|
||
if rankErr != "" {
|
||
c.compensationError(400, rankErr)
|
||
return
|
||
}
|
||
baseSalary := compensationMoney(payload.BaseSalary)
|
||
if baseSalary <= 0 && rank.SalaryMin > 0 {
|
||
baseSalary = compensationMoney(rank.SalaryMin)
|
||
}
|
||
row := &models.BackendOaCompensationScheme{
|
||
Tid: uint64(claims.TenantId), EmployeeID: payload.EmployeeID, RankID: rankID,
|
||
SchemeName: strings.TrimSpace(payload.SchemeName),
|
||
EffectiveDate: date, ExpiryDate: expiry, BaseSalary: baseSalary,
|
||
PostAllowance: compensationMoney(payload.PostAllowance), PerformanceSalary: compensationMoney(payload.PerformanceSalary),
|
||
TransportAllowance: compensationMoney(payload.TransportAllowance), MealAllowance: compensationMoney(payload.MealAllowance),
|
||
CommunicationAllowance: compensationMoney(payload.CommunicationAllowance), SocialInsuranceBase: compensationMoney(payload.SocialInsuranceBase),
|
||
HousingFundBase: compensationMoney(payload.HousingFundBase), SocialInsuranceRate: payload.SocialInsuranceRate,
|
||
HousingFundRate: payload.HousingFundRate, Status: status, Remark: strings.TrimSpace(payload.Remark),
|
||
}
|
||
if _, err = models.Orm.Insert(row); err != nil {
|
||
c.compensationError(500, "保存薪酬方案失败")
|
||
return
|
||
}
|
||
c.compensationOK(compensationSchemeView(uint64(claims.TenantId), *row))
|
||
}
|
||
|
||
// UpdateScheme POST /backend/oa/compensation/schemes/:id
|
||
func (c *BackendOaCompensationController) UpdateScheme() {
|
||
claims, err := c.compensationClaims()
|
||
if err != nil {
|
||
c.compensationError(401, "未登录或无权限")
|
||
return
|
||
}
|
||
id, valid := compensationID(c.Ctx.Input.Param(":id"))
|
||
if !valid {
|
||
c.compensationError(400, "无效方案ID")
|
||
return
|
||
}
|
||
var payload compensationSchemePayload
|
||
if !c.parseBody(&payload) {
|
||
return
|
||
}
|
||
var row models.BackendOaCompensationScheme
|
||
if models.Orm.QueryTable(new(models.BackendOaCompensationScheme)).Filter("id", id).Filter("tid", claims.TenantId).Filter("is_deleted", 0).One(&row) != nil {
|
||
c.compensationError(404, "薪酬方案不存在")
|
||
return
|
||
}
|
||
// 保持更新逻辑与创建一致,先软删除旧值再由统一创建验证的字段约束赋值。
|
||
date, ok := validCompensationDate(payload.EffectiveDate)
|
||
if !ok || payload.EmployeeID == 0 || strings.TrimSpace(payload.SchemeName) == "" || !validateNonNegative(payload.BaseSalary, payload.PostAllowance, payload.PerformanceSalary, payload.TransportAllowance, payload.MealAllowance, payload.CommunicationAllowance, payload.SocialInsuranceBase, payload.HousingFundBase, payload.SocialInsuranceRate, payload.HousingFundRate) || payload.SocialInsuranceRate > 1 || payload.HousingFundRate > 1 {
|
||
c.compensationError(400, "方案内容无效")
|
||
return
|
||
}
|
||
employee, ok := c.employee(uint64(claims.TenantId), payload.EmployeeID)
|
||
if !ok {
|
||
c.compensationError(400, "员工不存在或不属于当前租户")
|
||
return
|
||
}
|
||
rankID, rank, rankErr := c.compensationResolveRank(uint64(claims.TenantId), employee, payload.RankID)
|
||
if rankErr != "" {
|
||
c.compensationError(400, rankErr)
|
||
return
|
||
}
|
||
var expiry *time.Time
|
||
if strings.TrimSpace(payload.ExpiryDate) != "" {
|
||
value, dateOK := validCompensationDate(payload.ExpiryDate)
|
||
if !dateOK || value.Before(date) {
|
||
c.compensationError(400, "失效日期无效")
|
||
return
|
||
}
|
||
expiry = &value
|
||
}
|
||
now := time.Now()
|
||
row.EmployeeID, row.SchemeName, row.EffectiveDate, row.ExpiryDate = payload.EmployeeID, strings.TrimSpace(payload.SchemeName), date, expiry
|
||
row.RankID = rankID
|
||
row.BaseSalary, row.PostAllowance, row.PerformanceSalary = compensationMoney(payload.BaseSalary), compensationMoney(payload.PostAllowance), compensationMoney(payload.PerformanceSalary)
|
||
// 职级带出:base_salary 未填且职级有标准薪资时取下限作为参考值
|
||
if row.BaseSalary <= 0 && rank.SalaryMin > 0 {
|
||
row.BaseSalary = compensationMoney(rank.SalaryMin)
|
||
}
|
||
row.TransportAllowance, row.MealAllowance, row.CommunicationAllowance = compensationMoney(payload.TransportAllowance), compensationMoney(payload.MealAllowance), compensationMoney(payload.CommunicationAllowance)
|
||
row.SocialInsuranceBase, row.HousingFundBase, row.SocialInsuranceRate, row.HousingFundRate = compensationMoney(payload.SocialInsuranceBase), compensationMoney(payload.HousingFundBase), payload.SocialInsuranceRate, payload.HousingFundRate
|
||
row.Status, row.Remark, row.UpdateTime = payload.Status, strings.TrimSpace(payload.Remark), &now
|
||
if row.Status != 0 {
|
||
row.Status = 1
|
||
}
|
||
if _, err = models.Orm.Update(&row); err != nil {
|
||
c.compensationError(500, "更新薪酬方案失败")
|
||
return
|
||
}
|
||
c.compensationOK(compensationSchemeView(uint64(claims.TenantId), row))
|
||
}
|
||
|
||
// DeleteScheme DELETE /backend/oa/compensation/schemes/:id
|
||
func (c *BackendOaCompensationController) DeleteScheme() {
|
||
claims, err := c.compensationClaims()
|
||
if err != nil {
|
||
c.compensationError(401, "未登录或无权限")
|
||
return
|
||
}
|
||
id, valid := compensationID(c.Ctx.Input.Param(":id"))
|
||
if !valid {
|
||
c.compensationError(400, "无效方案ID")
|
||
return
|
||
}
|
||
now := time.Now()
|
||
count, err := models.Orm.QueryTable(new(models.BackendOaCompensationScheme)).Filter("id", id).Filter("tid", claims.TenantId).Filter("is_deleted", 0).Update(orm.Params{"is_deleted": int8(1), "delete_time": now, "update_time": now})
|
||
if err != nil || count == 0 {
|
||
c.compensationError(404, "薪酬方案不存在")
|
||
return
|
||
}
|
||
c.compensationOK(nil)
|
||
}
|
||
|
||
// List GET /backend/oa/compensation/payrolls
|
||
func (c *BackendOaCompensationController) List() {
|
||
claims, err := c.compensationClaims()
|
||
if err != nil {
|
||
c.compensationError(401, "未登录或无权限")
|
||
return
|
||
}
|
||
page, _ := c.GetInt("page", 1)
|
||
pageSize, _ := c.GetInt("pageSize", 20)
|
||
if page < 1 {
|
||
page = 1
|
||
}
|
||
if pageSize < 1 || pageSize > 200 {
|
||
pageSize = 20
|
||
}
|
||
month, keyword := strings.TrimSpace(c.GetString("payroll_month")), strings.TrimSpace(c.GetString("keyword"))
|
||
status := strings.TrimSpace(c.GetString("status"))
|
||
qs := models.Orm.QueryTable(new(models.BackendOaPayroll)).Filter("tid", claims.TenantId).Filter("is_deleted", 0)
|
||
if compensationMonthPattern.MatchString(month) {
|
||
qs = qs.Filter("payroll_month", month)
|
||
}
|
||
if keyword != "" {
|
||
qs = qs.Filter("employee_name__icontains", keyword)
|
||
}
|
||
if status == "0" || status == "1" || status == "2" || status == "3" {
|
||
value, _ := strconv.Atoi(status)
|
||
qs = qs.Filter("status", int8(value))
|
||
}
|
||
total, _ := qs.Count()
|
||
var rows []models.BackendOaPayroll
|
||
_, err = qs.OrderBy("-payroll_month", "employee_name", "-id").Limit(pageSize).Offset((page - 1) * pageSize).All(&rows)
|
||
if err != nil && err != orm.ErrNoRows {
|
||
log.Printf("薪资单查询失败: tid=%d month=%q keyword=%q status=%q err=%v", claims.TenantId, month, keyword, status, err)
|
||
c.compensationError(500, "查询薪资单失败,请检查薪酬管理数据表是否已创建")
|
||
return
|
||
}
|
||
list := make([]map[string]interface{}, 0, len(rows))
|
||
for _, row := range rows {
|
||
list = append(list, payrollMap(row))
|
||
}
|
||
c.compensationOK(map[string]interface{}{"list": list, "total": total})
|
||
}
|
||
|
||
// Detail GET /backend/oa/compensation/payrolls/:id
|
||
func (c *BackendOaCompensationController) Detail() {
|
||
claims, err := c.compensationClaims()
|
||
if err != nil {
|
||
c.compensationError(401, "未登录或无权限")
|
||
return
|
||
}
|
||
id, valid := compensationID(c.Ctx.Input.Param(":id"))
|
||
if !valid {
|
||
c.compensationError(400, "无效薪资单ID")
|
||
return
|
||
}
|
||
var row models.BackendOaPayroll
|
||
if models.Orm.QueryTable(new(models.BackendOaPayroll)).Filter("id", id).Filter("tid", claims.TenantId).Filter("is_deleted", 0).One(&row) != nil {
|
||
c.compensationError(404, "薪资单不存在")
|
||
return
|
||
}
|
||
var items []models.BackendOaPayrollItem
|
||
_, _ = models.Orm.QueryTable(new(models.BackendOaPayrollItem)).Filter("tid", claims.TenantId).Filter("payroll_id", id).Filter("is_deleted", 0).OrderBy("sort_order", "id").All(&items)
|
||
data := payrollMap(row)
|
||
data["items"] = items
|
||
c.compensationOK(data)
|
||
}
|
||
|
||
// Create POST /backend/oa/compensation/payrolls
|
||
func (c *BackendOaCompensationController) Create() {
|
||
claims, err := c.compensationClaims()
|
||
if err != nil {
|
||
c.compensationError(401, "未登录或无权限")
|
||
return
|
||
}
|
||
var payload compensationPayrollPayload
|
||
if !c.parseBody(&payload) {
|
||
return
|
||
}
|
||
if payload.EmployeeID == 0 || !compensationMonthPattern.MatchString(strings.TrimSpace(payload.PayrollMonth)) || !validateNonNegative(payload.BaseSalary, payload.PostAllowance, payload.PerformanceSalary, payload.TransportAllowance, payload.MealAllowance, payload.CommunicationAllowance, payload.OvertimePay, payload.Bonus, payload.OtherAddition, payload.LeaveDeduction, payload.LateDeduction, payload.OtherDeduction, payload.SocialInsurance, payload.HousingFund, payload.PersonalIncomeTax) {
|
||
c.compensationError(400, "员工、工资月份或金额无效")
|
||
return
|
||
}
|
||
employee, ok := c.employee(uint64(claims.TenantId), payload.EmployeeID)
|
||
if !ok {
|
||
c.compensationError(400, "员工不存在或不属于当前租户")
|
||
return
|
||
}
|
||
exists := models.Orm.QueryTable(new(models.BackendOaPayroll)).Filter("tid", claims.TenantId).Filter("employee_id", payload.EmployeeID).Filter("payroll_month", payload.PayrollMonth).Filter("is_deleted", 0).Exist()
|
||
if exists {
|
||
c.compensationError(409, "该员工本月薪资单已存在")
|
||
return
|
||
}
|
||
row := &models.BackendOaPayroll{Tid: uint64(claims.TenantId), EmployeeID: payload.EmployeeID, PayrollMonth: strings.TrimSpace(payload.PayrollMonth), Status: 0}
|
||
c.fillPayroll(row, payload, employee)
|
||
if _, err = models.Orm.Insert(row); err != nil {
|
||
c.compensationError(500, "创建薪资单失败")
|
||
return
|
||
}
|
||
if err = c.saveItems(uint64(claims.TenantId), row.ID, payload.Items); err != nil {
|
||
c.compensationError(400, "自定义薪资项无效")
|
||
return
|
||
}
|
||
c.compensationOK(payrollMap(*row))
|
||
}
|
||
|
||
// Update POST /backend/oa/compensation/payrolls/:id
|
||
func (c *BackendOaCompensationController) Update() {
|
||
claims, err := c.compensationClaims()
|
||
if err != nil {
|
||
c.compensationError(401, "未登录或无权限")
|
||
return
|
||
}
|
||
id, valid := compensationID(c.Ctx.Input.Param(":id"))
|
||
if !valid {
|
||
c.compensationError(400, "无效薪资单ID")
|
||
return
|
||
}
|
||
var payload compensationPayrollPayload
|
||
if !c.parseBody(&payload) {
|
||
return
|
||
}
|
||
var row models.BackendOaPayroll
|
||
if models.Orm.QueryTable(new(models.BackendOaPayroll)).Filter("id", id).Filter("tid", claims.TenantId).Filter("is_deleted", 0).One(&row) != nil {
|
||
c.compensationError(404, "薪资单不存在")
|
||
return
|
||
}
|
||
if row.Status != 0 {
|
||
c.compensationError(400, "仅草稿薪资单可以编辑")
|
||
return
|
||
}
|
||
if payload.EmployeeID == 0 || !compensationMonthPattern.MatchString(strings.TrimSpace(payload.PayrollMonth)) || !validateNonNegative(payload.BaseSalary, payload.PostAllowance, payload.PerformanceSalary, payload.TransportAllowance, payload.MealAllowance, payload.CommunicationAllowance, payload.OvertimePay, payload.Bonus, payload.OtherAddition, payload.LeaveDeduction, payload.LateDeduction, payload.OtherDeduction, payload.SocialInsurance, payload.HousingFund, payload.PersonalIncomeTax) {
|
||
c.compensationError(400, "薪资单内容无效")
|
||
return
|
||
}
|
||
employee, ok := c.employee(uint64(claims.TenantId), payload.EmployeeID)
|
||
if !ok {
|
||
c.compensationError(400, "员工不存在")
|
||
return
|
||
}
|
||
exists := models.Orm.QueryTable(new(models.BackendOaPayroll)).Filter("tid", claims.TenantId).Filter("employee_id", payload.EmployeeID).Filter("payroll_month", payload.PayrollMonth).Filter("is_deleted", 0).Filter("id__ne", id).Exist()
|
||
if exists {
|
||
c.compensationError(409, "该员工本月薪资单已存在")
|
||
return
|
||
}
|
||
row.EmployeeID, row.PayrollMonth = payload.EmployeeID, strings.TrimSpace(payload.PayrollMonth)
|
||
c.fillPayroll(&row, payload, employee)
|
||
now := time.Now()
|
||
row.UpdateTime = &now
|
||
if _, err = models.Orm.Update(&row); err != nil {
|
||
c.compensationError(500, "更新薪资单失败")
|
||
return
|
||
}
|
||
if err = c.saveItems(uint64(claims.TenantId), row.ID, payload.Items); err != nil {
|
||
c.compensationError(400, "自定义薪资项无效")
|
||
return
|
||
}
|
||
c.compensationOK(payrollMap(row))
|
||
}
|
||
|
||
// Delete DELETE /backend/oa/compensation/payrolls/:id
|
||
func (c *BackendOaCompensationController) Delete() {
|
||
claims, err := c.compensationClaims()
|
||
if err != nil {
|
||
c.compensationError(401, "未登录或无权限")
|
||
return
|
||
}
|
||
id, valid := compensationID(c.Ctx.Input.Param(":id"))
|
||
if !valid {
|
||
c.compensationError(400, "无效薪资单ID")
|
||
return
|
||
}
|
||
now := time.Now()
|
||
count, err := models.Orm.QueryTable(new(models.BackendOaPayroll)).Filter("id", id).Filter("tid", claims.TenantId).Filter("status", 0).Filter("is_deleted", 0).Update(orm.Params{"is_deleted": int8(1), "delete_time": now, "update_time": now})
|
||
if err != nil || count == 0 {
|
||
c.compensationError(400, "薪资单不存在或当前状态不可删除")
|
||
return
|
||
}
|
||
c.compensationOK(nil)
|
||
}
|
||
|
||
// UpdateStatus POST /backend/oa/compensation/payrolls/:id/status
|
||
func (c *BackendOaCompensationController) UpdateStatus() {
|
||
claims, err := c.compensationClaims()
|
||
if err != nil {
|
||
c.compensationError(401, "未登录或无权限")
|
||
return
|
||
}
|
||
id, valid := compensationID(c.Ctx.Input.Param(":id"))
|
||
if !valid {
|
||
c.compensationError(400, "无效薪资单ID")
|
||
return
|
||
}
|
||
var payload struct {
|
||
Status int8 `json:"status"`
|
||
}
|
||
if !c.parseBody(&payload) {
|
||
return
|
||
}
|
||
if payload.Status < 0 || payload.Status > 3 {
|
||
c.compensationError(400, "无效状态")
|
||
return
|
||
}
|
||
var row models.BackendOaPayroll
|
||
if models.Orm.QueryTable(new(models.BackendOaPayroll)).Filter("id", id).Filter("tid", claims.TenantId).Filter("is_deleted", 0).One(&row) != nil {
|
||
c.compensationError(404, "薪资单不存在")
|
||
return
|
||
}
|
||
if row.Status == 2 && payload.Status != 2 {
|
||
c.compensationError(400, "已发放薪资单不可修改状态")
|
||
return
|
||
}
|
||
now := time.Now()
|
||
row.Status, row.UpdateTime = payload.Status, &now
|
||
if payload.Status == 1 {
|
||
row.ConfirmedAt = &now
|
||
}
|
||
if payload.Status == 2 {
|
||
row.PaidAt = &now
|
||
}
|
||
if _, err = models.Orm.Update(&row, "Status", "ConfirmedAt", "PaidAt", "UpdateTime"); err != nil {
|
||
c.compensationError(500, "更新状态失败")
|
||
return
|
||
}
|
||
c.compensationOK(payrollMap(row))
|
||
}
|
||
|
||
// Dashboard GET /backend/oa/compensation/dashboard
|
||
func (c *BackendOaCompensationController) Dashboard() {
|
||
claims, err := c.compensationClaims()
|
||
if err != nil {
|
||
c.compensationError(401, "未登录或无权限")
|
||
return
|
||
}
|
||
month := strings.TrimSpace(c.GetString("payroll_month"))
|
||
if !compensationMonthPattern.MatchString(month) {
|
||
month = time.Now().Format("2006-01")
|
||
}
|
||
var rows []models.BackendOaPayroll
|
||
_, err = models.Orm.QueryTable(new(models.BackendOaPayroll)).Filter("tid", claims.TenantId).Filter("payroll_month", month).Filter("is_deleted", 0).All(&rows)
|
||
if err != nil && err != orm.ErrNoRows {
|
||
log.Printf("薪酬仪表盘统计失败: tid=%d month=%q err=%v", claims.TenantId, month, err)
|
||
c.compensationError(500, "统计失败,请检查薪酬管理数据表是否已创建")
|
||
return
|
||
}
|
||
totalNet, totalGross, draft, confirmed, paid := 0.0, 0.0, 0, 0, 0
|
||
for _, row := range rows {
|
||
totalNet += row.NetSalary
|
||
totalGross += row.GrossSalary
|
||
switch row.Status {
|
||
case 0:
|
||
draft++
|
||
case 1:
|
||
confirmed++
|
||
case 2:
|
||
paid++
|
||
}
|
||
}
|
||
c.compensationOK(map[string]interface{}{"payroll_month": month, "employee_count": len(rows), "total_gross_salary": compensationMoney(totalGross), "total_net_salary": compensationMoney(totalNet), "draft_count": draft, "confirmed_count": confirmed, "paid_count": paid})
|
||
}
|