- 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:
co-authored by
Claude Opus 4.8
parent
ce4023ca59
commit
f83724b995
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user