spin-backend/internal/httpapi/handlers_incentive.go
theorose49 df09d23662
All checks were successful
build-and-push / build (push) Successful in 35s
feat: 인턴 직급 + 초과근무 관리자 집계화 + SSO 로그아웃 URL + 디바이스/FCM
- 직급에 인턴 추가(기본 할당량 15), 직책(position)은 UI에서 제거(컬럼은 유지)
- 초과근무: 유저 신청 제거 → 관리자 근무관리에서 실제 출퇴근 기록 기반 자동 집계
- 로그아웃: infra 공통 LOGOUT_URL(/me로 전달) 사용 → oauth2-proxy 종료 + Keycloak end-session
- (이전 커밋 포함) Device 등록 + FCM HTTP v1 sender + notify 연동

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 10:55:53 +09:00

469 lines
15 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 httpapi
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"time"
"spin/internal/incentive"
"spin/internal/models"
"github.com/go-chi/chi/v5"
)
// ---- config ---------------------------------------------------------------
// incentiveConfig loads (or defaults) the IncentiveConfig for a year and adapts
// it to the engine's plain Config.
func (s *Server) incentiveConfig(year int) (models.IncentiveConfig, incentive.Config) {
var c models.IncentiveConfig
if err := s.db.Where("year = ?", year).First(&c).Error; err != nil {
c = models.IncentiveConfig{Year: year, PointRate: 1_000_000, DepositPct: 30,
MiddlePct: 40, FinalPct: 30, NonBECompanyPct: 60, NonBEPartnerPct: 40,
RankQuota: defaultQuota()}
}
return c, toEngineConfig(c)
}
func defaultQuota() map[string]interface{} {
return map[string]interface{}{
models.RankIntern: 15.0, models.RankJunior: 30.0, models.RankSenior: 50.0,
models.RankLead: 80.0, models.RankPartner: 120.0,
}
}
func toEngineConfig(c models.IncentiveConfig) incentive.Config {
quota := map[string]float64{}
for k, v := range c.RankQuota {
if f, ok := toFloat(v); ok {
quota[k] = f
}
}
return incentive.Config{
PointRate: c.PointRate, DepositPct: c.DepositPct, MiddlePct: c.MiddlePct,
FinalPct: c.FinalPct, NonBECompanyPct: c.NonBECompanyPct,
NonBEPartnerPct: c.NonBEPartnerPct, RankQuota: quota,
}
}
func toFloat(v interface{}) (float64, bool) {
switch n := v.(type) {
case float64:
return n, true
case float32:
return float64(n), true
case int:
return float64(n), true
case int64:
return float64(n), true
case json.Number:
// GORM's datatypes.JSONMap decodes JSON numbers as json.Number.
f, err := n.Float64()
return f, err == nil
case string:
f, err := strconv.ParseFloat(n, 64)
return f, err == nil
}
return 0, false
}
func yearParam(r *http.Request) int {
if y, err := strconv.Atoi(r.URL.Query().Get("year")); err == nil && y > 0 {
return y
}
return time.Now().Year()
}
func (s *Server) handleGetIncentiveConfig(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
c, _ := s.incentiveConfig(yearParam(r))
writeJSON(w, http.StatusOK, c)
}
func (s *Server) handlePutIncentiveConfig(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var in models.IncentiveConfig
if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if in.Year == 0 {
in.Year = time.Now().Year()
}
var existing models.IncentiveConfig
if err := s.db.Where("year = ?", in.Year).First(&existing).Error; err == nil {
in.ID = existing.ID
s.db.Save(&in)
} else {
s.db.Create(&in)
}
s.audit(r, "update", "incentive_config", strconv.Itoa(in.Year), "")
writeJSON(w, http.StatusOK, in)
}
// ---- recompute (rebuild stages + allocations from contract) ---------------
func (s *Server) projectMemberPortions(projectID string) []incentive.MemberPortion {
var pms []models.ProjectMember
s.db.Where("project_id = ?", projectID).Find(&pms)
out := make([]incentive.MemberPortion, 0, len(pms))
for _, pm := range pms {
isPartner := false
if m := s.lookupMember(pm.MemberEmail); m != nil {
isPartner = m.IsPartner || m.Rank == models.RankPartner
}
out = append(out, incentive.MemberPortion{Email: pm.MemberEmail, Portion: pm.Portion, IsPartner: isPartner})
}
return out
}
func (s *Server) handleRecomputeProject(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
pid := chi.URLParam(r, "id")
var contract models.Contract
if err := s.db.First(&contract, "project_id = ?", pid).Error; err != nil {
writeError(w, http.StatusBadRequest, "계약 정보(BE/계약금액)를 먼저 입력하세요")
return
}
year := yearParam(r)
_, eng := s.incentiveConfig(year)
stages := incentive.ComputeStages(contract.TotalAmount, contract.BEAmount, eng)
// upsert PaymentStage rows (keep status/dates on existing kind×scope).
for _, st := range stages {
var existing models.PaymentStage
err := s.db.Where("project_id = ? AND kind = ? AND scope = ?", pid, st.Kind, st.Scope).First(&existing).Error
if err != nil {
s.db.Create(&models.PaymentStage{ProjectID: pid, Kind: st.Kind, Scope: st.Scope,
Amount: st.Amount, Pct: st.Pct, Status: models.FixPlanned})
} else {
existing.Amount = st.Amount
existing.Pct = st.Pct
s.db.Save(&existing)
}
}
// rebuild non-override UserIncentive rows.
members := s.projectMemberPortions(pid)
allocs := incentive.ComputeAllocs(stages, members, eng)
s.db.Where("project_id = ? AND \"override\" = ?", pid, false).Delete(&models.UserIncentive{})
var stageRows []models.PaymentStage
s.db.Where("project_id = ?", pid).Find(&stageRows)
stageID := map[string]string{}
stageStatus := map[string]string{}
for _, sr := range stageRows {
stageID[sr.Kind+"|"+sr.Scope] = sr.ID
stageStatus[sr.Kind+"|"+sr.Scope] = sr.Status
}
for _, a := range allocs {
key := a.Kind + "|" + a.Scope
// skip member+stage that already has an override row
var cnt int64
s.db.Model(&models.UserIncentive{}).
Where("project_id = ? AND lower(member_email) = lower(?) AND kind = ? AND scope = ? AND \"override\" = ?",
pid, a.Email, a.Kind, a.Scope, true).Count(&cnt)
if cnt > 0 {
continue
}
s.db.Create(&models.UserIncentive{ProjectID: pid, MemberEmail: a.Email, StageID: stageID[key],
Kind: a.Kind, Scope: a.Scope, Year: year, Portion: a.Portion, Amount: a.Amount,
Points: a.Points, FixStatus: stageStatus[key]})
}
s.audit(r, "recompute", "incentive", pid, "")
writeJSON(w, http.StatusOK, map[string]interface{}{"stages": len(stages), "allocs": len(allocs)})
}
func (s *Server) handleListStages(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var out []models.PaymentStage
q := s.db.Order("scope asc, kind asc")
if pid := r.URL.Query().Get("projectId"); pid != "" {
q = q.Where("project_id = ?", pid)
}
q.Find(&out)
writeJSON(w, http.StatusOK, out)
}
// handleSetStageStatus toggles a stage's fix lifecycle and propagates the status
// to its non-override UserIncentive rows (반영중 → 반영완료 등).
func (s *Server) handleSetStageStatus(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var st models.PaymentStage
if err := s.db.First(&st, "id = ?", chi.URLParam(r, "stId")).Error; err != nil {
writeError(w, http.StatusNotFound, "단계를 찾을 수 없습니다")
return
}
var body struct {
Status string `json:"status"`
FixedDate string `json:"fixedDate"`
}
decodeJSON(r, &body)
st.Status = body.Status
if body.FixedDate != "" {
st.FixedDate = body.FixedDate
}
s.db.Save(&st)
// propagate to non-override allocations
now := time.Now()
updates := map[string]interface{}{"fix_status": body.Status}
if body.Status == models.FixApplied {
updates["applied_at"] = &now
}
if body.Status == models.FixPaid {
updates["paid_at"] = &now
}
s.db.Model(&models.UserIncentive{}).
Where("stage_id = ? AND \"override\" = ?", st.ID, false).Updates(updates)
s.audit(r, "stage_status", "payment_stage", st.ID, body.Status)
// notify affected members on 반영완료/지급완료 (BE/non-BE 등 내부 개념은 노출하지 않음)
if body.Status == models.FixApplied || body.Status == models.FixPaid {
msg := "인센티브 포인트가 반영되었습니다."
if body.Status == models.FixPaid {
msg = "인센티브가 지급되었습니다."
}
var emails []string
s.db.Model(&models.UserIncentive{}).Where("stage_id = ?", st.ID).Distinct().Pluck("member_email", &emails)
for _, e := range emails {
s.notify(e, "incentive", "인센티브 업데이트", msg, "/incentive")
}
}
writeJSON(w, http.StatusOK, st)
}
// ---- user incentives ------------------------------------------------------
func (s *Server) handleListUserIncentives(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
q := s.db.Order("created_at desc")
if pid := r.URL.Query().Get("projectId"); pid != "" {
q = q.Where("project_id = ?", pid)
}
if em := lc(r.URL.Query().Get("email")); em != "" {
q = q.Where("lower(member_email) = ?", em)
}
var out []models.UserIncentive
q.Find(&out)
writeJSON(w, http.StatusOK, out)
}
// handlePatchUserIncentive is the custom-override entry point: any field can be
// hand-edited and the row is flagged Override so recompute won't clobber it.
func (s *Server) handlePatchUserIncentive(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var ui models.UserIncentive
if err := s.db.First(&ui, "id = ?", chi.URLParam(r, "uiId")).Error; err != nil {
writeError(w, http.StatusNotFound, "내역을 찾을 수 없습니다")
return
}
var patch map[string]interface{}
if err := decodeJSON(r, &patch); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
delete(patch, "id")
patch["override"] = true // any manual edit pins the row
s.db.Model(&ui).Updates(patch)
s.db.First(&ui, "id = ?", ui.ID)
s.audit(r, "override", "user_incentive", ui.ID, "")
writeJSON(w, http.StatusOK, ui)
}
// ---- member dashboard -----------------------------------------------------
type myIncentive struct {
Year int `json:"year"`
Rank string `json:"rank"`
Quota float64 `json:"quota"`
PointsTotal float64 `json:"pointsTotal"` // all points (any status)
PointsApplied float64 `json:"pointsApplied"` // applied+paid
ExcessPoints float64 `json:"excessPoints"`
PointRate float64 `json:"pointRate"`
EstPayout float64 `json:"estPayout"`
Items []models.UserIncentive `json:"items"`
ByProject map[string]float64 `json:"byProject"`
}
func (s *Server) handleMyIncentive(w http.ResponseWriter, r *http.Request) {
email := s.email(r)
if s.isAdmin(r) {
if q := lc(r.URL.Query().Get("email")); q != "" {
email = q
}
}
year := yearParam(r)
cfg, eng := s.incentiveConfig(year)
var items []models.UserIncentive
s.db.Where("lower(member_email) = ? AND year = ?", email, year).Order("created_at desc").Find(&items)
total, applied := 0.0, 0.0
byProject := map[string]float64{}
for _, it := range items {
total += it.Points
if it.FixStatus == models.FixApplied || it.FixStatus == models.FixPaid {
applied += it.Points
}
byProject[it.ProjectID] += it.Points
}
rank := ""
if m := s.lookupMember(email); m != nil {
rank = m.Rank
}
st := incentive.ComputeSettlement(email, rank, applied, 0, eng)
writeJSON(w, http.StatusOK, myIncentive{
Year: year, Rank: rank, Quota: st.Quota, PointsTotal: total, PointsApplied: applied,
ExcessPoints: st.ExcessPoints, PointRate: cfg.PointRate, EstPayout: st.PayoutAmount,
Items: items, ByProject: byProject,
})
}
// ---- quarterly settlement -------------------------------------------------
func (s *Server) handleListSettlements(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
q := s.db.Order("year desc, quarter desc")
if y, err := strconv.Atoi(r.URL.Query().Get("year")); err == nil && y > 0 {
q = q.Where("year = ?", y)
}
var out []models.QuarterlySettlement
q.Find(&out)
writeJSON(w, http.StatusOK, out)
}
// handleRunSettlement computes (or refreshes) settlement rows for a year+quarter.
func (s *Server) handleRunSettlement(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var body struct {
Year int `json:"year"`
Quarter int `json:"quarter"`
}
decodeJSON(r, &body)
if body.Year == 0 {
body.Year = time.Now().Year()
}
if body.Quarter == 0 {
body.Quarter = (int(time.Now().Month())-1)/3 + 1
}
_, eng := s.incentiveConfig(body.Year)
// applied points per member up to this quarter (cumulative within the year).
var members []models.Member
s.db.Find(&members)
var results []models.QuarterlySettlement
for _, m := range members {
var applied float64
s.db.Model(&models.UserIncentive{}).
Where("lower(member_email) = lower(?) AND year = ? AND (fix_status = ? OR fix_status = ?)",
m.Email, body.Year, models.FixApplied, models.FixPaid).
Select("COALESCE(SUM(points),0)").Scan(&applied)
// prior paid this year
var paidYTD float64
s.db.Model(&models.QuarterlySettlement{}).
Where("lower(member_email) = lower(?) AND year = ? AND quarter < ? AND fixed = ?",
m.Email, body.Year, body.Quarter, true).
Select("COALESCE(SUM(payout_points),0)").Scan(&paidYTD)
st := incentive.ComputeSettlement(m.Email, m.Rank, applied, paidYTD, eng)
if st.PointsCumul == 0 && st.PayoutPoints == 0 {
continue
}
var existing models.QuarterlySettlement
row := models.QuarterlySettlement{MemberEmail: m.Email, Year: body.Year, Quarter: body.Quarter,
Rank: m.Rank, Quota: st.Quota, PointsCumul: st.PointsCumul, ExcessPoints: st.ExcessPoints,
PaidPointsYTD: st.PaidPointsYTD, PayoutPoints: st.PayoutPoints, PayoutAmount: st.PayoutAmount}
if err := s.db.Where("lower(member_email)=lower(?) AND year=? AND quarter=?", m.Email, body.Year, body.Quarter).First(&existing).Error; err == nil {
if !existing.Fixed { // never overwrite a fixed row
row.ID = existing.ID
s.db.Save(&row)
}
row = existing
} else {
s.db.Create(&row)
}
results = append(results, row)
}
s.audit(r, "settlement_run", "settlement", strconv.Itoa(body.Year)+"Q"+strconv.Itoa(body.Quarter), "")
writeJSON(w, http.StatusOK, results)
}
// handleFixSettlement locks a settlement row (그 차익이 급여로 확정 지급).
func (s *Server) handleFixSettlement(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var st models.QuarterlySettlement
if err := s.db.First(&st, "id = ?", chi.URLParam(r, "sId")).Error; err != nil {
writeError(w, http.StatusNotFound, "정산을 찾을 수 없습니다")
return
}
now := time.Now()
st.Fixed = true
st.FixedAt = &now
s.db.Save(&st)
s.audit(r, "settlement_fix", "settlement", st.ID, "")
if st.PayoutPoints > 0 {
s.notify(st.MemberEmail, "settlement",
fmt.Sprintf("%d년 %d분기 인센티브 정산 확정", st.Year, st.Quarter),
"이번 분기 인센티브가 확정되었습니다. 내 인센티브에서 확인하세요.", "/incentive")
}
writeJSON(w, http.StatusOK, st)
}
// ---- simulation (pure, no persistence) ------------------------------------
type simRequest struct {
Year int `json:"year"`
Total float64 `json:"total"`
BE float64 `json:"be"`
Members []incentive.MemberPortion `json:"members"`
Config *incentive.Config `json:"config"` // optional overrides
}
func (s *Server) handleSimulate(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var req simRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
_, eng := s.incentiveConfig(yearParam(r))
if req.Config != nil {
eng = *req.Config
if eng.PointRate == 0 {
eng.PointRate = 1
}
}
stages := incentive.ComputeStages(req.Total, req.BE, eng)
allocs := incentive.ComputeAllocs(stages, req.Members, eng)
// per-member point totals
byMember := map[string]float64{}
for _, a := range allocs {
byMember[a.Email] += a.Points
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"stages": stages, "allocs": allocs, "byMember": byMember,
})
}