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
+63
View File
@@ -0,0 +1,63 @@
package models
import (
"time"
"gorm.io/gorm"
)
// Transaction kinds for the ledger. 인센티브 지급 links the accounting side to the
// incentive engine so the "가상 포인트 vs 실제 현금" gap can be reconciled.
const (
TxnIncome = "income" // 수입 (계약 입금 등)
TxnExpense = "expense" // 비용
TxnTax = "tax" // 세금
TxnPayroll = "payroll" // 급여
TxnIncentive = "incentive" // 인센티브 지급
)
// Account is a chart-of-accounts entry (계정과목).
type Account struct {
Base
Code string `json:"code"`
Name string `json:"name"`
Type string `json:"type"` // income | expense | tax | asset | liability
CreatedAt time.Time `json:"createdAt"`
}
func (m *Account) BeforeCreate(*gorm.DB) error { m.ensureID(); return nil }
// Transaction is a single ledger line. Optional ProjectID / MemberEmail tie it to
// project profitability and per-member incentive payouts.
type Transaction struct {
Base
Date string `gorm:"index" json:"date"` // YYYY-MM-DD
Kind string `gorm:"index" json:"kind"`
AccountID *string `json:"accountId"`
Amount float64 `json:"amount"` // signed (income +, expense/tax/payroll −)
ProjectID *string `gorm:"index" json:"projectId"`
MemberEmail *string `json:"memberEmail"`
Counterparty string `json:"counterparty"`
Memo string `json:"memo"`
CreatedBy string `json:"createdBy"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (m *Transaction) BeforeCreate(*gorm.DB) error { m.ensureID(); return nil }
// TaxRecord captures periodic tax obligations (부가세/원천세 등) for the dashboard.
type TaxRecord struct {
Base
Period string `json:"period"` // YYYY-MM or YYYY-Q
Type string `json:"type"`
Base_ float64 `gorm:"column:base" json:"base"`
Amount float64 `json:"amount"`
DueDate string `json:"dueDate"`
Paid bool `json:"paid"`
Memo string `json:"memo"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (m *TaxRecord) BeforeCreate(*gorm.DB) error { m.ensureID(); return nil }
+109
View File
@@ -0,0 +1,109 @@
package models
import (
"time"
"gorm.io/gorm"
)
// Request status shared by leave/overtime approval workflows.
const (
StatusPending = "pending"
StatusApproved = "approved"
StatusRejected = "rejected"
StatusCanceled = "canceled"
)
// Leave types (한국 근로기준법 기준). 연차 draws down the annual balance; the rest
// are 인정/무급 categories that still count toward worked time where applicable.
const (
LeaveAnnual = "annual" // 연차
LeaveHalfAM = "half_am" // 오전 반차
LeaveHalfPM = "half_pm" // 오후 반차
LeavePublic = "public" // 공가
LeaveSick = "sick" // 병가
LeaveFamily = "family" // 경조사
LeaveUnpaid = "unpaid" // 무급
)
// Attendance is one day's clock record for a member (date is YYYY-MM-DD in KST).
type Attendance struct {
Base
MemberEmail string `gorm:"index" json:"memberEmail"`
Date string `gorm:"index" json:"date"` // YYYY-MM-DD
ClockIn *time.Time `json:"clockIn"`
ClockOut *time.Time `json:"clockOut"`
WorkMinutes int `json:"workMinutes"` // computed net worked minutes
Source string `json:"source"` // web | admin | auto
Note string `json:"note"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (m *Attendance) BeforeCreate(*gorm.DB) error { m.ensureID(); return nil }
// LeaveRequest is a member-submitted leave application. Members may only create;
// admins approve/reject. Days is fractional to support 반차 (0.5).
type LeaveRequest struct {
Base
MemberEmail string `gorm:"index" json:"memberEmail"`
Type string `json:"type"`
StartDate string `json:"startDate"` // YYYY-MM-DD
EndDate string `json:"endDate"`
Days float64 `json:"days"`
Reason string `json:"reason"`
Status string `gorm:"index" json:"status"`
Approver string `json:"approver"`
DecidedAt *time.Time `json:"decidedAt"`
DecisionMemo string `json:"decisionMemo"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (m *LeaveRequest) BeforeCreate(*gorm.DB) error { m.ensureID(); return nil }
// OvertimeRequest is a member-submitted overtime application (관리자만 확인/승인).
type OvertimeRequest struct {
Base
MemberEmail string `gorm:"index" json:"memberEmail"`
Date string `json:"date"`
Minutes int `json:"minutes"`
Reason string `json:"reason"`
Status string `gorm:"index" json:"status"`
Approver string `json:"approver"`
DecidedAt *time.Time `json:"decidedAt"`
DecisionMemo string `json:"decisionMemo"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (m *OvertimeRequest) BeforeCreate(*gorm.DB) error { m.ensureID(); return nil }
// WorkPolicy is the company-wide work-hours policy used by the monthly timesheet
// roll-up. Singleton-ish (one active row); admin editable.
type WorkPolicy struct {
Base
Name string `json:"name"`
WeeklyHours float64 `json:"weeklyHours"` // 주 소정근로시간 (기본 40)
DailyStandardMin int `json:"dailyStandardMin"` // 일 소정근로분 (기본 480)
CoreStart string `json:"coreStart"` // "09:00"
CoreEnd string `json:"coreEnd"` // "18:00"
LunchMinutes int `json:"lunchMinutes"` // 휴게(기본 60)
AnnualLeaveBase float64 `json:"annualLeaveBase"` // 1년 미만/이상 기준 부여일
Active bool `json:"active"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (m *WorkPolicy) BeforeCreate(*gorm.DB) error { m.ensureID(); return nil }
// LeaveBalance tracks per-member annual leave usage for a given year.
type LeaveBalance struct {
Base
MemberEmail string `gorm:"index" json:"memberEmail"`
Year int `gorm:"index" json:"year"`
Granted float64 `json:"granted"`
Used float64 `json:"used"`
}
func (m *LeaveBalance) BeforeCreate(*gorm.DB) error { m.ensureID(); return nil }
+112
View File
@@ -0,0 +1,112 @@
package models
import (
"time"
"gorm.io/datatypes"
"gorm.io/gorm"
)
// Payment stage kinds (계약금/중도금/잔금) and scopes (BE / non-BE).
const (
StageDeposit = "deposit" // 계약금
StageMiddle = "middle" // 중도금
StageFinal = "final" // 잔금
ScopeBE = "be" // 손익분기 금액분 → 작업자 인센티브 포인트 풀
ScopeNonBE = "non_be" // BE 초과분 → 회사:파트너 분배
)
// Fix lifecycle for a user's incentive on a project stage:
// 예정 → 반영중(회사 입금) → 반영완료(포인트 반영) → 지급완료(급여 지급)
const (
FixPlanned = "planned" // 예정
FixApplying = "applying" // 반영중
FixApplied = "applied" // 반영완료
FixPaid = "paid" // 지급완료
)
// IncentiveConfig is the per-year rule set. Admin tunes it; once happy it gets
// frozen for the year. RankQuota maps rank label → annual point quota.
type IncentiveConfig struct {
Base
Year int `gorm:"uniqueIndex" json:"year"`
PointRate float64 `json:"pointRate"` // KRW per 1 incentive point (환율)
DepositPct float64 `json:"depositPct"` // 계약금 비율 (%)
MiddlePct float64 `json:"middlePct"` // 중도금 비율 (%)
FinalPct float64 `json:"finalPct"` // 잔금 비율 (%)
NonBECompanyPct float64 `json:"nonBeCompanyPct"` // non-BE 회사 몫 (%)
NonBEPartnerPct float64 `json:"nonBePartnerPct"` // non-BE 파트너 몫 (%)
RankQuota datatypes.JSONMap `json:"rankQuota"` // {"주임":n,...} annual point quota
Frozen bool `json:"frozen"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (m *IncentiveConfig) BeforeCreate(*gorm.DB) error { m.ensureID(); return nil }
// PaymentStage is a project-level stage bucket (one per kind×scope). The admin
// toggles Status as money arrives ("계약금 들어옴", "중도금까지 들어옴").
type PaymentStage struct {
Base
ProjectID string `gorm:"index" json:"projectId"`
Kind string `json:"kind"` // deposit | middle | final
Scope string `json:"scope"` // be | non_be
Amount float64 `json:"amount"` // KRW allocated to this bucket
Pct float64 `json:"pct"` // % of scope total
ExpectedDate string `json:"expectedDate"`
FixedDate string `json:"fixedDate"`
Status string `json:"status"` // planned | applying | applied | paid
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (m *PaymentStage) BeforeCreate(*gorm.DB) error { m.ensureID(); return nil }
// UserIncentive is the per (project, member, stage, scope) incentive record. It
// is derived from the member's portion but fully overridable per the spec
// ("특정 유저만 픽스하지 않는 상황" etc). Points = Amount × portion ÷ pointRate.
type UserIncentive struct {
Base
ProjectID string `gorm:"index" json:"projectId"`
MemberEmail string `gorm:"index" json:"memberEmail"`
StageID string `gorm:"index" json:"stageId"`
Kind string `json:"kind"`
Scope string `json:"scope"`
Year int `gorm:"index" json:"year"`
Quarter int `json:"quarter"` // 1..4 settlement bucket
Portion float64 `json:"portion"`
Amount float64 `json:"amount"` // KRW share
Points float64 `json:"points"`
FixStatus string `json:"fixStatus"` // planned | applying | applied | paid
Override bool `json:"override"` // manually adjusted (engine won't recompute)
Memo string `json:"memo"`
AppliedAt *time.Time `json:"appliedAt"`
PaidAt *time.Time `json:"paidAt"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (m *UserIncentive) BeforeCreate(*gorm.DB) error { m.ensureID(); return nil }
// QuarterlySettlement is the 3/6/9/12월 calculation snapshot per member: how much
// of their accumulated points exceed the rank quota, and the incremental payout.
type QuarterlySettlement struct {
Base
MemberEmail string `gorm:"index" json:"memberEmail"`
Year int `gorm:"index" json:"year"`
Quarter int `gorm:"index" json:"quarter"`
Rank string `json:"rank"`
Quota float64 `json:"quota"`
PointsCumul float64 `json:"pointsCumul"` // cumulative applied points to date
ExcessPoints float64 `json:"excessPoints"` // cumul − quota (floored at 0)
PaidPointsYTD float64 `json:"paidPointsYtd"` // already paid in prior quarters
PayoutPoints float64 `json:"payoutPoints"` // excess − paidYTD (this quarter's delta)
PayoutAmount float64 `json:"payoutAmount"` // KRW = payoutPoints × pointRate
Fixed bool `json:"fixed"`
FixedAt *time.Time `json:"fixedAt"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (m *QuarterlySettlement) BeforeCreate(*gorm.DB) error { m.ensureID(); return nil }
+70
View File
@@ -0,0 +1,70 @@
package models
import (
"time"
"gorm.io/gorm"
)
// Rank is the career grade that drives incentive point quotas.
// 주임(junior) · 선임(senior) · 책임(lead) · 파트너(partner)
// Stored as the Korean label so it round-trips to the UI directly.
const (
RankJunior = "주임"
RankSenior = "선임"
RankLead = "책임"
RankPartner = "파트너"
)
// Member roles within spin. Account lifecycle (create/disable) is Keycloak's
// job; spin only distinguishes admin from regular member for authorization.
const (
RoleAdmin = "admin"
RoleMember = "user"
)
// Member is a company employee who uses spin, matched to the logged-in Keycloak
// identity by email (case-insensitive). It carries the org/HR profile spin needs
// (rank, department, partner flag) that Keycloak does not hold.
type Member struct {
Base
Email string `gorm:"index" json:"email"`
DisplayName string `json:"displayName"`
Rank string `json:"rank"` // 주임/선임/책임/파트너
DepartmentID *string `json:"departmentId"`
Role string `json:"role"` // admin | user
IsPartner bool `json:"isPartner"` // shares non-BE profit pool
Phone string `json:"phone"`
Position string `json:"position"` // free-text job title
Status string `json:"status"` // active | inactive
JoinDate *time.Time `json:"joinDate"`
AnnualLeave float64 `json:"annualLeave"` // granted 연차 days for the year
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (m *Member) BeforeCreate(*gorm.DB) error { m.ensureID(); return nil }
// Department is an org unit. Lightweight; the lead is a Member email.
type Department struct {
Base
Name string `json:"name"`
LeadEmail string `json:"leadEmail"`
CreatedAt time.Time `json:"createdAt"`
}
func (m *Department) BeforeCreate(*gorm.DB) error { m.ensureID(); return nil }
// AuditLog records sensitive actions (approvals, incentive fixes, contract edits)
// for the admin trail. Entity/EntityID point at the affected row.
type AuditLog struct {
Base
Actor string `gorm:"index" json:"actor"`
Action string `json:"action"`
Entity string `gorm:"index" json:"entity"`
EntityID string `gorm:"index" json:"entityId"`
Detail string `json:"detail"`
CreatedAt time.Time `gorm:"index" json:"createdAt"`
}
func (m *AuditLog) BeforeCreate(*gorm.DB) error { m.ensureID(); return nil }
+39
View File
@@ -0,0 +1,39 @@
// Package models defines the GORM models for spin. Each domain slice adds its
// own file (member.go, attendance.go, project.go, incentive.go, accounting.go);
// this file holds the shared Base type and the AutoMigrate registry.
package models
import (
"github.com/google/uuid"
"gorm.io/gorm"
)
// Base provides a string UUID primary key populated in a BeforeCreate hook.
type Base struct {
ID string `gorm:"primaryKey" json:"id"`
}
func (b *Base) ensureID() {
if b.ID == "" {
b.ID = uuid.NewString()
}
}
// All returns every model for AutoMigrate. Each slice appends its models here.
func All() []interface{} {
return []interface{}{
// slice 1 — members / org
&Member{}, &Department{}, &AuditLog{},
// slice 2 — attendance / leave
&Attendance{}, &LeaveRequest{}, &OvertimeRequest{}, &WorkPolicy{}, &LeaveBalance{},
// slice 3 — projects
&Company{}, &Product{}, &Version{}, &Project{}, &ProjectMember{},
&ClientContact{}, &ProjectTask{}, &Contract{}, &ContractFile{}, &PaymentSplit{},
// slice 4 — incentive
&IncentiveConfig{}, &PaymentStage{}, &UserIncentive{}, &QuarterlySettlement{},
// slice 5 — accounting
&Account{}, &Transaction{}, &TaxRecord{},
}
}
var _ = gorm.ErrRecordNotFound // keep gorm imported for slice files
+164
View File
@@ -0,0 +1,164 @@
package models
import (
"time"
"gorm.io/datatypes"
"gorm.io/gorm"
)
// Company → Product → Version is the consulting hierarchy. A Project is created
// per (company, product, version) engagement and is otherwise independent.
type Company struct {
Base
Name string `json:"name"`
Code string `json:"code"`
Note string `json:"note"`
CreatedAt time.Time `json:"createdAt"`
}
func (m *Company) BeforeCreate(*gorm.DB) error { m.ensureID(); return nil }
type Product struct {
Base
CompanyID string `gorm:"index" json:"companyId"`
Name string `json:"name"`
Code string `json:"code"`
CreatedAt time.Time `json:"createdAt"`
}
func (m *Product) BeforeCreate(*gorm.DB) error { m.ensureID(); return nil }
type Version struct {
Base
ProductID string `gorm:"index" json:"productId"`
Label string `json:"label"`
CreatedAt time.Time `json:"createdAt"`
}
func (m *Version) BeforeCreate(*gorm.DB) error { m.ensureID(); return nil }
// Consulting metadata enums (stored as free strings for flexibility).
const (
ScopeText = "text" // 글
ScopeGraphic = "graphic" // 그림
ScopeBoth = "both" // 글+그림
)
// Project is the core engagement record. The bracketed [admin-only] fields in the
// spec live on Contract / PaymentSplit, not here, so Project is safe to expose to
// any member who belongs to it.
type Project struct {
Base
Name string `json:"name"`
CompanyID string `gorm:"index" json:"companyId"`
ProductID string `json:"productId"`
VersionID string `json:"versionId"`
CompanyName string `json:"companyName"` // denormalized 업체명 snapshot
ProductName string `json:"productName"`
VersionName string `json:"versionName"`
ConsultingType string `json:"consultingType"` // 컨설팅 종류
Country string `json:"country"` // 제출 국가
Scope string `json:"scope"` // text | graphic | both
PMEmail string `json:"pmEmail"` // 프로젝트 PM
Cautions string `json:"cautions"` // 주의사항 (구성원 공개)
Status string `json:"status"` // planned | active | hold | done | dropped
StartDate string `json:"startDate"`
DueDate string `json:"dueDate"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (m *Project) BeforeCreate(*gorm.DB) error { m.ensureID(); return nil }
// ProjectMember links a Member to a Project with a portion (기여도, 0–100) that
// drives incentive distribution. Role is a free label (작업자/리뷰어/...).
type ProjectMember struct {
Base
ProjectID string `gorm:"index" json:"projectId"`
MemberEmail string `gorm:"index" json:"memberEmail"`
Portion float64 `json:"portion"` // 기여도 percent
Role string `json:"role"`
CreatedAt time.Time `json:"createdAt"`
}
func (m *ProjectMember) BeforeCreate(*gorm.DB) error { m.ensureID(); return nil }
// ClientContact is a 업체 담당자 (직무/이름/연락처).
type ClientContact struct {
Base
ProjectID string `gorm:"index" json:"projectId"`
Name string `json:"name"`
Title string `json:"title"` // 직무
Phone string `json:"phone"`
Email string `json:"email"`
}
func (m *ClientContact) BeforeCreate(*gorm.DB) error { m.ensureID(); return nil }
// ProjectTask feeds both the Gantt and Kanban views and matches the real
// calendar via Start/End (YYYY-MM-DD). Lane is the Kanban column.
type ProjectTask struct {
Base
ProjectID string `gorm:"index" json:"projectId"`
Title string `json:"title"`
Lane string `json:"lane"` // todo | doing | review | done
Start string `json:"start"` // YYYY-MM-DD
End string `json:"end"`
Assignee string `json:"assignee"`
OrderIdx int `json:"orderIdx"`
Progress int `json:"progress"` // 0–100
DependsOn datatypes.JSONSlice[string] `json:"dependsOn"`
Color string `json:"color"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (m *ProjectTask) BeforeCreate(*gorm.DB) error { m.ensureID(); return nil }
// Contract holds the [admin-only] commercial terms of a project. BE is the
// break-even floor (손해가 안 나는 최소 금액). Exposed ONLY to admins.
type Contract struct {
Base
ProjectID string `gorm:"uniqueIndex" json:"projectId"`
TotalAmount float64 `json:"totalAmount"` // 계약 금액
BEAmount float64 `json:"beAmount"` // BE (break-even 최소 금액)
AdminCaution string `json:"adminCaution"` // 관리자 주의사항
Memo string `json:"memo"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (m *Contract) BeforeCreate(*gorm.DB) error { m.ensureID(); return nil }
// ContractFile is an [admin-only] attachment (계약서/기타 자료, 여러개 가능) in S3.
type ContractFile struct {
Base
ProjectID string `gorm:"index" json:"projectId"`
Kind string `json:"kind"` // contract | other
Filename string `json:"filename"`
S3Key string `json:"s3Key"`
Size int64 `json:"size"`
UploadedBy string `json:"uploadedBy"`
CreatedAt time.Time `json:"createdAt"`
}
func (m *ContractFile) BeforeCreate(*gorm.DB) error { m.ensureID(); return nil }
// PaymentSplit is one [admin-only] custom installment of the contract amount.
// Amounts arrive in arbitrary splits across dates, so every field is editable.
type PaymentSplit struct {
Base
ProjectID string `gorm:"index" json:"projectId"`
Label string `json:"label"`
Amount float64 `json:"amount"`
ExpectedDate string `json:"expectedDate"` // 예상 일정
PaidDate string `json:"paidDate"` // 실제 입금일 (빈값=미입금)
Paid bool `json:"paid"`
Memo string `json:"memo"`
OrderIdx int `json:"orderIdx"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (m *PaymentSplit) BeforeCreate(*gorm.DB) error { m.ensureID(); return nil }