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

162 lines
5.0 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 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 // 0100
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,
}
}