spin-backend/internal/httpapi/handlers_incentive.go
theorose49 f83724b995
All checks were successful
build-and-push / build (push) Successful in 39s
feat: spin 백엔드 전체 구현 (근무·프로젝트·인센티브·회계)
- config/db/storage/auth/router/perms: eQMS 규약 미러링, 권한 2-tier
  (관리자 전체 / 구성원 본인·신청만), oauth2-proxy 헤더 인증 + DEV_AUTH mock
- 모델: 구성원/부서, 근무(출퇴근·휴가·공가·초과), 프로젝트(회사/제품/버전·
  작업자portion·담당자·태스크·계약·첨부·분할입금), 인센티브(설정·단계·
  유저배분·분기정산), 회계(거래·세금)
- internal/worktime: 근로기준법 월 집계 엔진
- internal/incentive: BE/non-BE × 계약금/중도금/잔금 3단계 계산 + 시뮬레이션
- 시드 데이터, Go 멀티스테이지 Dockerfile
- ADMIN_GROUPS 기본값 'admin' (전 내부 앱 공통 그룹)

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

451 lines
14 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"
"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.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)
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, "")
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,
})
}