feat: spin 백엔드 전체 구현 (근무·프로젝트·인센티브·회계)
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>
This commit is contained in:
theorose49 2026-06-28 08:57:35 +09:00
parent ce4023ca59
commit f83724b995
28 changed files with 3960 additions and 5 deletions

View File

@ -1,8 +1,21 @@
# Placeholder Dockerfile — nginx-unprivileged 기반 static serve, port 8080. # --- build stage ------------------------------------------------------------
# 실제 코드 추가 후 본인 stack(Node/Python/Go 등)으로 교체 권장. FROM golang:1.22-alpine AS build
FROM nginxinc/nginx-unprivileged:1.27-alpine WORKDIR /src
# 정적 파일 (html/css/js) 또는 SPA build output 을 root 에 복사. # Cache dependencies first.
COPY --chown=nginx:nginx . /usr/share/nginx/html/ COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/server ./cmd/server
# --- runtime stage ----------------------------------------------------------
FROM alpine:3.20
RUN apk add --no-cache ca-certificates && adduser -D -u 10001 app
USER app
WORKDIR /app
COPY --from=build /out/server /app/server
ENV PORT=8080
EXPOSE 8080 EXPOSE 8080
ENTRYPOINT ["/app/server"]

61
cmd/server/main.go Normal file
View File

@ -0,0 +1,61 @@
package main
import (
"context"
"log"
"net/http"
"time"
"spin/internal/config"
"spin/internal/db"
"spin/internal/httpapi"
"spin/internal/seed"
"spin/internal/storage"
)
func main() {
cfg := config.Load()
gdb, err := db.Connect(cfg.DatabaseURL)
if err != nil {
log.Fatalf("database connection failed: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
store, err := storage.New(ctx, cfg)
if err != nil {
log.Printf("storage init failed (continuing without S3): %v", err)
store = nil
} else if err := store.EnsureBucket(ctx); err != nil {
log.Printf("ensure bucket failed (retrying once): %v", err)
time.Sleep(2 * time.Second)
if err := store.EnsureBucket(ctx); err != nil {
log.Printf("ensure bucket failed (best-effort): %v", err)
}
}
if cfg.SeedData {
if err := seed.Run(gdb); err != nil {
log.Printf("seed failed (continuing): %v", err)
}
} else {
log.Printf("seed skipped (SEED=false)")
}
router := httpapi.NewRouter(gdb, store, cfg)
addr := ":" + cfg.Port
srv := &http.Server{
Addr: addr,
Handler: router,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
}
log.Printf("spin backend listening on %s (devAuth=%v)", addr, cfg.DevAuth)
if err := srv.ListenAndServe(); err != nil {
log.Fatalf("server error: %v", err)
}
}

46
go.mod Normal file
View File

@ -0,0 +1,46 @@
module spin
go 1.22
require (
github.com/aws/aws-sdk-go-v2/config v1.27.27
github.com/aws/aws-sdk-go-v2/credentials v1.17.27
github.com/aws/aws-sdk-go-v2/service/s3 v1.58.2
github.com/go-chi/chi/v5 v5.1.0
github.com/go-chi/cors v1.2.1
github.com/google/uuid v1.6.0
gorm.io/datatypes v1.2.1
gorm.io/driver/postgres v1.5.9
gorm.io/gorm v1.25.11
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/aws/aws-sdk-go-v2 v1.30.3 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.11 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.15 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.17 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.17 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.15 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.22.4 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 // indirect
github.com/aws/smithy-go v1.20.3 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 // indirect
github.com/jackc/pgx/v5 v5.5.5 // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
golang.org/x/crypto v0.22.0 // indirect
golang.org/x/sync v0.1.0 // indirect
golang.org/x/text v0.14.0 // indirect
gorm.io/driver/mysql v1.5.6 // indirect
)

119
go.sum Normal file
View File

@ -0,0 +1,119 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/aws/aws-sdk-go-v2 v1.30.3 h1:jUeBtG0Ih+ZIFH0F4UkmL9w3cSpaMv9tYYDbzILP8dY=
github.com/aws/aws-sdk-go-v2 v1.30.3/go.mod h1:nIQjQVp5sfpQcTc9mPSr1B0PaWK5ByX9MOoDadSN4lc=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 h1:tW1/Rkad38LA15X4UQtjXZXNKsCgkshC3EbmcUmghTg=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3/go.mod h1:UbnqO+zjqk3uIt9yCACHJ9IVNhyhOCnYk8yA19SAWrM=
github.com/aws/aws-sdk-go-v2/config v1.27.27 h1:HdqgGt1OAP0HkEDDShEl0oSYa9ZZBSOmKpdpsDMdO90=
github.com/aws/aws-sdk-go-v2/config v1.27.27/go.mod h1:MVYamCg76dFNINkZFu4n4RjDixhVr51HLj4ErWzrVwg=
github.com/aws/aws-sdk-go-v2/credentials v1.17.27 h1:2raNba6gr2IfA0eqqiP2XiQ0UVOpGPgDSi0I9iAP+UI=
github.com/aws/aws-sdk-go-v2/credentials v1.17.27/go.mod h1:gniiwbGahQByxan6YjQUMcW4Aov6bLC3m+evgcoN4r4=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.11 h1:KreluoV8FZDEtI6Co2xuNk/UqI9iwMrOx/87PBNIKqw=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.11/go.mod h1:SeSUYBLsMYFoRvHE0Tjvn7kbxaUhl75CJi1sbfhMxkU=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15 h1:SoNJ4RlFEQEbtDcCEt+QG56MY4fm4W8rYirAmq+/DdU=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15/go.mod h1:U9ke74k1n2bf+RIgoX1SXFed1HLs51OgUSs+Ph0KJP8=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15 h1:C6WHdGnTDIYETAm5iErQUiVNsclNx9qbJVPIt03B6bI=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15/go.mod h1:ZQLZqhcu+JhSrA9/NXRm8SkDvsycE+JkV3WGY41e+IM=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 h1:hT8rVHwugYE2lEfdFE0QWVo81lF7jMrYJVDWI+f+VxU=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.15 h1:Z5r7SycxmSllHYmaAZPpmN8GviDrSGhMS6bldqtXZPw=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.15/go.mod h1:CetW7bDE00QoGEmPUoZuRog07SGVAUVW6LFpNP0YfIg=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3 h1:dT3MqvGhSoaIhRseqw2I0yH81l7wiR2vjs57O51EAm8=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3/go.mod h1:GlAeCkHwugxdHaueRr4nhPuY+WW+gR8UjlcqzPr1SPI=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.17 h1:YPYe6ZmvUfDDDELqEKtAd6bo8zxhkm+XEFEzQisqUIE=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.17/go.mod h1:oBtcnYua/CgzCWYN7NZ5j7PotFDaFSUjCYVTtfyn7vw=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.17 h1:HGErhhrxZlQ044RiM+WdoZxp0p+EGM62y3L6pwA4olE=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.17/go.mod h1:RkZEx4l0EHYDJpWppMJ3nD9wZJAa8/0lq9aVC+r2UII=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.15 h1:246A4lSTXWJw/rmlQI+TT2OcqeDMKBdyjEQrafMaQdA=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.15/go.mod h1:haVfg3761/WF7YPuJOER2MP0k4UAXyHaLclKXB6usDg=
github.com/aws/aws-sdk-go-v2/service/s3 v1.58.2 h1:sZXIzO38GZOU+O0C+INqbH7C2yALwfMWpd64tONS/NE=
github.com/aws/aws-sdk-go-v2/service/s3 v1.58.2/go.mod h1:Lcxzg5rojyVPU/0eFwLtcyTaek/6Mtic5B1gJo7e/zE=
github.com/aws/aws-sdk-go-v2/service/sso v1.22.4 h1:BXx0ZIxvrJdSgSvKTZ+yRBeSqqgPM89VPlulEcl37tM=
github.com/aws/aws-sdk-go-v2/service/sso v1.22.4/go.mod h1:ooyCOXjvJEsUw7x+ZDHeISPMhtwI3ZCB7ggFMcFfWLU=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4 h1:yiwVzJW2ZxZTurVbYWA7QOrAaCYQR72t0wrSBfoesUE=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4/go.mod h1:0oxfLkpz3rQ/CHlx5hB7H69YUpFiI1tql6Q6Ne+1bCw=
github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 h1:ZsDKRLXGWHk8WdtyYMoGNO7bTudrvuKpDKgMVRlepGE=
github.com/aws/aws-sdk-go-v2/service/sts v1.30.3/go.mod h1:zwySh8fpFyXp9yOr/KVzxOl8SRqgf/IDw5aUt9UKFcQ=
github.com/aws/smithy-go v1.20.3 h1:ryHwveWzPV5BIof6fyDvor6V3iUL7nTfiTKXHiW05nE=
github.com/aws/smithy-go v1.20.3/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-pdf/fpdf v0.9.0 h1:PPvSaUuo1iMi9KkaAn90NuKi+P4gwMedWPHhj8YlJQw=
github.com/go-pdf/fpdf v0.9.0/go.mod h1:oO8N111TkmKb9D7VvWGLvLJlaZUQVPM+6V42pp3iV4Y=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA=
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/mattn/go-sqlite3 v1.14.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI=
github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/microsoft/go-mssqldb v0.17.0 h1:Fto83dMZPnYv1Zwx5vHHxpNraeEaUlQ/hhHLgZiaenE=
github.com/microsoft/go-mssqldb v0.17.0/go.mod h1:OkoNGhGEs8EZqchVTtochlXruEhEOaO4S0d2sB5aeGQ=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM=
github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk=
github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/richardlehane/msoleps v1.0.3 h1:aznSZzrwYRl3rLKRT3gUk9am7T/mLNSnJINvN0AQoVM=
github.com/richardlehane/msoleps v1.0.3/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/xuri/efp v0.0.0-20231025114914-d1ff6096ae53 h1:Chd9DkqERQQuHpXjR/HSV1jLZA6uaoiwwH3vSuF3IW0=
github.com/xuri/efp v0.0.0-20231025114914-d1ff6096ae53/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
github.com/xuri/excelize/v2 v2.8.1 h1:pZLMEwK8ep+CLIUWpWmvW8IWE/yxqG0I1xcN6cVMGuQ=
github.com/xuri/excelize/v2 v2.8.1/go.mod h1:oli1E4C3Pa5RXg1TBXn4ENCXDV5JUMlBluUhG7c+CEE=
github.com/xuri/nfp v0.0.0-20230919160717-d98342af3f05 h1:qhbILQo1K3mphbwKh1vNm4oGezE1eF9fQWmNiIpSfI4=
github.com/xuri/nfp v0.0.0-20230919160717-d98342af3f05/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
golang.org/x/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4=
golang.org/x/image v0.14.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/datatypes v1.2.1 h1:r+g0bk4LPCW2v4+Ls7aeNgGme7JYdNDQ2VtvlNUfBh0=
gorm.io/datatypes v1.2.1/go.mod h1:hYK6OTb/1x+m96PgoZZq10UXJ6RvEBb9kRDQ2yyhzGs=
gorm.io/driver/mysql v1.5.6 h1:Ld4mkIickM+EliaQZQx3uOJDJHtrd70MxAUqWqlx3Y8=
gorm.io/driver/mysql v1.5.6/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
gorm.io/driver/postgres v1.5.9 h1:DkegyItji119OlcaLjqN11kHoUgZ/j13E0jkJZgD6A8=
gorm.io/driver/postgres v1.5.9/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
gorm.io/driver/sqlite v1.4.3 h1:HBBcZSDnWi5BW3B3rwvVTc510KGkBkexlOg0QrmLUuU=
gorm.io/driver/sqlite v1.4.3/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI=
gorm.io/driver/sqlserver v1.4.1 h1:t4r4r6Jam5E6ejqP7N82qAJIJAht27EGT41HyPfXRw0=
gorm.io/driver/sqlserver v1.4.1/go.mod h1:DJ4P+MeZbc5rvY58PnmN1Lnyvb5gw5NPzGshHDnJLig=
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.25.11 h1:/Wfyg1B/je1hnDx3sMkX+gAlxrlZpn6X0BXRlwXlvHg=
gorm.io/gorm v1.25.11/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=

121
internal/config/config.go Normal file
View File

@ -0,0 +1,121 @@
package config
import (
"net/url"
"os"
"strings"
)
// Config holds all runtime configuration loaded from environment variables.
//
// Variable names follow the cluster/CI contract documented in .env.sample
// (PGHOST/PGUSER/…, AWS_ACCESS_KEY_ID/…, S3_ENDPOINT/S3_BUCKET/S3_PREFIX) so the
// same binary runs unchanged in the Special Partners infra. Local docker-compose
// supplies the same variables. This mirrors the sister eQMS (Mallard) service.
type Config struct {
Port string
DatabaseURL string
S3Endpoint string
S3PublicEndpoint string
S3Region string
S3Bucket string
S3Prefix string
S3AccessKey string
S3SecretKey string
DevAuth bool
SeedData bool
// AdminGroups are the Keycloak groups whose members are super-admins
// (manage everything across the company: incentive console, accounting,
// approvals, all member/project data).
AdminGroups []string
}
func env(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
// firstEnv returns the first non-empty env var among keys, else def.
func firstEnv(def string, keys ...string) string {
for _, k := range keys {
if v := os.Getenv(k); v != "" {
return v
}
}
return def
}
// Load reads configuration from the environment, applying sane local defaults
// so `go run ./cmd/server` works without docker-compose.
func Load() Config {
return Config{
Port: env("PORT", "8080"),
DatabaseURL: databaseURL(),
S3Endpoint: withScheme(env("S3_ENDPOINT", "http://localhost:9000")),
S3PublicEndpoint: publicEndpoint(),
S3Region: env("S3_REGION", "us-east-1"),
S3Bucket: env("S3_BUCKET", "spin"),
S3Prefix: env("S3_PREFIX", ""),
// Cluster secrets arrive as AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY;
// the S3_* aliases are kept for convenience/back-compat.
S3AccessKey: firstEnv("minioadmin", "AWS_ACCESS_KEY_ID", "S3_ACCESS_KEY"),
S3SecretKey: firstEnv("minioadmin", "AWS_SECRET_ACCESS_KEY", "S3_SECRET_KEY"),
DevAuth: env("DEV_AUTH", "true") != "false",
// Sample data is seeded only when SEED is truthy (default). Production
// sets SEED=false so the cluster DB stays clean.
SeedData: env("SEED", "true") != "false",
// Super-admin Keycloak groups (comma-separated). Default: admin
// (shared group name across all internal apps, not app-specific).
AdminGroups: splitCSV(env("ADMIN_GROUPS", "admin")),
}
}
// splitCSV splits a comma-separated list, trimming whitespace and dropping
// empty entries.
func splitCSV(raw string) []string {
var out []string
for _, p := range strings.Split(raw, ",") {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
// databaseURL prefers an explicit DATABASE_URL (override), otherwise assembles a
// DSN from the discrete PG* variables injected from the DB-creds Secret.
func databaseURL() string {
if v := os.Getenv("DATABASE_URL"); v != "" {
return v
}
u := url.URL{
Scheme: "postgres",
User: url.UserPassword(env("PGUSER", "spin"), env("PGPASSWORD", "spin")),
Host: env("PGHOST", "localhost") + ":" + env("PGPORT", "5432"),
Path: "/" + env("PGDATABASE", "spin"),
}
q := url.Values{}
q.Set("sslmode", env("PGSSLMODE", "disable"))
u.RawQuery = q.Encode()
return u.String()
}
// publicEndpoint is the browser-reachable S3 host used for presigned URLs.
// Falls back to the in-cluster endpoint when not separately provided.
func publicEndpoint() string {
if v := os.Getenv("S3_PUBLIC_ENDPOINT"); v != "" {
return withScheme(v)
}
return withScheme(env("S3_ENDPOINT", "http://localhost:9000"))
}
// withScheme guarantees an http(s):// prefix so the AWS SDK accepts the endpoint
// (the .env.sample shows bare hosts like "localhost").
func withScheme(s string) string {
if s == "" || strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") {
return s
}
return "http://" + s
}

49
internal/db/db.go Normal file
View File

@ -0,0 +1,49 @@
package db
import (
"log"
"time"
"spin/internal/models"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// Connect opens a GORM connection, retrying a few times to tolerate
// docker-compose startup ordering, then runs AutoMigrate over models.All().
func Connect(dsn string) (*gorm.DB, error) {
var gdb *gorm.DB
var err error
for i := 0; i < 15; i++ {
gdb, err = gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Warn),
// Skip relationship handling during migration to avoid GORM v1.25
// ReorderModels panics; runtime associations (Preload/Append) are
// unaffected. spin uses explicit join models (e.g. ProjectMember)
// rather than many2many, so no manual join table is required.
IgnoreRelationshipsWhenMigrating: true,
})
if err == nil {
var sqlDB, e = gdb.DB()
if e == nil {
if e = sqlDB.Ping(); e == nil {
break
}
err = e
} else {
err = e
}
}
log.Printf("db: waiting for database (attempt %d): %v", i+1, err)
time.Sleep(2 * time.Second)
}
if err != nil {
return nil, err
}
if err := gdb.AutoMigrate(models.All()...); err != nil {
return nil, err
}
return gdb, nil
}

209
internal/httpapi/auth.go Normal file
View File

@ -0,0 +1,209 @@
package httpapi
import (
"context"
"encoding/base64"
"encoding/json"
"net/http"
"strings"
)
// User is the authenticated principal carried on the request context. Identity
// comes from oauth2-proxy (Keycloak) in production; spin matches it to a Member
// row by email to resolve rank/role/admin within the app.
type User struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Role string `json:"role"`
Groups []string `json:"groups,omitempty"`
IsSuperAdmin bool `json:"isSuperAdmin"`
}
type ctxKey string
const userKey ctxKey = "user"
// devUser is the fixed identity injected when DEV_AUTH is enabled (local dev).
var devUser = User{ID: "u-admin", Name: "관리자", Email: "theorose49@gmail.com", Role: "관리자", Groups: []string{"admin"}}
// authMiddleware injects the authenticated user into the request context.
//
// - DEV_AUTH=true → a fixed mock identity (local development).
// - DEV_AUTH=false → the identity forwarded by oauth2-proxy after a successful
// Keycloak login (X-Forwarded-* / X-Auth-Request-* headers + token claims).
// The backend trusts these headers because it is only reachable behind the
// proxy; requests without them are rejected.
func authMiddleware(devAuth bool, adminGroups []string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var u User
if devAuth {
u = devUser
// Local dev runs as a super-admin so every screen is reachable.
// Append ?as=user to any request to simulate a non-admin member.
u.IsSuperAdmin = r.URL.Query().Get("as") != "user"
if !u.IsSuperAdmin {
u = User{ID: "u-member", Name: "김주임", Email: "member@special-partners.com", Role: "주임", Groups: []string{"user"}}
}
} else {
var ok bool
u, ok = userFromProxyHeaders(r)
if !ok {
writeError(w, http.StatusUnauthorized, "not authenticated (oauth2-proxy headers missing)")
return
}
u.IsSuperAdmin = groupsIntersect(u.Groups, adminGroups)
}
ctx := context.WithValue(r.Context(), userKey, u)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// groupsIntersect reports whether any of the user's groups is in adminGroups
// (case-insensitive).
func groupsIntersect(groups, adminGroups []string) bool {
for _, g := range groups {
for _, a := range adminGroups {
if strings.EqualFold(strings.TrimSpace(g), strings.TrimSpace(a)) {
return true
}
}
}
return false
}
// userFromProxyHeaders builds the principal from the headers oauth2-proxy
// injects. It prefers the X-Forwarded-* headers (--pass-user-headers), falls
// back to X-Auth-Request-* (--set-xauthrequest), and enriches the display name
// and role from the forwarded access-token claims when available.
func userFromProxyHeaders(r *http.Request) (User, bool) {
hdr := func(keys ...string) string {
for _, k := range keys {
if v := strings.TrimSpace(r.Header.Get(k)); v != "" {
return v
}
}
return ""
}
email := hdr("X-Forwarded-Email", "X-Auth-Request-Email")
username := hdr("X-Forwarded-Preferred-Username", "X-Auth-Request-Preferred-Username",
"X-Forwarded-User", "X-Auth-Request-User")
groups := splitGroups(hdr("X-Forwarded-Groups", "X-Auth-Request-Groups"))
claims := decodeTokenClaims(r)
if username == "" {
username = firstNonEmpty(claims.PreferredUsername, claims.Sub)
}
if email == "" {
email = claims.Email
}
if len(groups) == 0 {
groups = splitGroups(strings.Join(claims.Groups, ","))
}
// No identifying header at all → request did not come through oauth2-proxy.
if username == "" && email == "" {
return User{}, false
}
return User{
ID: firstNonEmpty(email, username, claims.Sub),
Name: firstNonEmpty(claims.Name, username, email),
Email: email,
Role: deriveRole(groups, claims.RealmAccess.Roles),
Groups: groups,
}, true
}
// oidcClaims is the subset of Keycloak token claims we read.
type oidcClaims struct {
Sub string `json:"sub"`
Name string `json:"name"`
PreferredUsername string `json:"preferred_username"`
Email string `json:"email"`
Groups []string `json:"groups"`
RealmAccess struct {
Roles []string `json:"roles"`
} `json:"realm_access"`
}
// decodeTokenClaims reads the forwarded access token and decodes its JWT payload
// WITHOUT signature verification — oauth2-proxy already validated it upstream and
// the backend is only reachable behind the proxy. Returns zero claims if absent.
func decodeTokenClaims(r *http.Request) oidcClaims {
var c oidcClaims
tok := firstNonEmpty(
r.Header.Get("X-Forwarded-Access-Token"),
r.Header.Get("X-Auth-Request-Access-Token"),
)
if tok == "" {
if a := r.Header.Get("Authorization"); strings.HasPrefix(a, "Bearer ") {
tok = strings.TrimSpace(strings.TrimPrefix(a, "Bearer "))
}
}
if tok == "" {
return c
}
parts := strings.Split(tok, ".")
if len(parts) < 2 {
return c
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
if payload, err = base64.URLEncoding.DecodeString(parts[1]); err != nil {
return c
}
}
_ = json.Unmarshal(payload, &c)
return c
}
var ignoredRealmRoles = map[string]bool{"offline_access": true, "uma_authorization": true}
// deriveRole picks a human-readable role for display: the first Keycloak group,
// else the first non-default realm role, else "구성원".
func deriveRole(groups, realmRoles []string) string {
for _, g := range groups {
if g != "" {
return g
}
}
for _, role := range realmRoles {
if role == "" || ignoredRealmRoles[role] || strings.HasPrefix(role, "default-roles") {
continue
}
return role
}
return "구성원"
}
func splitGroups(raw string) []string {
var out []string
for _, g := range strings.Split(raw, ",") {
g = strings.TrimPrefix(strings.TrimSpace(g), "/")
if g != "" {
out = append(out, g)
}
}
return out
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
// currentUser returns the authenticated user from context.
func currentUser(ctx context.Context) User {
if u, ok := ctx.Value(userKey).(User); ok {
return u
}
return devUser
}

View File

@ -0,0 +1,20 @@
package httpapi
import (
"testing"
"spin/internal/models"
"gorm.io/datatypes"
)
func TestToEngineConfigQuota(t *testing.T) {
c := models.IncentiveConfig{
PointRate: 1_000_000,
RankQuota: datatypes.JSONMap{"주임": 30.0, "선임": 50.0},
}
eng := toEngineConfig(c)
if eng.RankQuota["주임"] != 30 {
t.Fatalf("expected 30, got %v (map=%v)", eng.RankQuota["주임"], eng.RankQuota)
}
}

View File

@ -0,0 +1,37 @@
package httpapi
import (
"fmt"
"os"
"testing"
"spin/internal/models"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
// Run with: SPIN_TEST_DB=1 go test ./internal/httpapi -run TestDBLoadQuota -v
func TestDBLoadQuota(t *testing.T) {
if os.Getenv("SPIN_TEST_DB") == "" {
t.Skip("set SPIN_TEST_DB=1 to run against local compose db")
}
dsn := "postgres://spin:spin@localhost:5580/spin?sslmode=disable"
gdb, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
var c models.IncentiveConfig
if err := gdb.Where("year = ?", 2026).First(&c).Error; err != nil {
t.Fatal(err)
}
fmt.Printf("loaded RankQuota=%#v\n", c.RankQuota)
for k, v := range c.RankQuota {
fmt.Printf(" key=%q val=%#v type=%T\n", k, v, v)
}
eng := toEngineConfig(c)
fmt.Printf("eng.RankQuota=%#v\n", eng.RankQuota)
if eng.RankQuota["주임"] != 30 {
t.Fatalf("expected 30, got %v", eng.RankQuota["주임"])
}
}

View File

@ -0,0 +1,61 @@
package httpapi
import (
"net/http"
"strings"
"spin/internal/models"
)
// MeResponse enriches the proxy identity with the matched Member profile.
type MeResponse struct {
User User `json:"user"`
Member *models.Member `json:"member"`
IsAdmin bool `json:"isAdmin"`
}
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
u := currentUser(r.Context())
writeJSON(w, http.StatusOK, MeResponse{
User: u,
Member: s.lookupMember(u.Email),
IsAdmin: s.isAdmin(r),
})
}
// NavItem is one sidebar entry; adminOnly entries are filtered for members.
type NavItem struct {
Key string `json:"key"`
Label string `json:"label"`
Path string `json:"path"`
Icon string `json:"icon"`
AdminOnly bool `json:"adminOnly"`
Section string `json:"section"`
}
var navItems = []NavItem{
{Key: "dashboard", Label: "대시보드", Path: "/", Icon: "LayoutDashboard", Section: "개요"},
{Key: "attendance", Label: "근무", Path: "/attendance", Icon: "Clock", Section: "나의 업무"},
{Key: "projects", Label: "프로젝트", Path: "/projects", Icon: "FolderKanban", Section: "나의 업무"},
{Key: "incentive", Label: "인센티브", Path: "/incentive", Icon: "Coins", Section: "나의 업무"},
{Key: "approvals", Label: "승인 관리", Path: "/admin/approvals", Icon: "CheckSquare", AdminOnly: true, Section: "관리자"},
{Key: "incentive-admin", Label: "인센티브 관리", Path: "/admin/incentive", Icon: "Calculator", AdminOnly: true, Section: "관리자"},
{Key: "accounting", Label: "회계", Path: "/admin/accounting", Icon: "Wallet", AdminOnly: true, Section: "관리자"},
{Key: "members", Label: "구성원", Path: "/admin/members", Icon: "Users", AdminOnly: true, Section: "관리자"},
{Key: "settings", Label: "설정", Path: "/admin/settings", Icon: "Settings", AdminOnly: true, Section: "관리자"},
}
func (s *Server) handleNav(w http.ResponseWriter, r *http.Request) {
admin := s.isAdmin(r)
out := make([]NavItem, 0, len(navItems))
for _, it := range navItems {
if it.AdminOnly && !admin {
continue
}
out = append(out, it)
}
writeJSON(w, http.StatusOK, out)
}
// lc lower-cases & trims a string (small helper used across handlers).
func lc(s string) string { return strings.ToLower(strings.TrimSpace(s)) }

View File

@ -0,0 +1,225 @@
package httpapi
import (
"net/http"
"strconv"
"time"
"spin/internal/models"
"github.com/go-chi/chi/v5"
)
// All accounting is admin-only (전사 재무 데이터).
func (s *Server) handleListAccounts(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var out []models.Account
s.db.Order("code asc").Find(&out)
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleCreateAccount(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var a models.Account
if err := decodeJSON(r, &a); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.db.Create(&a)
writeJSON(w, http.StatusCreated, a)
}
func (s *Server) handleListTransactions(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
q := s.db.Order("date desc, created_at desc")
if k := r.URL.Query().Get("kind"); k != "" {
q = q.Where("kind = ?", k)
}
if pid := r.URL.Query().Get("projectId"); pid != "" {
q = q.Where("project_id = ?", pid)
}
if from := r.URL.Query().Get("from"); from != "" {
q = q.Where("date >= ?", from)
}
if to := r.URL.Query().Get("to"); to != "" {
q = q.Where("date <= ?", to)
}
var out []models.Transaction
q.Limit(1000).Find(&out)
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleCreateTransaction(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var t models.Transaction
if err := decodeJSON(r, &t); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
t.CreatedBy = currentUser(r.Context()).Email
s.db.Create(&t)
s.audit(r, "create", "transaction", t.ID, t.Kind)
writeJSON(w, http.StatusCreated, t)
}
func (s *Server) handlePatchTransaction(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var t models.Transaction
if err := s.db.First(&t, "id = ?", chi.URLParam(r, "txId")).Error; err != nil {
writeError(w, http.StatusNotFound, "거래를 찾을 수 없습니다")
return
}
var patch map[string]interface{}
if err := decodeJSON(r, &patch); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
delete(patch, "id")
s.db.Model(&t).Updates(patch)
s.db.First(&t, "id = ?", t.ID)
writeJSON(w, http.StatusOK, t)
}
func (s *Server) handleDeleteTransaction(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
s.db.Delete(&models.Transaction{}, "id = ?", chi.URLParam(r, "txId"))
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
func (s *Server) handleListTaxes(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var out []models.TaxRecord
s.db.Order("due_date desc").Find(&out)
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleCreateTax(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var t models.TaxRecord
if err := decodeJSON(r, &t); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.db.Create(&t)
writeJSON(w, http.StatusCreated, t)
}
func (s *Server) handlePatchTax(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var t models.TaxRecord
if err := s.db.First(&t, "id = ?", chi.URLParam(r, "taxId")).Error; err != nil {
writeError(w, http.StatusNotFound, "세금 항목을 찾을 수 없습니다")
return
}
var patch map[string]interface{}
if err := decodeJSON(r, &patch); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
delete(patch, "id")
s.db.Model(&t).Updates(patch)
s.db.First(&t, "id = ?", t.ID)
writeJSON(w, http.StatusOK, t)
}
// ---- summary: cashflow vs incentive gap -----------------------------------
type acctSummary struct {
Year int `json:"year"`
CashIn float64 `json:"cashIn"`
CashOut float64 `json:"cashOut"`
Net float64 `json:"net"`
IncentiveApplied float64 `json:"incentiveApplied"` // KRW value of applied points
IncentivePaid float64 `json:"incentivePaid"` // actually disbursed (지급완료)
Gap float64 `json:"gap"` // applied paid (미지급 부채성)
Monthly []monthlyPL `json:"monthly"`
ByKind map[string]float64 `json:"byKind"`
}
type monthlyPL struct {
Month string `json:"month"`
Income float64 `json:"income"`
Expense float64 `json:"expense"`
Net float64 `json:"net"`
}
func (s *Server) handleAccountingSummary(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
year := yearParam(r)
prefix := strconv.Itoa(year)
var txns []models.Transaction
s.db.Where("date LIKE ?", prefix+"%").Find(&txns)
sum := acctSummary{Year: year, ByKind: map[string]float64{}}
monthly := map[string]*monthlyPL{}
for m := 1; m <= 12; m++ {
key := prefix + "-" + pad2(m)
monthly[key] = &monthlyPL{Month: key}
}
for _, t := range txns {
sum.ByKind[t.Kind] += t.Amount
mk := t.Date
if len(mk) >= 7 {
mk = mk[:7]
}
row := monthly[mk]
if row == nil {
row = &monthlyPL{Month: mk}
monthly[mk] = row
}
if t.Kind == models.TxnIncome {
sum.CashIn += t.Amount
row.Income += t.Amount
} else {
out := t.Amount
if out < 0 {
out = -out
}
sum.CashOut += out
row.Expense += out
}
row.Net = row.Income - row.Expense
}
sum.Net = sum.CashIn - sum.CashOut
// incentive applied (반영완료/지급완료) point value vs actually paid (지급완료)
var appliedPts, paidPts float64
s.db.Model(&models.UserIncentive{}).Where("year = ? AND (fix_status = ? OR fix_status = ?)",
year, models.FixApplied, models.FixPaid).Select("COALESCE(SUM(points),0)").Scan(&appliedPts)
s.db.Model(&models.UserIncentive{}).Where("year = ? AND fix_status = ?",
year, models.FixPaid).Select("COALESCE(SUM(points),0)").Scan(&paidPts)
_, eng := s.incentiveConfig(year)
sum.IncentiveApplied = appliedPts * eng.PointRate
sum.IncentivePaid = paidPts * eng.PointRate
sum.Gap = sum.IncentiveApplied - sum.IncentivePaid
// ordered monthly slice
for m := 1; m <= 12; m++ {
key := prefix + "-" + pad2(m)
sum.Monthly = append(sum.Monthly, *monthly[key])
}
writeJSON(w, http.StatusOK, sum)
}
var _ = time.Now

View File

@ -0,0 +1,345 @@
package httpapi
import (
"net/http"
"strconv"
"time"
"spin/internal/models"
"spin/internal/worktime"
"github.com/go-chi/chi/v5"
)
// scopeEmail resolves which member's data a request targets. Members are always
// scoped to themselves; admins may pass ?email= to inspect anyone (or "" = all).
func (s *Server) scopeEmail(r *http.Request) (email string, all bool) {
if !s.isAdmin(r) {
return s.email(r), false
}
q := lc(r.URL.Query().Get("email"))
if q == "" {
return "", true
}
return q, false
}
// ---- attendance -----------------------------------------------------------
func (s *Server) handleListAttendance(w http.ResponseWriter, r *http.Request) {
email, all := s.scopeEmail(r)
q := s.db.Order("date desc")
if !all {
q = q.Where("lower(member_email) = ?", email)
}
if month := r.URL.Query().Get("month"); month != "" { // YYYY-MM
q = q.Where("date LIKE ?", month+"%")
}
var out []models.Attendance
q.Find(&out)
writeJSON(w, http.StatusOK, out)
}
// handlePunch records a clock-in (first call of the day) or clock-out.
func (s *Server) handlePunch(w http.ResponseWriter, r *http.Request) {
email := s.email(r)
now := time.Now()
date := now.Format("2006-01-02")
pol := s.activeWorkPolicy()
var a models.Attendance
err := s.db.Where("lower(member_email) = ? AND date = ?", email, date).First(&a).Error
if err != nil {
a = models.Attendance{MemberEmail: email, Date: date, ClockIn: &now, Source: "web"}
s.db.Create(&a)
writeJSON(w, http.StatusOK, a)
return
}
// already has a record → set clock-out and compute worked minutes
a.ClockOut = &now
a.WorkMinutes = worktime.NetWorkMinutes(a.ClockIn, a.ClockOut, pol.LunchMinutes)
s.db.Save(&a)
writeJSON(w, http.StatusOK, a)
}
// handleTimesheet returns the monthly roll-up for the scoped member.
func (s *Server) handleTimesheet(w http.ResponseWriter, r *http.Request) {
email := s.email(r)
if s.isAdmin(r) {
if q := lc(r.URL.Query().Get("email")); q != "" {
email = q
}
}
now := time.Now()
year, _ := strconv.Atoi(r.URL.Query().Get("year"))
month, _ := strconv.Atoi(r.URL.Query().Get("month"))
if year == 0 {
year = now.Year()
}
if month == 0 {
month = int(now.Month())
}
prefix := strconv.Itoa(year) + "-" + pad2(month)
pol := s.activeWorkPolicy()
var att []models.Attendance
s.db.Where("lower(member_email) = ? AND date LIKE ?", email, prefix+"%").Find(&att)
worked, days := 0, 0
for _, a := range att {
worked += a.WorkMinutes
if a.WorkMinutes > 0 {
days++
}
}
// recognized leave minutes (approved annual/public/etc within month)
var leaves []models.LeaveRequest
s.db.Where("lower(member_email) = ? AND status = ? AND start_date LIKE ?",
email, models.StatusApproved, prefix+"%").Find(&leaves)
leaveMin := 0
for _, lv := range leaves {
if lv.Type == models.LeaveUnpaid {
continue
}
leaveMin += int(lv.Days * float64(pol.DailyStandardMin))
}
var ots []models.OvertimeRequest
s.db.Where("lower(member_email) = ? AND status = ? AND date LIKE ?",
email, models.StatusApproved, prefix+"%").Find(&ots)
otMin := 0
for _, o := range ots {
otMin += o.Minutes
}
writeJSON(w, http.StatusOK, worktime.Compute(year, month, pol.DailyStandardMin, worked, leaveMin, otMin, days))
}
func pad2(n int) string {
if n < 10 {
return "0" + strconv.Itoa(n)
}
return strconv.Itoa(n)
}
// ---- leave ----------------------------------------------------------------
func (s *Server) handleListLeave(w http.ResponseWriter, r *http.Request) {
email, all := s.scopeEmail(r)
q := s.db.Order("created_at desc")
if !all {
q = q.Where("lower(member_email) = ?", email)
}
if st := r.URL.Query().Get("status"); st != "" {
q = q.Where("status = ?", st)
}
var out []models.LeaveRequest
q.Find(&out)
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleCreateLeave(w http.ResponseWriter, r *http.Request) {
var lv models.LeaveRequest
if err := decodeJSON(r, &lv); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
lv.MemberEmail = s.email(r) // members can only file for themselves
lv.Status = models.StatusPending
lv.Approver = ""
if lv.Days == 0 {
lv.Days = leaveDays(lv)
}
if err := s.db.Create(&lv).Error; err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusCreated, lv)
}
// leaveDays estimates day count from the date span (half-day types = 0.5).
func leaveDays(lv models.LeaveRequest) float64 {
if lv.Type == models.LeaveHalfAM || lv.Type == models.LeaveHalfPM {
return 0.5
}
s, e1 := lv.StartDate, lv.EndDate
if e1 == "" {
e1 = s
}
t1, err1 := time.Parse("2006-01-02", s)
t2, err2 := time.Parse("2006-01-02", e1)
if err1 != nil || err2 != nil {
return 1
}
return t2.Sub(t1).Hours()/24 + 1
}
type decision struct {
Approve bool `json:"approve"`
Memo string `json:"memo"`
}
func (s *Server) handleDecideLeave(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var lv models.LeaveRequest
if err := s.db.First(&lv, "id = ?", chi.URLParam(r, "id")).Error; err != nil {
writeError(w, http.StatusNotFound, "신청을 찾을 수 없습니다")
return
}
var d decision
decodeJSON(r, &d)
now := time.Now()
lv.Status = models.StatusRejected
if d.Approve {
lv.Status = models.StatusApproved
s.applyLeaveBalance(lv)
}
lv.Approver = currentUser(r.Context()).Email
lv.DecidedAt = &now
lv.DecisionMemo = d.Memo
s.db.Save(&lv)
s.audit(r, "decide", "leave", lv.ID, lv.Status)
writeJSON(w, http.StatusOK, lv)
}
// applyLeaveBalance draws down the annual leave balance for 연차 types.
func (s *Server) applyLeaveBalance(lv models.LeaveRequest) {
if lv.Type != models.LeaveAnnual && lv.Type != models.LeaveHalfAM && lv.Type != models.LeaveHalfPM {
return
}
year := time.Now().Year()
if t, err := time.Parse("2006-01-02", lv.StartDate); err == nil {
year = t.Year()
}
var bal models.LeaveBalance
if err := s.db.Where("lower(member_email) = ? AND year = ?", lc(lv.MemberEmail), year).First(&bal).Error; err != nil {
bal = models.LeaveBalance{MemberEmail: lv.MemberEmail, Year: year, Granted: 15}
s.db.Create(&bal)
}
bal.Used += lv.Days
s.db.Save(&bal)
}
func (s *Server) handleCancelLeave(w http.ResponseWriter, r *http.Request) {
var lv models.LeaveRequest
if err := s.db.First(&lv, "id = ?", chi.URLParam(r, "id")).Error; err != nil {
writeError(w, http.StatusNotFound, "신청을 찾을 수 없습니다")
return
}
if !s.isAdmin(r) && !s.owns(r, lv.MemberEmail) {
writeError(w, http.StatusForbidden, "본인 신청만 취소할 수 있습니다")
return
}
lv.Status = models.StatusCanceled
s.db.Save(&lv)
writeJSON(w, http.StatusOK, lv)
}
// ---- overtime -------------------------------------------------------------
func (s *Server) handleListOvertime(w http.ResponseWriter, r *http.Request) {
email, all := s.scopeEmail(r)
q := s.db.Order("created_at desc")
if !all {
q = q.Where("lower(member_email) = ?", email)
}
var out []models.OvertimeRequest
q.Find(&out)
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleCreateOvertime(w http.ResponseWriter, r *http.Request) {
var o models.OvertimeRequest
if err := decodeJSON(r, &o); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
o.MemberEmail = s.email(r)
o.Status = models.StatusPending
if err := s.db.Create(&o).Error; err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusCreated, o)
}
func (s *Server) handleDecideOvertime(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var o models.OvertimeRequest
if err := s.db.First(&o, "id = ?", chi.URLParam(r, "id")).Error; err != nil {
writeError(w, http.StatusNotFound, "신청을 찾을 수 없습니다")
return
}
var d decision
decodeJSON(r, &d)
now := time.Now()
o.Status = models.StatusRejected
if d.Approve {
o.Status = models.StatusApproved
}
o.Approver = currentUser(r.Context()).Email
o.DecidedAt = &now
o.DecisionMemo = d.Memo
s.db.Save(&o)
s.audit(r, "decide", "overtime", o.ID, o.Status)
writeJSON(w, http.StatusOK, o)
}
// ---- work policy ----------------------------------------------------------
// activeWorkPolicy returns the active policy or a sane default.
func (s *Server) activeWorkPolicy() models.WorkPolicy {
var p models.WorkPolicy
if err := s.db.Where("active = ?", true).First(&p).Error; err != nil {
return models.WorkPolicy{WeeklyHours: 40, DailyStandardMin: 480, LunchMinutes: 60,
CoreStart: "09:00", CoreEnd: "18:00", AnnualLeaveBase: 15, Active: true}
}
return p
}
func (s *Server) handleGetWorkPolicy(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.activeWorkPolicy())
}
func (s *Server) handlePutWorkPolicy(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var in models.WorkPolicy
if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
var p models.WorkPolicy
if err := s.db.Where("active = ?", true).First(&p).Error; err != nil {
in.Active = true
s.db.Create(&in)
writeJSON(w, http.StatusOK, in)
return
}
in.ID = p.ID
in.Active = true
s.db.Save(&in)
writeJSON(w, http.StatusOK, in)
}
// ---- approval queue (admin) ----------------------------------------------
type approvalQueue struct {
Leave []models.LeaveRequest `json:"leave"`
Overtime []models.OvertimeRequest `json:"overtime"`
}
func (s *Server) handleApprovalQueue(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var q approvalQueue
s.db.Where("status = ?", models.StatusPending).Order("created_at asc").Find(&q.Leave)
s.db.Where("status = ?", models.StatusPending).Order("created_at asc").Find(&q.Overtime)
writeJSON(w, http.StatusOK, q)
}

View File

@ -0,0 +1,59 @@
package httpapi
import (
"net/http"
"strconv"
"time"
"spin/internal/models"
)
// handleDashboard returns a role-tailored summary. Members get their own work /
// incentive / project snapshot; admins additionally get company-wide widgets.
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
email := s.email(r)
year := time.Now().Year()
out := map[string]interface{}{"isAdmin": s.isAdmin(r), "year": year}
// my projects count
out["myProjects"] = len(s.myProjectIDs(email))
// my applied incentive points this year
var myPoints float64
s.db.Model(&models.UserIncentive{}).
Where("lower(member_email) = ? AND year = ? AND (fix_status = ? OR fix_status = ?)",
email, year, models.FixApplied, models.FixPaid).
Select("COALESCE(SUM(points),0)").Scan(&myPoints)
out["myPoints"] = myPoints
// my pending requests
var myPending int64
s.db.Model(&models.LeaveRequest{}).Where("lower(member_email) = ? AND status = ?", email, models.StatusPending).Count(&myPending)
out["myPendingRequests"] = myPending
if s.isAdmin(r) {
var pendingLeave, pendingOT, activeProjects int64
s.db.Model(&models.LeaveRequest{}).Where("status = ?", models.StatusPending).Count(&pendingLeave)
s.db.Model(&models.OvertimeRequest{}).Where("status = ?", models.StatusPending).Count(&pendingOT)
s.db.Model(&models.Project{}).Where("status = ?", "active").Count(&activeProjects)
out["pendingApprovals"] = pendingLeave + pendingOT
out["activeProjects"] = activeProjects
// cash position this year
var cashIn, cashOut float64
yr := strconv.Itoa(year)
s.db.Model(&models.Transaction{}).Where("date LIKE ? AND kind = ?", yr+"%", models.TxnIncome).
Select("COALESCE(SUM(amount),0)").Scan(&cashIn)
s.db.Model(&models.Transaction{}).Where("date LIKE ? AND kind <> ?", yr+"%", models.TxnIncome).
Select("COALESCE(SUM(ABS(amount)),0)").Scan(&cashOut)
out["cashIn"] = cashIn
out["cashOut"] = cashOut
out["cashNet"] = cashIn - cashOut
// upcoming payment splits (expected, unpaid)
var upcoming []models.PaymentSplit
s.db.Where("paid = ? AND expected_date <> ''", false).Order("expected_date asc").Limit(8).Find(&upcoming)
out["upcomingPayments"] = upcoming
}
writeJSON(w, http.StatusOK, out)
}

View File

@ -0,0 +1,450 @@
package httpapi
import (
"encoding/json"
"net/http"
"strconv"
"time"
"spin/internal/incentive"
"spin/internal/models"
"github.com/go-chi/chi/v5"
)
// ---- config ---------------------------------------------------------------
// incentiveConfig loads (or defaults) the IncentiveConfig for a year and adapts
// it to the engine's plain Config.
func (s *Server) incentiveConfig(year int) (models.IncentiveConfig, incentive.Config) {
var c models.IncentiveConfig
if err := s.db.Where("year = ?", year).First(&c).Error; err != nil {
c = models.IncentiveConfig{Year: year, PointRate: 1_000_000, DepositPct: 30,
MiddlePct: 40, FinalPct: 30, NonBECompanyPct: 60, NonBEPartnerPct: 40,
RankQuota: defaultQuota()}
}
return c, toEngineConfig(c)
}
func defaultQuota() map[string]interface{} {
return map[string]interface{}{
models.RankJunior: 30.0, models.RankSenior: 50.0,
models.RankLead: 80.0, models.RankPartner: 120.0,
}
}
func toEngineConfig(c models.IncentiveConfig) incentive.Config {
quota := map[string]float64{}
for k, v := range c.RankQuota {
if f, ok := toFloat(v); ok {
quota[k] = f
}
}
return incentive.Config{
PointRate: c.PointRate, DepositPct: c.DepositPct, MiddlePct: c.MiddlePct,
FinalPct: c.FinalPct, NonBECompanyPct: c.NonBECompanyPct,
NonBEPartnerPct: c.NonBEPartnerPct, RankQuota: quota,
}
}
func toFloat(v interface{}) (float64, bool) {
switch n := v.(type) {
case float64:
return n, true
case float32:
return float64(n), true
case int:
return float64(n), true
case int64:
return float64(n), true
case json.Number:
// GORM's datatypes.JSONMap decodes JSON numbers as json.Number.
f, err := n.Float64()
return f, err == nil
case string:
f, err := strconv.ParseFloat(n, 64)
return f, err == nil
}
return 0, false
}
func yearParam(r *http.Request) int {
if y, err := strconv.Atoi(r.URL.Query().Get("year")); err == nil && y > 0 {
return y
}
return time.Now().Year()
}
func (s *Server) handleGetIncentiveConfig(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
c, _ := s.incentiveConfig(yearParam(r))
writeJSON(w, http.StatusOK, c)
}
func (s *Server) handlePutIncentiveConfig(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var in models.IncentiveConfig
if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if in.Year == 0 {
in.Year = time.Now().Year()
}
var existing models.IncentiveConfig
if err := s.db.Where("year = ?", in.Year).First(&existing).Error; err == nil {
in.ID = existing.ID
s.db.Save(&in)
} else {
s.db.Create(&in)
}
s.audit(r, "update", "incentive_config", strconv.Itoa(in.Year), "")
writeJSON(w, http.StatusOK, in)
}
// ---- recompute (rebuild stages + allocations from contract) ---------------
func (s *Server) projectMemberPortions(projectID string) []incentive.MemberPortion {
var pms []models.ProjectMember
s.db.Where("project_id = ?", projectID).Find(&pms)
out := make([]incentive.MemberPortion, 0, len(pms))
for _, pm := range pms {
isPartner := false
if m := s.lookupMember(pm.MemberEmail); m != nil {
isPartner = m.IsPartner || m.Rank == models.RankPartner
}
out = append(out, incentive.MemberPortion{Email: pm.MemberEmail, Portion: pm.Portion, IsPartner: isPartner})
}
return out
}
func (s *Server) handleRecomputeProject(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
pid := chi.URLParam(r, "id")
var contract models.Contract
if err := s.db.First(&contract, "project_id = ?", pid).Error; err != nil {
writeError(w, http.StatusBadRequest, "계약 정보(BE/계약금액)를 먼저 입력하세요")
return
}
year := yearParam(r)
_, eng := s.incentiveConfig(year)
stages := incentive.ComputeStages(contract.TotalAmount, contract.BEAmount, eng)
// upsert PaymentStage rows (keep status/dates on existing kind×scope).
for _, st := range stages {
var existing models.PaymentStage
err := s.db.Where("project_id = ? AND kind = ? AND scope = ?", pid, st.Kind, st.Scope).First(&existing).Error
if err != nil {
s.db.Create(&models.PaymentStage{ProjectID: pid, Kind: st.Kind, Scope: st.Scope,
Amount: st.Amount, Pct: st.Pct, Status: models.FixPlanned})
} else {
existing.Amount = st.Amount
existing.Pct = st.Pct
s.db.Save(&existing)
}
}
// rebuild non-override UserIncentive rows.
members := s.projectMemberPortions(pid)
allocs := incentive.ComputeAllocs(stages, members, eng)
s.db.Where("project_id = ? AND \"override\" = ?", pid, false).Delete(&models.UserIncentive{})
var stageRows []models.PaymentStage
s.db.Where("project_id = ?", pid).Find(&stageRows)
stageID := map[string]string{}
stageStatus := map[string]string{}
for _, sr := range stageRows {
stageID[sr.Kind+"|"+sr.Scope] = sr.ID
stageStatus[sr.Kind+"|"+sr.Scope] = sr.Status
}
for _, a := range allocs {
key := a.Kind + "|" + a.Scope
// skip member+stage that already has an override row
var cnt int64
s.db.Model(&models.UserIncentive{}).
Where("project_id = ? AND lower(member_email) = lower(?) AND kind = ? AND scope = ? AND \"override\" = ?",
pid, a.Email, a.Kind, a.Scope, true).Count(&cnt)
if cnt > 0 {
continue
}
s.db.Create(&models.UserIncentive{ProjectID: pid, MemberEmail: a.Email, StageID: stageID[key],
Kind: a.Kind, Scope: a.Scope, Year: year, Portion: a.Portion, Amount: a.Amount,
Points: a.Points, FixStatus: stageStatus[key]})
}
s.audit(r, "recompute", "incentive", pid, "")
writeJSON(w, http.StatusOK, map[string]interface{}{"stages": len(stages), "allocs": len(allocs)})
}
func (s *Server) handleListStages(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var out []models.PaymentStage
q := s.db.Order("scope asc, kind asc")
if pid := r.URL.Query().Get("projectId"); pid != "" {
q = q.Where("project_id = ?", pid)
}
q.Find(&out)
writeJSON(w, http.StatusOK, out)
}
// handleSetStageStatus toggles a stage's fix lifecycle and propagates the status
// to its non-override UserIncentive rows (반영중 → 반영완료 등).
func (s *Server) handleSetStageStatus(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var st models.PaymentStage
if err := s.db.First(&st, "id = ?", chi.URLParam(r, "stId")).Error; err != nil {
writeError(w, http.StatusNotFound, "단계를 찾을 수 없습니다")
return
}
var body struct {
Status string `json:"status"`
FixedDate string `json:"fixedDate"`
}
decodeJSON(r, &body)
st.Status = body.Status
if body.FixedDate != "" {
st.FixedDate = body.FixedDate
}
s.db.Save(&st)
// propagate to non-override allocations
now := time.Now()
updates := map[string]interface{}{"fix_status": body.Status}
if body.Status == models.FixApplied {
updates["applied_at"] = &now
}
if body.Status == models.FixPaid {
updates["paid_at"] = &now
}
s.db.Model(&models.UserIncentive{}).
Where("stage_id = ? AND \"override\" = ?", st.ID, false).Updates(updates)
s.audit(r, "stage_status", "payment_stage", st.ID, body.Status)
writeJSON(w, http.StatusOK, st)
}
// ---- user incentives ------------------------------------------------------
func (s *Server) handleListUserIncentives(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
q := s.db.Order("created_at desc")
if pid := r.URL.Query().Get("projectId"); pid != "" {
q = q.Where("project_id = ?", pid)
}
if em := lc(r.URL.Query().Get("email")); em != "" {
q = q.Where("lower(member_email) = ?", em)
}
var out []models.UserIncentive
q.Find(&out)
writeJSON(w, http.StatusOK, out)
}
// handlePatchUserIncentive is the custom-override entry point: any field can be
// hand-edited and the row is flagged Override so recompute won't clobber it.
func (s *Server) handlePatchUserIncentive(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var ui models.UserIncentive
if err := s.db.First(&ui, "id = ?", chi.URLParam(r, "uiId")).Error; err != nil {
writeError(w, http.StatusNotFound, "내역을 찾을 수 없습니다")
return
}
var patch map[string]interface{}
if err := decodeJSON(r, &patch); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
delete(patch, "id")
patch["override"] = true // any manual edit pins the row
s.db.Model(&ui).Updates(patch)
s.db.First(&ui, "id = ?", ui.ID)
s.audit(r, "override", "user_incentive", ui.ID, "")
writeJSON(w, http.StatusOK, ui)
}
// ---- member dashboard -----------------------------------------------------
type myIncentive struct {
Year int `json:"year"`
Rank string `json:"rank"`
Quota float64 `json:"quota"`
PointsTotal float64 `json:"pointsTotal"` // all points (any status)
PointsApplied float64 `json:"pointsApplied"` // applied+paid
ExcessPoints float64 `json:"excessPoints"`
PointRate float64 `json:"pointRate"`
EstPayout float64 `json:"estPayout"`
Items []models.UserIncentive `json:"items"`
ByProject map[string]float64 `json:"byProject"`
}
func (s *Server) handleMyIncentive(w http.ResponseWriter, r *http.Request) {
email := s.email(r)
if s.isAdmin(r) {
if q := lc(r.URL.Query().Get("email")); q != "" {
email = q
}
}
year := yearParam(r)
cfg, eng := s.incentiveConfig(year)
var items []models.UserIncentive
s.db.Where("lower(member_email) = ? AND year = ?", email, year).Order("created_at desc").Find(&items)
total, applied := 0.0, 0.0
byProject := map[string]float64{}
for _, it := range items {
total += it.Points
if it.FixStatus == models.FixApplied || it.FixStatus == models.FixPaid {
applied += it.Points
}
byProject[it.ProjectID] += it.Points
}
rank := ""
if m := s.lookupMember(email); m != nil {
rank = m.Rank
}
st := incentive.ComputeSettlement(email, rank, applied, 0, eng)
writeJSON(w, http.StatusOK, myIncentive{
Year: year, Rank: rank, Quota: st.Quota, PointsTotal: total, PointsApplied: applied,
ExcessPoints: st.ExcessPoints, PointRate: cfg.PointRate, EstPayout: st.PayoutAmount,
Items: items, ByProject: byProject,
})
}
// ---- quarterly settlement -------------------------------------------------
func (s *Server) handleListSettlements(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
q := s.db.Order("year desc, quarter desc")
if y, err := strconv.Atoi(r.URL.Query().Get("year")); err == nil && y > 0 {
q = q.Where("year = ?", y)
}
var out []models.QuarterlySettlement
q.Find(&out)
writeJSON(w, http.StatusOK, out)
}
// handleRunSettlement computes (or refreshes) settlement rows for a year+quarter.
func (s *Server) handleRunSettlement(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var body struct {
Year int `json:"year"`
Quarter int `json:"quarter"`
}
decodeJSON(r, &body)
if body.Year == 0 {
body.Year = time.Now().Year()
}
if body.Quarter == 0 {
body.Quarter = (int(time.Now().Month())-1)/3 + 1
}
_, eng := s.incentiveConfig(body.Year)
// applied points per member up to this quarter (cumulative within the year).
var members []models.Member
s.db.Find(&members)
var results []models.QuarterlySettlement
for _, m := range members {
var applied float64
s.db.Model(&models.UserIncentive{}).
Where("lower(member_email) = lower(?) AND year = ? AND (fix_status = ? OR fix_status = ?)",
m.Email, body.Year, models.FixApplied, models.FixPaid).
Select("COALESCE(SUM(points),0)").Scan(&applied)
// prior paid this year
var paidYTD float64
s.db.Model(&models.QuarterlySettlement{}).
Where("lower(member_email) = lower(?) AND year = ? AND quarter < ? AND fixed = ?",
m.Email, body.Year, body.Quarter, true).
Select("COALESCE(SUM(payout_points),0)").Scan(&paidYTD)
st := incentive.ComputeSettlement(m.Email, m.Rank, applied, paidYTD, eng)
if st.PointsCumul == 0 && st.PayoutPoints == 0 {
continue
}
var existing models.QuarterlySettlement
row := models.QuarterlySettlement{MemberEmail: m.Email, Year: body.Year, Quarter: body.Quarter,
Rank: m.Rank, Quota: st.Quota, PointsCumul: st.PointsCumul, ExcessPoints: st.ExcessPoints,
PaidPointsYTD: st.PaidPointsYTD, PayoutPoints: st.PayoutPoints, PayoutAmount: st.PayoutAmount}
if err := s.db.Where("lower(member_email)=lower(?) AND year=? AND quarter=?", m.Email, body.Year, body.Quarter).First(&existing).Error; err == nil {
if !existing.Fixed { // never overwrite a fixed row
row.ID = existing.ID
s.db.Save(&row)
}
row = existing
} else {
s.db.Create(&row)
}
results = append(results, row)
}
s.audit(r, "settlement_run", "settlement", strconv.Itoa(body.Year)+"Q"+strconv.Itoa(body.Quarter), "")
writeJSON(w, http.StatusOK, results)
}
// handleFixSettlement locks a settlement row (그 차익이 급여로 확정 지급).
func (s *Server) handleFixSettlement(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var st models.QuarterlySettlement
if err := s.db.First(&st, "id = ?", chi.URLParam(r, "sId")).Error; err != nil {
writeError(w, http.StatusNotFound, "정산을 찾을 수 없습니다")
return
}
now := time.Now()
st.Fixed = true
st.FixedAt = &now
s.db.Save(&st)
s.audit(r, "settlement_fix", "settlement", st.ID, "")
writeJSON(w, http.StatusOK, st)
}
// ---- simulation (pure, no persistence) ------------------------------------
type simRequest struct {
Year int `json:"year"`
Total float64 `json:"total"`
BE float64 `json:"be"`
Members []incentive.MemberPortion `json:"members"`
Config *incentive.Config `json:"config"` // optional overrides
}
func (s *Server) handleSimulate(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var req simRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
_, eng := s.incentiveConfig(yearParam(r))
if req.Config != nil {
eng = *req.Config
if eng.PointRate == 0 {
eng.PointRate = 1
}
}
stages := incentive.ComputeStages(req.Total, req.BE, eng)
allocs := incentive.ComputeAllocs(stages, req.Members, eng)
// per-member point totals
byMember := map[string]float64{}
for _, a := range allocs {
byMember[a.Email] += a.Points
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"stages": stages, "allocs": allocs, "byMember": byMember,
})
}

View File

@ -0,0 +1,207 @@
package httpapi
import (
"net/http"
"spin/internal/models"
"github.com/go-chi/chi/v5"
)
// ---- members --------------------------------------------------------------
// handleListMembers: admins see everyone; a regular member sees only themselves
// (the directory itself is admin-managed, individuals only see their own row).
func (s *Server) handleListMembers(w http.ResponseWriter, r *http.Request) {
q := s.db.Order("display_name asc")
if !s.isAdmin(r) {
q = q.Where("lower(email) = ?", s.email(r))
}
var out []models.Member
if err := q.Find(&out).Error; err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleGetMember(w http.ResponseWriter, r *http.Request) {
var m models.Member
if err := s.db.First(&m, "id = ?", chi.URLParam(r, "id")).Error; err != nil {
writeError(w, http.StatusNotFound, "구성원을 찾을 수 없습니다")
return
}
if !s.isAdmin(r) && !s.owns(r, m.Email) {
writeError(w, http.StatusForbidden, "본인 정보만 조회할 수 있습니다")
return
}
writeJSON(w, http.StatusOK, m)
}
func (s *Server) handleCreateMember(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var m models.Member
if err := decodeJSON(r, &m); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if m.Role == "" {
m.Role = models.RoleMember
}
if m.Status == "" {
m.Status = "active"
}
if err := s.db.Create(&m).Error; err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.audit(r, "create", "member", m.ID, m.Email)
writeJSON(w, http.StatusCreated, m)
}
// memberSelfPatch is the small set of fields a member may edit on their own row.
type memberSelfPatch struct {
Phone *string `json:"phone"`
Position *string `json:"position"`
}
func (s *Server) handlePatchMember(w http.ResponseWriter, r *http.Request) {
var m models.Member
if err := s.db.First(&m, "id = ?", chi.URLParam(r, "id")).Error; err != nil {
writeError(w, http.StatusNotFound, "구성원을 찾을 수 없습니다")
return
}
if s.isAdmin(r) {
var patch map[string]interface{}
if err := decodeJSON(r, &patch); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
// whitelist admin-editable columns
allowed := map[string]bool{"displayName": true, "rank": true, "departmentId": true,
"role": true, "isPartner": true, "phone": true, "position": true, "status": true,
"joinDate": true, "annualLeave": true, "email": true}
cols := map[string]interface{}{}
for k, v := range patch {
if allowed[k] {
cols[colName(k)] = v
}
}
if err := s.db.Model(&m).Updates(cols).Error; err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.audit(r, "update", "member", m.ID, "")
} else {
if !s.owns(r, m.Email) {
writeError(w, http.StatusForbidden, "본인 정보만 수정할 수 있습니다")
return
}
var p memberSelfPatch
if err := decodeJSON(r, &p); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if p.Phone != nil {
m.Phone = *p.Phone
}
if p.Position != nil {
m.Position = *p.Position
}
s.db.Save(&m)
}
s.db.First(&m, "id = ?", m.ID)
writeJSON(w, http.StatusOK, m)
}
func (s *Server) handleDeleteMember(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
id := chi.URLParam(r, "id")
if err := s.db.Delete(&models.Member{}, "id = ?", id).Error; err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.audit(r, "delete", "member", id, "")
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
// colName maps a JSON field name to its snake_case DB column.
func colName(json string) string {
switch json {
case "displayName":
return "display_name"
case "departmentId":
return "department_id"
case "isPartner":
return "is_partner"
case "joinDate":
return "join_date"
case "annualLeave":
return "annual_leave"
default:
return json
}
}
// ---- departments ----------------------------------------------------------
func (s *Server) handleListDepartments(w http.ResponseWriter, r *http.Request) {
var out []models.Department
s.db.Order("name asc").Find(&out)
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleCreateDepartment(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var d models.Department
if err := decodeJSON(r, &d); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.db.Create(&d)
writeJSON(w, http.StatusCreated, d)
}
func (s *Server) handlePatchDepartment(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var d models.Department
if err := s.db.First(&d, "id = ?", chi.URLParam(r, "id")).Error; err != nil {
writeError(w, http.StatusNotFound, "부서를 찾을 수 없습니다")
return
}
var patch models.Department
decodeJSON(r, &patch)
if patch.Name != "" {
d.Name = patch.Name
}
d.LeadEmail = patch.LeadEmail
s.db.Save(&d)
writeJSON(w, http.StatusOK, d)
}
func (s *Server) handleDeleteDepartment(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
s.db.Delete(&models.Department{}, "id = ?", chi.URLParam(r, "id"))
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
// ---- audit ----------------------------------------------------------------
func (s *Server) handleListAudit(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var out []models.AuditLog
s.db.Order("created_at desc").Limit(500).Find(&out)
writeJSON(w, http.StatusOK, out)
}

View File

@ -0,0 +1,507 @@
package httpapi
import (
"fmt"
"net/http"
"strings"
"time"
"spin/internal/models"
"github.com/go-chi/chi/v5"
)
// ---- company / product / version (master data) ----------------------------
func (s *Server) handleListCompanies(w http.ResponseWriter, r *http.Request) {
var out []models.Company
s.db.Order("name asc").Find(&out)
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleCreateCompany(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var c models.Company
if err := decodeJSON(r, &c); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.db.Create(&c)
writeJSON(w, http.StatusCreated, c)
}
func (s *Server) handleListProducts(w http.ResponseWriter, r *http.Request) {
q := s.db.Order("name asc")
if cid := r.URL.Query().Get("companyId"); cid != "" {
q = q.Where("company_id = ?", cid)
}
var out []models.Product
q.Find(&out)
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleCreateProduct(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var p models.Product
if err := decodeJSON(r, &p); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.db.Create(&p)
writeJSON(w, http.StatusCreated, p)
}
func (s *Server) handleListVersions(w http.ResponseWriter, r *http.Request) {
q := s.db.Order("label asc")
if pid := r.URL.Query().Get("productId"); pid != "" {
q = q.Where("product_id = ?", pid)
}
var out []models.Version
q.Find(&out)
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleCreateVersion(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var v models.Version
if err := decodeJSON(r, &v); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.db.Create(&v)
writeJSON(w, http.StatusCreated, v)
}
// ---- projects -------------------------------------------------------------
// myProjectIDs returns the project IDs the caller is a member of (or PM of).
func (s *Server) myProjectIDs(email string) []string {
var ids []string
s.db.Model(&models.ProjectMember{}).Where("lower(member_email) = ?", lc(email)).
Distinct().Pluck("project_id", &ids)
var pmIDs []string
s.db.Model(&models.Project{}).Where("lower(pm_email) = ?", lc(email)).Pluck("id", &pmIDs)
return append(ids, pmIDs...)
}
func (s *Server) handleListProjects(w http.ResponseWriter, r *http.Request) {
q := s.db.Order("created_at desc")
if !s.isAdmin(r) {
ids := s.myProjectIDs(s.email(r))
if len(ids) == 0 {
writeJSON(w, http.StatusOK, []models.Project{})
return
}
q = q.Where("id IN ?", ids)
}
if cid := r.URL.Query().Get("companyId"); cid != "" {
q = q.Where("company_id = ?", cid)
}
if st := r.URL.Query().Get("status"); st != "" {
q = q.Where("status = ?", st)
}
var out []models.Project
q.Find(&out)
writeJSON(w, http.StatusOK, out)
}
// canSeeProject reports whether the caller may view a project (admin or member).
func (s *Server) canSeeProject(r *http.Request, projectID string) bool {
if s.isAdmin(r) {
return true
}
for _, id := range s.myProjectIDs(s.email(r)) {
if id == projectID {
return true
}
}
return false
}
func (s *Server) handleGetProject(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if !s.canSeeProject(r, id) {
writeError(w, http.StatusForbidden, "참여한 프로젝트만 조회할 수 있습니다")
return
}
var p models.Project
if err := s.db.First(&p, "id = ?", id).Error; err != nil {
writeError(w, http.StatusNotFound, "프로젝트를 찾을 수 없습니다")
return
}
writeJSON(w, http.StatusOK, p)
}
func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var p models.Project
if err := decodeJSON(r, &p); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if p.Status == "" {
p.Status = "planned"
}
s.db.Create(&p)
s.audit(r, "create", "project", p.ID, p.Name)
writeJSON(w, http.StatusCreated, p)
}
func (s *Server) handlePatchProject(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var p models.Project
if err := s.db.First(&p, "id = ?", chi.URLParam(r, "id")).Error; err != nil {
writeError(w, http.StatusNotFound, "프로젝트를 찾을 수 없습니다")
return
}
var patch map[string]interface{}
if err := decodeJSON(r, &patch); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
delete(patch, "id")
if err := s.db.Model(&p).Updates(patch).Error; err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.db.First(&p, "id = ?", p.ID)
writeJSON(w, http.StatusOK, p)
}
func (s *Server) handleDeleteProject(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
s.db.Delete(&models.Project{}, "id = ?", chi.URLParam(r, "id"))
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
// ---- project members (portion) -------------------------------------------
func (s *Server) handleListProjectMembers(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if !s.canSeeProject(r, id) {
writeError(w, http.StatusForbidden, "권한이 없습니다")
return
}
var out []models.ProjectMember
s.db.Where("project_id = ?", id).Find(&out)
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleUpsertProjectMember(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var pm models.ProjectMember
if err := decodeJSON(r, &pm); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
pm.ProjectID = chi.URLParam(r, "id")
if pm.ID != "" {
s.db.Save(&pm)
} else {
s.db.Create(&pm)
}
writeJSON(w, http.StatusOK, pm)
}
func (s *Server) handleDeleteProjectMember(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
s.db.Delete(&models.ProjectMember{}, "id = ?", chi.URLParam(r, "pmId"))
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
// ---- client contacts ------------------------------------------------------
func (s *Server) handleListContacts(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if !s.canSeeProject(r, id) {
writeError(w, http.StatusForbidden, "권한이 없습니다")
return
}
var out []models.ClientContact
s.db.Where("project_id = ?", id).Find(&out)
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleUpsertContact(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var c models.ClientContact
if err := decodeJSON(r, &c); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
c.ProjectID = chi.URLParam(r, "id")
if c.ID != "" {
s.db.Save(&c)
} else {
s.db.Create(&c)
}
writeJSON(w, http.StatusOK, c)
}
func (s *Server) handleDeleteContact(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
s.db.Delete(&models.ClientContact{}, "id = ?", chi.URLParam(r, "cId"))
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
// ---- tasks (gantt / kanban) ----------------------------------------------
func (s *Server) handleListTasks(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if !s.canSeeProject(r, id) {
writeError(w, http.StatusForbidden, "권한이 없습니다")
return
}
var out []models.ProjectTask
s.db.Where("project_id = ?", id).Order("order_idx asc, start asc").Find(&out)
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if !s.canSeeProject(r, id) {
writeError(w, http.StatusForbidden, "권한이 없습니다")
return
}
var t models.ProjectTask
if err := decodeJSON(r, &t); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
t.ProjectID = id
if t.Lane == "" {
t.Lane = "todo"
}
s.db.Create(&t)
writeJSON(w, http.StatusCreated, t)
}
func (s *Server) handlePatchTask(w http.ResponseWriter, r *http.Request) {
var t models.ProjectTask
if err := s.db.First(&t, "id = ?", chi.URLParam(r, "tId")).Error; err != nil {
writeError(w, http.StatusNotFound, "작업을 찾을 수 없습니다")
return
}
if !s.canSeeProject(r, t.ProjectID) {
writeError(w, http.StatusForbidden, "권한이 없습니다")
return
}
var patch map[string]interface{}
if err := decodeJSON(r, &patch); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
delete(patch, "id")
delete(patch, "projectId")
s.db.Model(&t).Updates(patch)
s.db.First(&t, "id = ?", t.ID)
writeJSON(w, http.StatusOK, t)
}
func (s *Server) handleDeleteTask(w http.ResponseWriter, r *http.Request) {
var t models.ProjectTask
if err := s.db.First(&t, "id = ?", chi.URLParam(r, "tId")).Error; err != nil {
writeError(w, http.StatusNotFound, "작업을 찾을 수 없습니다")
return
}
if !s.isAdmin(r) && !s.canSeeProject(r, t.ProjectID) {
writeError(w, http.StatusForbidden, "권한이 없습니다")
return
}
s.db.Delete(&models.ProjectTask{}, "id = ?", t.ID)
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
// ---- contract (ADMIN ONLY) ------------------------------------------------
func (s *Server) handleGetContract(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var c models.Contract
if err := s.db.First(&c, "project_id = ?", chi.URLParam(r, "id")).Error; err != nil {
writeJSON(w, http.StatusOK, nil) // no contract yet
return
}
writeJSON(w, http.StatusOK, c)
}
func (s *Server) handlePutContract(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
pid := chi.URLParam(r, "id")
var in models.Contract
if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
in.ProjectID = pid
var existing models.Contract
if err := s.db.First(&existing, "project_id = ?", pid).Error; err == nil {
in.ID = existing.ID
s.db.Save(&in)
} else {
s.db.Create(&in)
}
s.audit(r, "update", "contract", pid, "")
writeJSON(w, http.StatusOK, in)
}
// ---- contract files (ADMIN ONLY, S3) -------------------------------------
func (s *Server) handleListContractFiles(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var out []models.ContractFile
s.db.Where("project_id = ?", chi.URLParam(r, "id")).Order("created_at desc").Find(&out)
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleUploadContractFile(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
pid := chi.URLParam(r, "id")
if err := r.ParseMultipartForm(50 << 20); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
file, hdr, err := r.FormFile("file")
if err != nil {
writeError(w, http.StatusBadRequest, "file 필드가 필요합니다")
return
}
defer file.Close()
kind := r.FormValue("kind")
if kind == "" {
kind = "contract"
}
key := fmt.Sprintf("contracts/%s/%d-%s", pid, time.Now().UnixNano(), hdr.Filename)
if s.store != nil {
if err := s.store.Upload(r.Context(), key, hdr.Header.Get("Content-Type"), file, hdr.Size); err != nil {
writeError(w, http.StatusInternalServerError, "업로드 실패: "+err.Error())
return
}
}
cf := models.ContractFile{ProjectID: pid, Kind: kind, Filename: hdr.Filename, S3Key: key,
Size: hdr.Size, UploadedBy: currentUser(r.Context()).Email}
s.db.Create(&cf)
s.audit(r, "upload", "contract_file", cf.ID, hdr.Filename)
writeJSON(w, http.StatusCreated, cf)
}
func (s *Server) handleDownloadContractFile(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var cf models.ContractFile
if err := s.db.First(&cf, "id = ?", chi.URLParam(r, "fId")).Error; err != nil {
writeError(w, http.StatusNotFound, "파일을 찾을 수 없습니다")
return
}
if s.store == nil {
writeError(w, http.StatusServiceUnavailable, "스토리지가 비활성화되어 있습니다")
return
}
url, err := s.store.PresignGet(r.Context(), cf.S3Key)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"url": url})
}
func (s *Server) handleDeleteContractFile(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var cf models.ContractFile
if err := s.db.First(&cf, "id = ?", chi.URLParam(r, "fId")).Error; err != nil {
writeError(w, http.StatusNotFound, "파일을 찾을 수 없습니다")
return
}
if s.store != nil {
_ = s.store.Delete(r.Context(), cf.S3Key)
}
s.db.Delete(&models.ContractFile{}, "id = ?", cf.ID)
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
// ---- payment splits (ADMIN ONLY) -----------------------------------------
func (s *Server) handleListPayments(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var out []models.PaymentSplit
s.db.Where("project_id = ?", chi.URLParam(r, "id")).Order("order_idx asc, expected_date asc").Find(&out)
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleCreatePayment(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var p models.PaymentSplit
if err := decodeJSON(r, &p); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
p.ProjectID = chi.URLParam(r, "id")
s.db.Create(&p)
writeJSON(w, http.StatusCreated, p)
}
func (s *Server) handlePatchPayment(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var p models.PaymentSplit
if err := s.db.First(&p, "id = ?", chi.URLParam(r, "payId")).Error; err != nil {
writeError(w, http.StatusNotFound, "분할 항목을 찾을 수 없습니다")
return
}
var patch map[string]interface{}
if err := decodeJSON(r, &patch); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
delete(patch, "id")
delete(patch, "projectId")
s.db.Model(&p).Updates(patch)
s.db.First(&p, "id = ?", p.ID)
writeJSON(w, http.StatusOK, p)
}
func (s *Server) handleDeletePayment(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
s.db.Delete(&models.PaymentSplit{}, "id = ?", chi.URLParam(r, "payId"))
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
// guard against unused import when trimming
var _ = strings.TrimSpace

78
internal/httpapi/perms.go Normal file
View File

@ -0,0 +1,78 @@
package httpapi
import (
"net/http"
"strings"
"spin/internal/models"
)
// spin is a single internal company, so authorization is a 2-tier model rather
// than eQMS's per-company membership matrix:
//
// - admin → super-admin (Keycloak group ∩ ADMIN_GROUPS) OR Member.Role==admin.
// Sees and manages everything: approvals, incentive console,
// accounting, all member/project data, the bracketed [admin-only]
// contract & payment fields.
// - member → sees only their OWN data and may only SUBMIT requests.
//
// Ownership is enforced per-handler by comparing the row's member email to the
// caller's email (case-insensitive).
// isSuperAdmin reports the Keycloak/dev super-admin flag from the auth middleware.
func (s *Server) isSuperAdmin(r *http.Request) bool {
return currentUser(r.Context()).IsSuperAdmin
}
// isAdmin reports whether the caller may manage company-wide data: either a
// super-admin or a Member whose Role is admin.
func (s *Server) isAdmin(r *http.Request) bool {
if s.isSuperAdmin(r) {
return true
}
m := s.lookupMember(currentUser(r.Context()).Email)
return m != nil && m.Role == models.RoleAdmin
}
// requireAdmin writes 403 and returns false when the caller is not an admin.
func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) bool {
if s.isAdmin(r) {
return true
}
writeError(w, http.StatusForbidden, "관리자 권한이 필요합니다")
return false
}
// email returns the caller's (lowercased) email.
func (s *Server) email(r *http.Request) string {
return strings.ToLower(strings.TrimSpace(currentUser(r.Context()).Email))
}
// owns reports whether the given member email belongs to the caller.
func (s *Server) owns(r *http.Request, memberEmail string) bool {
return strings.EqualFold(strings.TrimSpace(memberEmail), s.email(r))
}
// lookupMember loads the Member row matched to an email (nil if none).
func (s *Server) lookupMember(email string) *models.Member {
email = strings.TrimSpace(email)
if email == "" {
return nil
}
var m models.Member
if err := s.db.Where("lower(email) = lower(?)", email).First(&m).Error; err != nil {
return nil
}
return &m
}
// audit writes an AuditLog row (best-effort).
func (s *Server) audit(r *http.Request, action, entity, entityID, detail string) {
s.db.Create(&models.AuditLog{
Actor: currentUser(r.Context()).Email,
Action: action,
Entity: entity,
EntityID: entityID,
Detail: detail,
})
}

165
internal/httpapi/router.go Normal file
View File

@ -0,0 +1,165 @@
package httpapi
import (
"encoding/json"
"net/http"
"spin/internal/config"
"spin/internal/storage"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"gorm.io/gorm"
)
// Server bundles dependencies shared by all handlers.
type Server struct {
db *gorm.DB
store *storage.Storage
cfg config.Config
}
// NewRouter wires up the chi router and mounts the /api routes for every module.
func NewRouter(db *gorm.DB, store *storage.Storage, cfg config.Config) http.Handler {
s := &Server{db: db, store: store, cfg: cfg}
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Recoverer)
r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"*"},
ExposedHeaders: []string{"*"},
AllowCredentials: false,
MaxAge: 300,
}))
r.Route("/api", func(r chi.Router) {
// Public health for k8s probes.
r.Get("/health", s.handleHealth)
r.Group(func(r chi.Router) {
r.Use(authMiddleware(cfg.DevAuth, cfg.AdminGroups))
// identity / navigation
r.Get("/me", s.handleMe)
r.Get("/me/nav", s.handleNav)
// ---- slice 1: members / org ----
r.Get("/members", s.handleListMembers)
r.Post("/members", s.handleCreateMember)
r.Get("/members/{id}", s.handleGetMember)
r.Patch("/members/{id}", s.handlePatchMember)
r.Delete("/members/{id}", s.handleDeleteMember)
r.Get("/departments", s.handleListDepartments)
r.Post("/departments", s.handleCreateDepartment)
r.Patch("/departments/{id}", s.handlePatchDepartment)
r.Delete("/departments/{id}", s.handleDeleteDepartment)
r.Get("/audit", s.handleListAudit)
// ---- slice 2: attendance / leave ----
r.Get("/attendance", s.handleListAttendance) // own (admin: ?email= or all)
r.Post("/attendance/punch", s.handlePunch) // clock in/out
r.Get("/attendance/timesheet", s.handleTimesheet) // monthly roll-up
r.Get("/leave", s.handleListLeave)
r.Post("/leave", s.handleCreateLeave)
r.Post("/leave/{id}/decide", s.handleDecideLeave) // admin approve/reject
r.Post("/leave/{id}/cancel", s.handleCancelLeave)
r.Get("/overtime", s.handleListOvertime)
r.Post("/overtime", s.handleCreateOvertime)
r.Post("/overtime/{id}/decide", s.handleDecideOvertime)
r.Get("/work-policy", s.handleGetWorkPolicy)
r.Put("/work-policy", s.handlePutWorkPolicy)
r.Get("/approvals", s.handleApprovalQueue) // admin queue
// ---- slice 3: projects ----
r.Get("/companies", s.handleListCompanies)
r.Post("/companies", s.handleCreateCompany)
r.Get("/products", s.handleListProducts)
r.Post("/products", s.handleCreateProduct)
r.Get("/versions", s.handleListVersions)
r.Post("/versions", s.handleCreateVersion)
r.Get("/projects", s.handleListProjects)
r.Post("/projects", s.handleCreateProject)
r.Get("/projects/{id}", s.handleGetProject)
r.Patch("/projects/{id}", s.handlePatchProject)
r.Delete("/projects/{id}", s.handleDeleteProject)
r.Get("/projects/{id}/members", s.handleListProjectMembers)
r.Post("/projects/{id}/members", s.handleUpsertProjectMember)
r.Delete("/project-members/{pmId}", s.handleDeleteProjectMember)
r.Get("/projects/{id}/contacts", s.handleListContacts)
r.Post("/projects/{id}/contacts", s.handleUpsertContact)
r.Delete("/contacts/{cId}", s.handleDeleteContact)
r.Get("/projects/{id}/tasks", s.handleListTasks)
r.Post("/projects/{id}/tasks", s.handleCreateTask)
r.Patch("/tasks/{tId}", s.handlePatchTask)
r.Delete("/tasks/{tId}", s.handleDeleteTask)
// admin-only commercial block
r.Get("/projects/{id}/contract", s.handleGetContract)
r.Put("/projects/{id}/contract", s.handlePutContract)
r.Get("/projects/{id}/files", s.handleListContractFiles)
r.Post("/projects/{id}/files", s.handleUploadContractFile)
r.Get("/files/{fId}/download", s.handleDownloadContractFile)
r.Delete("/files/{fId}", s.handleDeleteContractFile)
r.Get("/projects/{id}/payments", s.handleListPayments)
r.Post("/projects/{id}/payments", s.handleCreatePayment)
r.Patch("/payments/{payId}", s.handlePatchPayment)
r.Delete("/payments/{payId}", s.handleDeletePayment)
// ---- slice 4: incentive ----
r.Get("/incentive/config", s.handleGetIncentiveConfig)
r.Put("/incentive/config", s.handlePutIncentiveConfig)
r.Get("/incentive/me", s.handleMyIncentive) // member dashboard
r.Get("/incentive/stages", s.handleListStages) // ?projectId=
r.Post("/incentive/projects/{id}/recompute", s.handleRecomputeProject)
r.Post("/incentive/stages/{stId}/status", s.handleSetStageStatus)
r.Get("/incentive/user-incentives", s.handleListUserIncentives)
r.Patch("/incentive/user-incentives/{uiId}", s.handlePatchUserIncentive)
r.Get("/incentive/settlements", s.handleListSettlements)
r.Post("/incentive/settlements/run", s.handleRunSettlement)
r.Post("/incentive/settlements/{sId}/fix", s.handleFixSettlement)
r.Post("/incentive/simulate", s.handleSimulate)
// ---- slice 5: accounting ----
r.Get("/accounts", s.handleListAccounts)
r.Post("/accounts", s.handleCreateAccount)
r.Get("/transactions", s.handleListTransactions)
r.Post("/transactions", s.handleCreateTransaction)
r.Patch("/transactions/{txId}", s.handlePatchTransaction)
r.Delete("/transactions/{txId}", s.handleDeleteTransaction)
r.Get("/taxes", s.handleListTaxes)
r.Post("/taxes", s.handleCreateTax)
r.Patch("/taxes/{taxId}", s.handlePatchTax)
r.Get("/accounting/summary", s.handleAccountingSummary) // cashflow vs incentive gap
// ---- slice 6: dashboard ----
r.Get("/dashboard", s.handleDashboard)
})
})
return r
}
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// --- shared helpers ---------------------------------------------------------
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
func decodeJSON(r *http.Request, v interface{}) error {
return json.NewDecoder(r.Body).Decode(v)
}

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 // 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,
}
}

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 }

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 }

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
internal/models/member.go Normal file
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
internal/models/models.go Normal file
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
internal/models/project.go Normal file
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 (기여도, 0100) 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"` // 0100
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 }

190
internal/seed/seed.go Normal file
View File

@ -0,0 +1,190 @@
// Package seed loads sample data for local development (SEED=true). Production
// runs with SEED=false so the cluster DB stays empty until real data arrives.
package seed
import (
"log"
"time"
"spin/internal/incentive"
"spin/internal/models"
"gorm.io/gorm"
)
// Run is idempotent-ish: it no-ops if members already exist.
func Run(db *gorm.DB) error {
var n int64
db.Model(&models.Member{}).Count(&n)
if n > 0 {
log.Printf("seed: data present, skipping")
return nil
}
log.Printf("seed: loading sample data")
year := time.Now().Year()
jan := time.Date(year, 1, 2, 0, 0, 0, 0, time.UTC)
// departments
consulting := models.Department{Name: "컨설팅본부", LeadEmail: "admin@special-partners.com"}
mgmt := models.Department{Name: "경영지원", LeadEmail: "admin@special-partners.com"}
db.Create(&consulting)
db.Create(&mgmt)
// members (admin = theorose49 to match dev-auth mock)
members := []models.Member{
{Email: "theorose49@gmail.com", DisplayName: "관리자", Rank: models.RankPartner, Role: models.RoleAdmin, IsPartner: true, Position: "대표 컨설턴트", DepartmentID: &mgmt.ID, Status: "active", JoinDate: &jan, AnnualLeave: 15, Phone: "010-0000-0001"},
{Email: "member@special-partners.com", DisplayName: "김주임", Rank: models.RankJunior, Role: models.RoleMember, Position: "컨설턴트", DepartmentID: &consulting.ID, Status: "active", JoinDate: &jan, AnnualLeave: 15, Phone: "010-1111-2222"},
{Email: "lee@special-partners.com", DisplayName: "이선임", Rank: models.RankSenior, Role: models.RoleMember, Position: "선임 컨설턴트", DepartmentID: &consulting.ID, Status: "active", JoinDate: &jan, AnnualLeave: 15, Phone: "010-3333-4444"},
{Email: "park@special-partners.com", DisplayName: "박책임", Rank: models.RankLead, Role: models.RoleMember, Position: "책임 컨설턴트", DepartmentID: &consulting.ID, Status: "active", JoinDate: &jan, AnnualLeave: 15, Phone: "010-5555-6666"},
{Email: "choi@special-partners.com", DisplayName: "최파트너", Rank: models.RankPartner, Role: models.RoleMember, IsPartner: true, Position: "파트너", DepartmentID: &consulting.ID, Status: "active", JoinDate: &jan, AnnualLeave: 15, Phone: "010-7777-8888"},
}
for i := range members {
db.Create(&members[i])
}
// work policy
db.Create(&models.WorkPolicy{Name: "기본 근무제", WeeklyHours: 40, DailyStandardMin: 480,
CoreStart: "09:00", CoreEnd: "18:00", LunchMinutes: 60, AnnualLeaveBase: 15, Active: true})
// leave balances
for _, m := range members {
db.Create(&models.LeaveBalance{MemberEmail: m.Email, Year: year, Granted: 15, Used: 0})
}
// some attendance for the current month (member + lee)
seedAttendance(db, "member@special-partners.com")
seedAttendance(db, "lee@special-partners.com")
// a couple of pending requests
today := time.Now().Format("2006-01-02")
db.Create(&models.LeaveRequest{MemberEmail: "member@special-partners.com", Type: models.LeaveAnnual,
StartDate: today, EndDate: today, Days: 1, Reason: "개인 사유", Status: models.StatusPending})
db.Create(&models.OvertimeRequest{MemberEmail: "lee@special-partners.com", Date: today,
Minutes: 120, Reason: "마감 대응", Status: models.StatusPending})
// incentive config
cfg := models.IncentiveConfig{Year: year, PointRate: 1_000_000, DepositPct: 30, MiddlePct: 40,
FinalPct: 30, NonBECompanyPct: 60, NonBEPartnerPct: 40,
RankQuota: map[string]interface{}{
models.RankJunior: 30.0, models.RankSenior: 50.0, models.RankLead: 80.0, models.RankPartner: 120.0,
}}
db.Create(&cfg)
// companies / products / versions / projects
comp := models.Company{Name: "메디테크㈜", Code: "MTC", Note: "의료기기 제조사"}
db.Create(&comp)
prod := models.Product{CompanyID: comp.ID, Name: "CardioScan", Code: "CS"}
db.Create(&prod)
ver := models.Version{ProductID: prod.ID, Label: "v1.0"}
db.Create(&ver)
proj := models.Project{Name: "CardioScan FDA 510(k)", CompanyID: comp.ID, ProductID: prod.ID, VersionID: ver.ID,
CompanyName: comp.Name, ProductName: prod.Name, VersionName: ver.Label,
ConsultingType: "510(k) 인허가", Country: "미국(FDA)", Scope: models.ScopeBoth,
PMEmail: "park@special-partners.com", Cautions: "임상 데이터 보완 필요", Status: "active",
StartDate: time.Now().Format("2006-01-02"), DueDate: time.Now().AddDate(0, 4, 0).Format("2006-01-02")}
db.Create(&proj)
pms := []models.ProjectMember{
{ProjectID: proj.ID, MemberEmail: "member@special-partners.com", Portion: 30, Role: "작업자"},
{ProjectID: proj.ID, MemberEmail: "lee@special-partners.com", Portion: 30, Role: "작업자"},
{ProjectID: proj.ID, MemberEmail: "park@special-partners.com", Portion: 25, Role: "PM"},
{ProjectID: proj.ID, MemberEmail: "choi@special-partners.com", Portion: 15, Role: "파트너 검수"},
}
for i := range pms {
db.Create(&pms[i])
}
db.Create(&models.ClientContact{ProjectID: proj.ID, Name: "John Kim", Title: "RA Manager",
Phone: "+1-202-555-0100", Email: "john.kim@meditech.com"})
seedTasks(db, proj.ID)
// contract (admin-only)
contract := models.Contract{ProjectID: proj.ID, TotalAmount: 100_000_000, BEAmount: 60_000_000,
AdminCaution: "BE 이하로 협상 금지", Memo: "선금 30% 수령 완료"}
db.Create(&contract)
// payment splits
db.Create(&models.PaymentSplit{ProjectID: proj.ID, Label: "계약금", Amount: 30_000_000,
ExpectedDate: time.Now().Format("2006-01-02"), PaidDate: time.Now().Format("2006-01-02"), Paid: true, OrderIdx: 0})
db.Create(&models.PaymentSplit{ProjectID: proj.ID, Label: "중도금", Amount: 40_000_000,
ExpectedDate: time.Now().AddDate(0, 2, 0).Format("2006-01-02"), OrderIdx: 1})
db.Create(&models.PaymentSplit{ProjectID: proj.ID, Label: "잔금", Amount: 30_000_000,
ExpectedDate: time.Now().AddDate(0, 4, 0).Format("2006-01-02"), OrderIdx: 2})
// incentive stages + user allocations via the engine
eng := incentive.Config{PointRate: cfg.PointRate, DepositPct: cfg.DepositPct, MiddlePct: cfg.MiddlePct,
FinalPct: cfg.FinalPct, NonBECompanyPct: cfg.NonBECompanyPct, NonBEPartnerPct: cfg.NonBEPartnerPct,
RankQuota: map[string]float64{models.RankJunior: 30, models.RankSenior: 50, models.RankLead: 80, models.RankPartner: 120}}
stages := incentive.ComputeStages(contract.TotalAmount, contract.BEAmount, eng)
stageID := map[string]string{}
for _, st := range stages {
row := models.PaymentStage{ProjectID: proj.ID, Kind: st.Kind, Scope: st.Scope, Amount: st.Amount, Pct: st.Pct, Status: models.FixPlanned}
// mark the deposit stages as applied so dashboards show points
if st.Kind == incentive.KindDeposit {
row.Status = models.FixApplied
row.FixedDate = time.Now().Format("2006-01-02")
}
db.Create(&row)
stageID[st.Kind+"|"+st.Scope] = row.ID
}
mps := make([]incentive.MemberPortion, 0, len(pms))
for _, pm := range pms {
isP := pm.MemberEmail == "choi@special-partners.com"
mps = append(mps, incentive.MemberPortion{Email: pm.MemberEmail, Portion: pm.Portion, IsPartner: isP})
}
for _, a := range incentive.ComputeAllocs(stages, mps, eng) {
status := models.FixPlanned
if a.Kind == incentive.KindDeposit {
status = models.FixApplied
}
db.Create(&models.UserIncentive{ProjectID: proj.ID, MemberEmail: a.Email, StageID: stageID[a.Kind+"|"+a.Scope],
Kind: a.Kind, Scope: a.Scope, Year: year, Portion: a.Portion, Amount: a.Amount, Points: a.Points, FixStatus: status})
}
// accounting: a few transactions
pidPtr := proj.ID
db.Create(&models.Account{Code: "4000", Name: "컨설팅 매출", Type: "income"})
db.Create(&models.Account{Code: "5100", Name: "인건비", Type: "expense"})
db.Create(&models.Account{Code: "5200", Name: "운영비", Type: "expense"})
db.Create(&models.Transaction{Date: time.Now().Format("2006-01-02"), Kind: models.TxnIncome, Amount: 30_000_000, ProjectID: &pidPtr, Counterparty: "메디테크㈜", Memo: "계약금 입금", CreatedBy: "theorose49@gmail.com"})
db.Create(&models.Transaction{Date: time.Now().Format("2006-01-02"), Kind: models.TxnExpense, Amount: -8_000_000, Counterparty: "사무실", Memo: "임대료", CreatedBy: "theorose49@gmail.com"})
db.Create(&models.Transaction{Date: time.Now().Format("2006-01-02"), Kind: models.TxnTax, Amount: -3_000_000, Memo: "부가세 예정", CreatedBy: "theorose49@gmail.com"})
log.Printf("seed: done")
return nil
}
func seedAttendance(db *gorm.DB, email string) {
now := time.Now()
for d := 1; d <= now.Day() && d <= 20; d++ {
date := time.Date(now.Year(), now.Month(), d, 0, 0, 0, 0, time.UTC)
if wd := date.Weekday(); wd == time.Saturday || wd == time.Sunday {
continue
}
in := time.Date(now.Year(), now.Month(), d, 9, 0, 0, 0, time.UTC)
out := time.Date(now.Year(), now.Month(), d, 18, 30, 0, 0, time.UTC)
db.Create(&models.Attendance{MemberEmail: email, Date: date.Format("2006-01-02"),
ClockIn: &in, ClockOut: &out, WorkMinutes: 510, Source: "web"})
}
}
func seedTasks(db *gorm.DB, projectID string) {
base := time.Now()
tasks := []models.ProjectTask{
{Title: "사전 미팅 & 범위 확정", Lane: "done", Progress: 100, OrderIdx: 0,
Start: base.Format("2006-01-02"), End: base.AddDate(0, 0, 7).Format("2006-01-02")},
{Title: "기술문서(STED) 작성", Lane: "doing", Progress: 60, OrderIdx: 1,
Start: base.AddDate(0, 0, 7).Format("2006-01-02"), End: base.AddDate(0, 1, 0).Format("2006-01-02"), Assignee: "member@special-partners.com"},
{Title: "임상 데이터 정리", Lane: "todo", Progress: 0, OrderIdx: 2,
Start: base.AddDate(0, 1, 0).Format("2006-01-02"), End: base.AddDate(0, 2, 0).Format("2006-01-02"), Assignee: "lee@special-partners.com"},
{Title: "FDA 제출 & 대응", Lane: "todo", Progress: 0, OrderIdx: 3,
Start: base.AddDate(0, 2, 0).Format("2006-01-02"), End: base.AddDate(0, 4, 0).Format("2006-01-02"), Assignee: "park@special-partners.com"},
}
for i := range tasks {
tasks[i].ProjectID = projectID
db.Create(&tasks[i])
}
}

197
internal/storage/storage.go Normal file
View File

@ -0,0 +1,197 @@
package storage
import (
"context"
"io"
"time"
"spin/internal/config"
awscfg "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
s3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
)
// ObjectInfo describes a single S3 object as listed by the file-admin tool.
type ObjectInfo struct {
Key string `json:"key"`
Size int64 `json:"size"`
LastModified time.Time `json:"lastModified"`
}
// Storage wraps two S3 clients: one for in-cluster operations (S3_ENDPOINT) and
// one used only to mint presigned URLs that the browser can reach
// (S3_PUBLIC_ENDPOINT). Mirrors the sister eQMS service.
type Storage struct {
bucket string
prefix string // S3_PREFIX — app namespace within a shared bucket
client *s3.Client
presign *s3.PresignClient
}
// k applies the configured key prefix. DB stores logical keys; the physical
// object location is prefix + key (e.g. "spin-backend/contracts/...").
func (s *Storage) k(key string) string { return s.prefix + key }
// New constructs the storage clients. MinIO requires path-style addressing.
func New(ctx context.Context, cfg config.Config) (*Storage, error) {
creds := credentials.NewStaticCredentialsProvider(cfg.S3AccessKey, cfg.S3SecretKey, "")
base, err := awscfg.LoadDefaultConfig(ctx,
awscfg.WithRegion(cfg.S3Region),
awscfg.WithCredentialsProvider(creds),
)
if err != nil {
return nil, err
}
client := s3.NewFromConfig(base, func(o *s3.Options) {
o.BaseEndpoint = strPtr(cfg.S3Endpoint)
o.UsePathStyle = true
})
// Separate client whose endpoint is the browser-reachable host.
publicClient := s3.NewFromConfig(base, func(o *s3.Options) {
o.BaseEndpoint = strPtr(cfg.S3PublicEndpoint)
o.UsePathStyle = true
})
return &Storage{
bucket: cfg.S3Bucket,
prefix: cfg.S3Prefix,
client: client,
presign: s3.NewPresignClient(publicClient),
}, nil
}
func strPtr(s string) *string { return &s }
// EnsureBucket creates the bucket if it does not exist. Best-effort.
func (s *Storage) EnsureBucket(ctx context.Context) error {
_, err := s.client.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: &s.bucket})
if err == nil {
return nil
}
_, err = s.client.CreateBucket(ctx, &s3.CreateBucketInput{Bucket: &s.bucket})
return err
}
// Upload stores an object.
func (s *Storage) Upload(ctx context.Context, key, contentType string, body io.Reader, size int64) error {
full := s.k(key)
_, err := s.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: &s.bucket,
Key: &full,
Body: body,
ContentType: &contentType,
ContentLength: &size,
})
return err
}
// Get streams an object's bytes from S3. The caller must close the returned
// reader. The second return value is the content length (-1 if unknown).
func (s *Storage) Get(ctx context.Context, key string) (io.ReadCloser, int64, error) {
full := s.k(key)
out, err := s.client.GetObject(ctx, &s3.GetObjectInput{
Bucket: &s.bucket,
Key: &full,
})
if err != nil {
return nil, 0, err
}
var size int64 = -1
if out.ContentLength != nil {
size = *out.ContentLength
}
return out.Body, size, nil
}
// Delete removes a single logical object (prefix applied).
func (s *Storage) Delete(ctx context.Context, key string) error {
full := s.k(key)
_, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: &s.bucket,
Key: &full,
})
return err
}
// Prefix returns the configured S3 key prefix (app namespace). Used by the
// file-admin tool as the default listing prefix.
func (s *Storage) Prefix() string { return s.prefix }
// List enumerates RAW S3 keys (no app-prefix rewrite) under the given prefix,
// following pagination. The admin file tool operates on physical keys directly.
func (s *Storage) List(ctx context.Context, prefix string) ([]ObjectInfo, error) {
var out []ObjectInfo
var token *string
for {
page, err := s.client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{
Bucket: &s.bucket,
Prefix: &prefix,
ContinuationToken: token,
})
if err != nil {
return nil, err
}
for _, obj := range page.Contents {
info := ObjectInfo{}
if obj.Key != nil {
info.Key = *obj.Key
}
if obj.Size != nil {
info.Size = *obj.Size
}
if obj.LastModified != nil {
info.LastModified = *obj.LastModified
}
out = append(out, info)
}
if page.IsTruncated == nil || !*page.IsTruncated {
break
}
token = page.NextContinuationToken
}
return out, nil
}
// DeleteKeys bulk-deletes RAW S3 keys (no app-prefix rewrite) and returns the
// number of objects deleted. Batches in groups of 1000 (S3 DeleteObjects limit).
func (s *Storage) DeleteKeys(ctx context.Context, keys []string) (int, error) {
deleted := 0
for i := 0; i < len(keys); i += 1000 {
end := i + 1000
if end > len(keys) {
end = len(keys)
}
ids := make([]s3types.ObjectIdentifier, 0, end-i)
for _, k := range keys[i:end] {
kk := k
ids = append(ids, s3types.ObjectIdentifier{Key: &kk})
}
out, err := s.client.DeleteObjects(ctx, &s3.DeleteObjectsInput{
Bucket: &s.bucket,
Delete: &s3types.Delete{Objects: ids},
})
if err != nil {
return deleted, err
}
deleted += len(out.Deleted)
}
return deleted, nil
}
// PresignGet returns a browser-usable presigned GET URL valid for 10 minutes.
func (s *Storage) PresignGet(ctx context.Context, key string) (string, error) {
full := s.k(key)
out, err := s.presign.PresignGetObject(ctx, &s3.GetObjectInput{
Bucket: &s.bucket,
Key: &full,
}, s3.WithPresignExpires(10*time.Minute))
if err != nil {
return "", err
}
return out.URL, nil
}

View File

@ -0,0 +1,78 @@
// Package worktime computes monthly working-time roll-ups under the Korean Labor
// Standards Act model: a 주 소정근로시간 (default 40h) baseline, business-day
// expansion for the month, plus recognized leave/overtime.
package worktime
import "time"
// MonthBusinessDays returns the count of weekdays (MonFri) in the given month.
// Public holidays are out of scope here; admins can adjust via leave/공가.
func MonthBusinessDays(year int, month time.Month) int {
n := 0
d := time.Date(year, month, 1, 0, 0, 0, 0, time.UTC)
for d.Month() == month {
if wd := d.Weekday(); wd != time.Saturday && wd != time.Sunday {
n++
}
d = d.AddDate(0, 0, 1)
}
return n
}
// StandardMinutes is the expected worked minutes for the month: business days ×
// the policy's daily standard minutes.
func StandardMinutes(year int, month time.Month, dailyStandardMin int) int {
if dailyStandardMin <= 0 {
dailyStandardMin = 480
}
return MonthBusinessDays(year, month) * dailyStandardMin
}
// NetWorkMinutes computes worked minutes between clock-in and clock-out minus an
// unpaid break. Returns 0 if either bound is missing or the span is negative.
func NetWorkMinutes(in, out *time.Time, lunchMinutes int) int {
if in == nil || out == nil {
return 0
}
mins := int(out.Sub(*in).Minutes()) - lunchMinutes
if mins < 0 {
return 0
}
return mins
}
// Summary is the monthly timesheet roll-up returned to the UI.
type Summary struct {
Year int `json:"year"`
Month int `json:"month"`
BusinessDays int `json:"businessDays"`
StandardMinutes int `json:"standardMinutes"`
WorkedMinutes int `json:"workedMinutes"`
LeaveMinutes int `json:"leaveMinutes"` // recognized (연차/공가 등) as worked-equivalent
OvertimeMinutes int `json:"overtimeMinutes"` // approved overtime
RecognizedTotal int `json:"recognizedTotal"` // worked + leave
FulfillmentPct float64 `json:"fulfillmentPct"` // recognizedTotal / standard
DaysPresent int `json:"daysPresent"`
}
// Compute assembles the summary from raw minute inputs.
func Compute(year, month, dailyStandardMin, worked, leave, overtime, daysPresent int) Summary {
std := StandardMinutes(year, time.Month(month), dailyStandardMin)
recognized := worked + leave
pct := 0.0
if std > 0 {
pct = float64(recognized) / float64(std) * 100
}
return Summary{
Year: year,
Month: month,
BusinessDays: MonthBusinessDays(year, time.Month(month)),
StandardMinutes: std,
WorkedMinutes: worked,
LeaveMinutes: leave,
OvertimeMinutes: overtime,
RecognizedTotal: recognized,
FulfillmentPct: pct,
DaysPresent: daysPresent,
}
}