407 lines
12 KiB
Go
407 lines
12 KiB
Go
package teamsalarypolicy
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"hyapp-admin-server/internal/appctx"
|
|
"hyapp-admin-server/internal/model"
|
|
"hyapp-admin-server/internal/repository"
|
|
)
|
|
|
|
const (
|
|
policyTypeBD = "bd"
|
|
policyTypeAdmin = "admin"
|
|
|
|
policyStatusActive = "active"
|
|
policyStatusDisabled = "disabled"
|
|
|
|
settlementTriggerAutomatic = "automatic"
|
|
settlementTriggerManual = "manual"
|
|
)
|
|
|
|
type Service struct {
|
|
store *repository.Store
|
|
}
|
|
|
|
func NewService(store *repository.Store) *Service {
|
|
return &Service{store: store}
|
|
}
|
|
|
|
func (s *Service) List(appCode string, options repository.TeamSalaryPolicyListOptions) ([]policyDTO, int64, error) {
|
|
options.AppCode = appctx.Normalize(appCode)
|
|
options.PolicyType = normalizePolicyType(options.PolicyType)
|
|
if options.PolicyType == "" {
|
|
return nil, 0, errors.New("政策类型不正确")
|
|
}
|
|
options.Status = normalizeStatusFilter(options.Status)
|
|
options.SettlementTriggerMode = normalizeSettlementTriggerModeFilter(options.SettlementTriggerMode)
|
|
items, total, err := s.store.ListTeamSalaryPolicies(options)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
out := make([]policyDTO, 0, len(items))
|
|
for _, item := range items {
|
|
out = append(out, policyFromModel(item))
|
|
}
|
|
return out, total, nil
|
|
}
|
|
|
|
func (s *Service) Create(appCode string, actorID uint, req policyRequest) (policyDTO, error) {
|
|
item, err := policyModelFromRequest(appctx.Normalize(appCode), actorID, req)
|
|
if err != nil {
|
|
return policyDTO{}, err
|
|
}
|
|
if err := s.ensureNoActiveOverlap(item, 0); err != nil {
|
|
return policyDTO{}, err
|
|
}
|
|
if err := s.store.CreateTeamSalaryPolicy(&item); err != nil {
|
|
return policyDTO{}, err
|
|
}
|
|
return policyFromModel(item), nil
|
|
}
|
|
|
|
func (s *Service) Update(appCode string, actorID uint, id uint, req policyRequest) (policyDTO, error) {
|
|
policyType := normalizePolicyType(req.PolicyType)
|
|
if policyType == "" {
|
|
return policyDTO{}, errors.New("政策类型不正确")
|
|
}
|
|
item, err := s.store.GetTeamSalaryPolicy(appctx.Normalize(appCode), policyType, id)
|
|
if err != nil {
|
|
return policyDTO{}, err
|
|
}
|
|
updated, err := policyModelFromRequest(item.AppCode, actorID, req)
|
|
if err != nil {
|
|
return policyDTO{}, err
|
|
}
|
|
item.Name = updated.Name
|
|
item.RegionID = updated.RegionID
|
|
item.Status = updated.Status
|
|
item.SettlementTriggerMode = updated.SettlementTriggerMode
|
|
item.EffectiveFromMS = updated.EffectiveFromMS
|
|
item.EffectiveToMS = updated.EffectiveToMS
|
|
item.Description = updated.Description
|
|
item.UpdatedByAdminID = actorID
|
|
item.Levels = updated.Levels
|
|
if err := s.ensureNoActiveOverlap(item, id); err != nil {
|
|
return policyDTO{}, err
|
|
}
|
|
if err := s.store.UpdateTeamSalaryPolicy(&item); err != nil {
|
|
return policyDTO{}, err
|
|
}
|
|
return policyFromModel(item), nil
|
|
}
|
|
|
|
func (s *Service) Delete(appCode string, policyType string, id uint) error {
|
|
policyType = normalizePolicyType(policyType)
|
|
if policyType == "" {
|
|
return errors.New("政策类型不正确")
|
|
}
|
|
if _, err := s.store.GetTeamSalaryPolicy(appctx.Normalize(appCode), policyType, id); err != nil {
|
|
return err
|
|
}
|
|
return s.store.DeleteTeamSalaryPolicy(appctx.Normalize(appCode), policyType, id)
|
|
}
|
|
|
|
func (s *Service) ensureNoActiveOverlap(item model.TeamSalaryPolicy, excludeID uint) error {
|
|
if item.Status != policyStatusActive {
|
|
return nil
|
|
}
|
|
overlap, err := s.store.HasOverlappingActiveTeamSalaryPolicy(item.AppCode, item.PolicyType, item.RegionID, item.EffectiveFromMS, item.EffectiveToMS, excludeID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if overlap {
|
|
return errors.New("同一区域同一时间只能存在一个启用政策")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func policyModelFromRequest(appCode string, actorID uint, req policyRequest) (model.TeamSalaryPolicy, error) {
|
|
policyType := normalizePolicyType(req.PolicyType)
|
|
if policyType == "" {
|
|
return model.TeamSalaryPolicy{}, errors.New("政策类型不正确")
|
|
}
|
|
name := strings.TrimSpace(req.Name)
|
|
if name == "" || len([]rune(name)) > 120 {
|
|
return model.TeamSalaryPolicy{}, errors.New("政策名称不正确")
|
|
}
|
|
if req.RegionID <= 0 {
|
|
return model.TeamSalaryPolicy{}, errors.New("请选择适用区域")
|
|
}
|
|
status := normalizeStatus(req.Status)
|
|
if status == "" {
|
|
// BD/Admin 政策默认先保存为停用,运营确认等级表无误后再启用,避免自动结算读到半成品。
|
|
status = policyStatusDisabled
|
|
}
|
|
if !validStatus(status) {
|
|
return model.TeamSalaryPolicy{}, errors.New("政策状态不正确")
|
|
}
|
|
triggerMode := normalizeSettlementTriggerMode(req.SettlementTriggerMode)
|
|
if triggerMode == "" {
|
|
triggerMode = settlementTriggerAutomatic
|
|
}
|
|
if !validSettlementTriggerMode(triggerMode) {
|
|
return model.TeamSalaryPolicy{}, errors.New("结算触发方式不正确")
|
|
}
|
|
if req.EffectiveFromMS < 0 || req.EffectiveToMS < 0 || (req.EffectiveToMS > 0 && req.EffectiveToMS <= req.EffectiveFromMS) {
|
|
return model.TeamSalaryPolicy{}, errors.New("政策生效时间不正确")
|
|
}
|
|
description := strings.TrimSpace(req.Description)
|
|
if len([]rune(description)) > 255 {
|
|
return model.TeamSalaryPolicy{}, errors.New("备注不能超过 255 个字符")
|
|
}
|
|
levels, err := levelModelsFromRequest(policyType, req.Levels)
|
|
if err != nil {
|
|
return model.TeamSalaryPolicy{}, err
|
|
}
|
|
return model.TeamSalaryPolicy{
|
|
AppCode: appCode,
|
|
PolicyType: policyType,
|
|
Name: name,
|
|
RegionID: req.RegionID,
|
|
Status: status,
|
|
SettlementTriggerMode: triggerMode,
|
|
EffectiveFromMS: req.EffectiveFromMS,
|
|
EffectiveToMS: req.EffectiveToMS,
|
|
Description: description,
|
|
CreatedByAdminID: actorID,
|
|
UpdatedByAdminID: actorID,
|
|
Levels: levels,
|
|
}, nil
|
|
}
|
|
|
|
func levelModelsFromRequest(policyType string, requests []levelRequest) ([]model.TeamSalaryLevel, error) {
|
|
if len(requests) == 0 {
|
|
return nil, errors.New("至少需要配置一个等级")
|
|
}
|
|
levels := append([]levelRequest(nil), requests...)
|
|
sortLevelRequests(levels)
|
|
seen := map[int32]struct{}{}
|
|
out := make([]model.TeamSalaryLevel, 0, len(levels))
|
|
var previousThresholdCents int64
|
|
var previousSalaryCents int64
|
|
for index, req := range levels {
|
|
if req.Level <= 0 {
|
|
return nil, errors.New("等级必须大于 0")
|
|
}
|
|
if _, ok := seen[req.Level]; ok {
|
|
return nil, fmt.Errorf("等级 %d 重复", req.Level)
|
|
}
|
|
seen[req.Level] = struct{}{}
|
|
threshold, thresholdCents, err := parseFixedDecimal(req.ThresholdUSD, 2, false, thresholdFieldName(policyType))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("等级 %d %w", req.Level, err)
|
|
}
|
|
if index > 0 && thresholdCents <= previousThresholdCents {
|
|
return nil, fmt.Errorf("等级 %d 收入门槛必须大于上一等级", req.Level)
|
|
}
|
|
// rate_percent 保存百分比文本,例如 8 表示 8%;允许 4 位小数应对后续更细比例。
|
|
rate, _, err := parseFixedDecimal(req.RatePercent, 4, false, "提成比例")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("等级 %d %w", req.Level, err)
|
|
}
|
|
salary, salaryCents, err := parseFixedDecimal(req.SalaryUSD, 2, true, salaryFieldName(policyType))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("等级 %d %w", req.Level, err)
|
|
}
|
|
if index > 0 && salaryCents < previousSalaryCents {
|
|
return nil, fmt.Errorf("等级 %d 累计工资不能小于上一等级", req.Level)
|
|
}
|
|
status := normalizeStatus(req.Status)
|
|
if status == "" {
|
|
status = policyStatusActive
|
|
}
|
|
if !validStatus(status) {
|
|
return nil, fmt.Errorf("等级 %d 状态不正确", req.Level)
|
|
}
|
|
sortOrder := req.SortOrder
|
|
if sortOrder == 0 {
|
|
sortOrder = req.Level
|
|
}
|
|
out = append(out, model.TeamSalaryLevel{
|
|
LevelNo: req.Level,
|
|
ThresholdUSD: threshold,
|
|
RatePercent: rate,
|
|
SalaryUSD: salary,
|
|
Status: status,
|
|
SortOrder: sortOrder,
|
|
})
|
|
previousThresholdCents = thresholdCents
|
|
previousSalaryCents = salaryCents
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func thresholdFieldName(policyType string) string {
|
|
if policyType == policyTypeAdmin {
|
|
return "BD工资门槛"
|
|
}
|
|
return "下属主播工资门槛"
|
|
}
|
|
|
|
func salaryFieldName(policyType string) string {
|
|
if policyType == policyTypeAdmin {
|
|
return "Admin工资"
|
|
}
|
|
return "BD工资"
|
|
}
|
|
|
|
func sortLevelRequests(items []levelRequest) {
|
|
for i := 1; i < len(items); i++ {
|
|
item := items[i]
|
|
j := i - 1
|
|
for j >= 0 && items[j].Level > item.Level {
|
|
items[j+1] = items[j]
|
|
j--
|
|
}
|
|
items[j+1] = item
|
|
}
|
|
}
|
|
|
|
func parseFixedDecimal(raw string, scale int, allowZero bool, field string) (string, int64, error) {
|
|
value := strings.TrimSpace(raw)
|
|
if strings.HasPrefix(value, "+") {
|
|
value = strings.TrimPrefix(value, "+")
|
|
}
|
|
if value == "" || strings.HasPrefix(value, "-") {
|
|
return "", 0, fmt.Errorf("%s不正确", field)
|
|
}
|
|
parts := strings.Split(value, ".")
|
|
if len(parts) > 2 || parts[0] == "" || !allDigits(parts[0]) {
|
|
return "", 0, fmt.Errorf("%s不正确", field)
|
|
}
|
|
if len(parts) == 2 && (parts[1] == "" || len(parts[1]) > scale || !allDigits(parts[1])) {
|
|
return "", 0, fmt.Errorf("%s最多支持 %d 位小数", field, scale)
|
|
}
|
|
whole, err := strconv.ParseInt(parts[0], 10, 64)
|
|
if err != nil {
|
|
return "", 0, fmt.Errorf("%s过大", field)
|
|
}
|
|
multiplier := pow10(scale)
|
|
if whole > (1<<62)/multiplier {
|
|
return "", 0, fmt.Errorf("%s过大", field)
|
|
}
|
|
var fraction int64
|
|
if len(parts) == 2 {
|
|
fractionText := parts[1] + strings.Repeat("0", scale-len(parts[1]))
|
|
fraction, err = strconv.ParseInt(fractionText, 10, 64)
|
|
if err != nil {
|
|
return "", 0, fmt.Errorf("%s不正确", field)
|
|
}
|
|
}
|
|
scaled := whole*multiplier + fraction
|
|
if !allowZero && scaled <= 0 {
|
|
return "", 0, fmt.Errorf("%s必须大于 0", field)
|
|
}
|
|
return strconv.FormatInt(whole, 10) + "." + fixedDigits(fraction, scale), scaled, nil
|
|
}
|
|
|
|
func fixedDigits(value int64, scale int) string {
|
|
text := strconv.FormatInt(value, 10)
|
|
if len(text) >= scale {
|
|
return text
|
|
}
|
|
return strings.Repeat("0", scale-len(text)) + text
|
|
}
|
|
|
|
func trimDecimalZeros(value string) string {
|
|
value = strings.TrimSpace(value)
|
|
if !strings.Contains(value, ".") {
|
|
return value
|
|
}
|
|
value = strings.TrimRight(value, "0")
|
|
return strings.TrimRight(value, ".")
|
|
}
|
|
|
|
func pow10(scale int) int64 {
|
|
var out int64 = 1
|
|
for i := 0; i < scale; i++ {
|
|
out *= 10
|
|
}
|
|
return out
|
|
}
|
|
|
|
func allDigits(value string) bool {
|
|
for _, r := range value {
|
|
if r < '0' || r > '9' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func firstNonBlank(values ...string) string {
|
|
for _, value := range values {
|
|
if strings.TrimSpace(value) != "" {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func normalizePolicyType(value string) string {
|
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
|
case policyTypeBD:
|
|
return policyTypeBD
|
|
case policyTypeAdmin, "super_admin", "super-admin":
|
|
return policyTypeAdmin
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func normalizeStatus(value string) string {
|
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
|
case "", policyStatusActive, "enabled":
|
|
if strings.TrimSpace(value) == "" {
|
|
return ""
|
|
}
|
|
return policyStatusActive
|
|
case policyStatusDisabled, "inactive":
|
|
return policyStatusDisabled
|
|
default:
|
|
return strings.ToLower(strings.TrimSpace(value))
|
|
}
|
|
}
|
|
|
|
func normalizeStatusFilter(value string) string {
|
|
status := normalizeStatus(value)
|
|
if !validStatus(status) {
|
|
return ""
|
|
}
|
|
return status
|
|
}
|
|
|
|
func validStatus(value string) bool {
|
|
return value == policyStatusActive || value == policyStatusDisabled
|
|
}
|
|
|
|
func normalizeSettlementTriggerMode(value string) string {
|
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
|
case "", settlementTriggerAutomatic, "auto":
|
|
if strings.TrimSpace(value) == "" {
|
|
return ""
|
|
}
|
|
return settlementTriggerAutomatic
|
|
case settlementTriggerManual, "manual_settlement":
|
|
return settlementTriggerManual
|
|
default:
|
|
return strings.ToLower(strings.TrimSpace(value))
|
|
}
|
|
}
|
|
|
|
func normalizeSettlementTriggerModeFilter(value string) string {
|
|
mode := normalizeSettlementTriggerMode(value)
|
|
if !validSettlementTriggerMode(mode) {
|
|
return ""
|
|
}
|
|
return mode
|
|
}
|
|
|
|
func validSettlementTriggerMode(value string) bool {
|
|
return value == settlementTriggerAutomatic || value == settlementTriggerManual
|
|
}
|