2026-05-29 19:56:14 +08:00

210 lines
7.7 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 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 ""
}