Files
yunzerwebsiteallinone/go/controllers/backend_oa_compensation.go
T

797 lines
31 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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})
}