210 lines
7.7 KiB
Go
210 lines
7.7 KiB
Go
package teamsalarysettlement
|
||
|
||
import (
|
||
"database/sql"
|
||
"fmt"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"hyapp-admin-server/internal/appctx"
|
||
"hyapp-admin-server/internal/modules/shared"
|
||
"hyapp-admin-server/internal/response"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
)
|
||
|
||
type Handler struct {
|
||
audit shared.OperationLogger
|
||
service *Service
|
||
}
|
||
|
||
// New 只做 HTTP 层组装,具体工资口径全部下沉到 Service,避免 handler 里复制账务逻辑。
|
||
func New(adminDB *sql.DB, walletDB *sql.DB, userDB *sql.DB, audit shared.OperationLogger) *Handler {
|
||
return &Handler{audit: audit, service: NewService(adminDB, walletDB, userDB)}
|
||
}
|
||
|
||
// Service 暴露给自动任务 runner 复用同一个结算引擎,保证手动和自动结算走同一套规则。
|
||
func (h *Handler) Service() *Service {
|
||
if h == nil {
|
||
return nil
|
||
}
|
||
return h.service
|
||
}
|
||
|
||
// ListPending 返回当前筛选条件下可产生正向工资差值的 BD/Admin 用户。
|
||
func (h *Handler) ListPending(c *gin.Context) {
|
||
req, ok := parsePendingQuery(c)
|
||
if !ok {
|
||
return
|
||
}
|
||
items, total, err := h.service.ListPending(c.Request.Context(), appctx.FromContext(c.Request.Context()), req)
|
||
if err != nil {
|
||
response.ServerError(c, "获取待结算工资失败")
|
||
return
|
||
}
|
||
response.OK(c, response.Page{Items: items, Page: req.Page, PageSize: req.PageSize, Total: total})
|
||
}
|
||
|
||
// ListRecords 返回已经生成 settlement_id 的 BD/Admin 结算记录,用于后台查账。
|
||
func (h *Handler) ListRecords(c *gin.Context) {
|
||
req, ok := parseRecordQuery(c)
|
||
if !ok {
|
||
return
|
||
}
|
||
items, total, err := h.service.ListRecords(c.Request.Context(), appctx.FromContext(c.Request.Context()), req)
|
||
if err != nil {
|
||
response.ServerError(c, "获取 BD/Admin 结算记录失败")
|
||
return
|
||
}
|
||
response.OK(c, response.Page{Items: items, Page: req.Page, PageSize: req.PageSize, Total: total})
|
||
}
|
||
|
||
// Settle 执行后台批量手动结算;前端传入 user_ids 后,Service 会再次按候选白名单过滤。
|
||
func (h *Handler) Settle(c *gin.Context) {
|
||
var req settleRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
response.BadRequest(c, "结算参数不正确")
|
||
return
|
||
}
|
||
req.PolicyType = normalizePolicyType(req.PolicyType)
|
||
req.TriggerMode = normalizeTriggerMode(req.TriggerMode)
|
||
req.CycleKey = strings.TrimSpace(req.CycleKey)
|
||
req.CountryCode = strings.ToUpper(strings.TrimSpace(req.CountryCode))
|
||
if req.PolicyType == "" {
|
||
response.BadRequest(c, "工资类型不正确")
|
||
return
|
||
}
|
||
if req.TriggerMode == "" {
|
||
response.BadRequest(c, "触发方式不正确")
|
||
return
|
||
}
|
||
result, err := h.service.Settle(c.Request.Context(), appctx.FromContext(c.Request.Context()), int64(shared.ActorFromContext(c).UserID), req)
|
||
if err != nil {
|
||
response.ServerError(c, "工资结算失败")
|
||
return
|
||
}
|
||
shared.OperationLog(c, h.audit, "settle-team-salary", "team_salary_settlement_records", "success", fmt.Sprintf("policy_type=%s cycle_key=%s success=%d skipped=%d failed=%d", result.PolicyType, result.CycleKey, result.SuccessCount, result.SkippedCount, result.FailureCount))
|
||
response.OK(c, result)
|
||
}
|
||
|
||
// parsePendingQuery 兼容 snake_case/camelCase 查询参数,便于前端直接传 API DTO 或内部模型字段。
|
||
func parsePendingQuery(c *gin.Context) (pendingQuery, bool) {
|
||
options := shared.ListOptions(c)
|
||
req := pendingQuery{
|
||
Page: options.Page,
|
||
PageSize: options.PageSize,
|
||
PolicyType: normalizePolicyType(firstQuery(c, "policy_type", "policyType")),
|
||
TriggerMode: normalizeTriggerMode(firstQuery(c, "trigger_mode", "triggerMode")),
|
||
CycleKey: strings.TrimSpace(firstQuery(c, "cycle_key", "cycleKey")),
|
||
CountryCode: strings.ToUpper(strings.TrimSpace(firstQuery(c, "country_code", "countryCode"))),
|
||
}
|
||
var ok bool
|
||
if req.RegionID, ok = optionalInt64(c, "region_id", "regionId"); !ok {
|
||
response.BadRequest(c, "区域 ID 不正确")
|
||
return pendingQuery{}, false
|
||
}
|
||
if req.CountryID, ok = optionalInt64(c, "country_id", "countryId"); !ok {
|
||
response.BadRequest(c, "国家 ID 不正确")
|
||
return pendingQuery{}, false
|
||
}
|
||
if req.UserID, ok = optionalInt64(c, "user_id", "userId"); !ok {
|
||
response.BadRequest(c, "用户 ID 不正确")
|
||
return pendingQuery{}, false
|
||
}
|
||
if raw := strings.TrimSpace(firstQuery(c, "policy_type", "policyType")); raw != "" && req.PolicyType == "" {
|
||
response.BadRequest(c, "工资类型不正确")
|
||
return pendingQuery{}, false
|
||
}
|
||
if raw := strings.TrimSpace(firstQuery(c, "trigger_mode", "triggerMode")); raw != "" && req.TriggerMode == "" {
|
||
response.BadRequest(c, "触发方式不正确")
|
||
return pendingQuery{}, false
|
||
}
|
||
return normalizePendingQuery(req), true
|
||
}
|
||
|
||
// parseRecordQuery 统一记录列表的分页、枚举和时间区间校验,避免把非法筛选传到 SQL 组装层。
|
||
func parseRecordQuery(c *gin.Context) (recordQuery, bool) {
|
||
options := shared.ListOptions(c)
|
||
req := recordQuery{
|
||
Page: options.Page,
|
||
PageSize: options.PageSize,
|
||
PolicyType: normalizePolicyType(firstQuery(c, "policy_type", "policyType")),
|
||
TriggerMode: normalizeTriggerMode(firstQuery(c, "trigger_mode", "triggerMode")),
|
||
CycleKey: strings.TrimSpace(firstQuery(c, "cycle_key", "cycleKey")),
|
||
Status: normalizeStatus(firstQuery(c, "status")),
|
||
CountryCode: strings.ToUpper(strings.TrimSpace(firstQuery(c, "country_code", "countryCode"))),
|
||
}
|
||
var ok bool
|
||
if req.RegionID, ok = optionalInt64(c, "region_id", "regionId"); !ok {
|
||
response.BadRequest(c, "区域 ID 不正确")
|
||
return recordQuery{}, false
|
||
}
|
||
if req.CountryID, ok = optionalInt64(c, "country_id", "countryId"); !ok {
|
||
response.BadRequest(c, "国家 ID 不正确")
|
||
return recordQuery{}, false
|
||
}
|
||
if req.UserID, ok = optionalInt64(c, "user_id", "userId"); !ok {
|
||
response.BadRequest(c, "用户 ID 不正确")
|
||
return recordQuery{}, false
|
||
}
|
||
if req.PolicyID, ok = optionalUint64(c, "policy_id", "policyId"); !ok {
|
||
response.BadRequest(c, "政策 ID 不正确")
|
||
return recordQuery{}, false
|
||
}
|
||
if req.StartAtMS, ok = optionalInt64(c, "start_at_ms", "startAtMs"); !ok {
|
||
response.BadRequest(c, "开始时间不正确")
|
||
return recordQuery{}, false
|
||
}
|
||
if req.EndAtMS, ok = optionalInt64(c, "end_at_ms", "endAtMs"); !ok {
|
||
response.BadRequest(c, "结束时间不正确")
|
||
return recordQuery{}, false
|
||
}
|
||
if req.StartAtMS > 0 && req.EndAtMS > 0 && req.StartAtMS >= req.EndAtMS {
|
||
response.BadRequest(c, "时间区间不正确")
|
||
return recordQuery{}, false
|
||
}
|
||
if raw := strings.TrimSpace(firstQuery(c, "policy_type", "policyType")); raw != "" && req.PolicyType == "" {
|
||
response.BadRequest(c, "工资类型不正确")
|
||
return recordQuery{}, false
|
||
}
|
||
if raw := strings.TrimSpace(firstQuery(c, "trigger_mode", "triggerMode")); raw != "" && req.TriggerMode == "" {
|
||
response.BadRequest(c, "触发方式不正确")
|
||
return recordQuery{}, false
|
||
}
|
||
if raw := strings.TrimSpace(firstQuery(c, "status")); raw != "" && strings.ToLower(raw) != "all" && req.Status == "" {
|
||
response.BadRequest(c, "状态不正确")
|
||
return recordQuery{}, false
|
||
}
|
||
return normalizeRecordQuery(req), true
|
||
}
|
||
|
||
// optionalInt64 解析可选正整数查询参数;空值代表不筛选。
|
||
func optionalInt64(c *gin.Context, keys ...string) (int64, bool) {
|
||
value := strings.TrimSpace(firstQuery(c, keys...))
|
||
if value == "" {
|
||
return 0, true
|
||
}
|
||
parsed, err := strconv.ParseInt(value, 10, 64)
|
||
return parsed, err == nil && parsed >= 0
|
||
}
|
||
|
||
// optionalUint64 解析可选无符号整数,当前用于 policy_id。
|
||
func optionalUint64(c *gin.Context, keys ...string) (uint64, bool) {
|
||
value := strings.TrimSpace(firstQuery(c, keys...))
|
||
if value == "" {
|
||
return 0, true
|
||
}
|
||
parsed, err := strconv.ParseUint(value, 10, 64)
|
||
return parsed, err == nil
|
||
}
|
||
|
||
// firstQuery 按优先级读取同义查询参数,避免每个字段重复写 snake/camel fallback。
|
||
func firstQuery(c *gin.Context, keys ...string) string {
|
||
for _, key := range keys {
|
||
if value := strings.TrimSpace(c.Query(key)); value != "" {
|
||
return value
|
||
}
|
||
}
|
||
return ""
|
||
}
|