feat: spin 백엔드 전체 구현 (근무·프로젝트·인센티브·회계)
build-and-push / build (push) Successful in 39s

- 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>
This commit is contained in:
theorose49
2026-06-28 08:57:35 +09:00
co-authored by Claude Opus 4.8
parent ce4023ca59
commit f83724b995
28 changed files with 3960 additions and 5 deletions
+161
View File
@@ -0,0 +1,161 @@
// Package incentive implements spin's incentive-point engine.
//
// Money model (per project, from its Contract):
// - BE = break-even floor amount → its 3-stage splits feed the WORKER
// incentive-point pool, distributed by each member's portion.
// - non-BE = (total − BE) → split between the COMPANY and the PARTNERS by a
// configured ratio; the partner share becomes partner incentive
// points (distributed among partners by portion).
//
// Each scope is broken into 계약금/중도금/잔금 (deposit/middle/final) by the
// IncentiveConfig percentages. Points = KRW ÷ pointRate (환율). Quarterly
// settlement pays out points accumulated beyond a member's rank quota.
package incentive
import "math"
// Config mirrors the persisted IncentiveConfig (decoupled for pure compute).
type Config struct {
PointRate float64
DepositPct float64
MiddlePct float64
FinalPct float64
NonBECompanyPct float64
NonBEPartnerPct float64
RankQuota map[string]float64
}
// MemberPortion is one project worker's contribution share.
type MemberPortion struct {
Email string
Portion float64 // 0–100
IsPartner bool
}
// Stage is a computed (kind, scope) money bucket for a project.
type Stage struct {
Kind string `json:"kind"`
Scope string `json:"scope"`
Amount float64 `json:"amount"`
Pct float64 `json:"pct"`
}
// UserAlloc is one member's computed allocation for a stage.
type UserAlloc struct {
Email string `json:"email"`
Kind string `json:"kind"`
Scope string `json:"scope"`
Portion float64 `json:"portion"`
Amount float64 `json:"amount"`
Points float64 `json:"points"`
}
const (
KindDeposit = "deposit"
KindMiddle = "middle"
KindFinal = "final"
ScopeBE = "be"
ScopeNonBE = "non_be"
)
// SplitScopes returns the BE and non-BE totals for a contract.
func SplitScopes(total, be float64) (beAmount, nonBE float64) {
if be > total {
be = total
}
if be < 0 {
be = 0
}
return be, math.Max(0, total-be)
}
// ComputeStages produces the six (kind×scope) buckets for a project.
func ComputeStages(total, be float64, cfg Config) []Stage {
beAmt, nonBE := SplitScopes(total, be)
mk := func(scope string, base float64) []Stage {
return []Stage{
{Kind: KindDeposit, Scope: scope, Pct: cfg.DepositPct, Amount: base * cfg.DepositPct / 100},
{Kind: KindMiddle, Scope: scope, Pct: cfg.MiddlePct, Amount: base * cfg.MiddlePct / 100},
{Kind: KindFinal, Scope: scope, Pct: cfg.FinalPct, Amount: base * cfg.FinalPct / 100},
}
}
out := mk(ScopeBE, beAmt)
out = append(out, mk(ScopeNonBE, nonBE)...)
return out
}
// ComputeAllocs distributes each stage's money to members and converts to points.
// - BE: every member gets stageAmount × portion%, then ÷ pointRate.
// - non-BE: only the partner pool (NonBEPartnerPct of the stage) is distributed,
// among partners weighted by their portion (the company share is company
// income, not a member incentive).
func ComputeAllocs(stages []Stage, members []MemberPortion, cfg Config) []UserAlloc {
rate := cfg.PointRate
if rate <= 0 {
rate = 1
}
partnerPortionSum := 0.0
for _, m := range members {
if m.IsPartner {
partnerPortionSum += m.Portion
}
}
var out []UserAlloc
for _, st := range stages {
switch st.Scope {
case ScopeBE:
for _, m := range members {
amt := st.Amount * m.Portion / 100
if amt == 0 {
continue
}
out = append(out, UserAlloc{Email: m.Email, Kind: st.Kind, Scope: st.Scope,
Portion: m.Portion, Amount: amt, Points: amt / rate})
}
case ScopeNonBE:
pool := st.Amount * cfg.NonBEPartnerPct / 100
if pool == 0 || partnerPortionSum == 0 {
continue
}
for _, m := range members {
if !m.IsPartner {
continue
}
share := pool * (m.Portion / partnerPortionSum)
out = append(out, UserAlloc{Email: m.Email, Kind: st.Kind, Scope: st.Scope,
Portion: m.Portion, Amount: share, Points: share / rate})
}
}
}
return out
}
// Settlement is one member's quarterly calculation result.
type Settlement struct {
Email string `json:"email"`
Rank string `json:"rank"`
Quota float64 `json:"quota"`
PointsCumul float64 `json:"pointsCumul"`
ExcessPoints float64 `json:"excessPoints"`
PaidPointsYTD float64 `json:"paidPointsYtd"`
PayoutPoints float64 `json:"payoutPoints"`
PayoutAmount float64 `json:"payoutAmount"`
}
// ComputeSettlement derives the incremental payout for one member: points beyond
// the rank quota, minus what was already paid earlier in the year.
func ComputeSettlement(email, rank string, pointsCumul, paidYTD float64, cfg Config) Settlement {
quota := cfg.RankQuota[rank]
excess := math.Max(0, pointsCumul-quota)
payout := math.Max(0, excess-paidYTD)
return Settlement{
Email: email,
Rank: rank,
Quota: quota,
PointsCumul: pointsCumul,
ExcessPoints: excess,
PaidPointsYTD: paidYTD,
PayoutPoints: payout,
PayoutAmount: payout * cfg.PointRate,
}
}