All checks were successful
build-and-push / build (push) Successful in 35s
- 직급에 인턴 추가(기본 할당량 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>
469 lines
15 KiB
Go
469 lines
15 KiB
Go
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,
|
||
})
|
||
}
|