All checks were successful
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>
162 lines
5.0 KiB
Go
162 lines
5.0 KiB
Go
// 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,
|
||
}
|
||
}
|