feat: spin 백엔드 전체 구현 (근무·프로젝트·인센티브·회계)
All checks were successful
build-and-push / build (push) Successful in 39s
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:
parent
ce4023ca59
commit
f83724b995
23
Dockerfile
23
Dockerfile
@ -1,8 +1,21 @@
|
||||
# Placeholder Dockerfile — nginx-unprivileged 기반 static serve, port 8080.
|
||||
# 실제 코드 추가 후 본인 stack(Node/Python/Go 등)으로 교체 권장.
|
||||
FROM nginxinc/nginx-unprivileged:1.27-alpine
|
||||
# --- build stage ------------------------------------------------------------
|
||||
FROM golang:1.22-alpine AS build
|
||||
WORKDIR /src
|
||||
|
||||
# 정적 파일 (html/css/js) 또는 SPA build output 을 root 에 복사.
|
||||
COPY --chown=nginx:nginx . /usr/share/nginx/html/
|
||||
# Cache dependencies first.
|
||||
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
|
||||
ENTRYPOINT ["/app/server"]
|
||||
|
||||
61
cmd/server/main.go
Normal file
61
cmd/server/main.go
Normal 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
46
go.mod
Normal 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
119
go.sum
Normal 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
121
internal/config/config.go
Normal 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
49
internal/db/db.go
Normal 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
209
internal/httpapi/auth.go
Normal 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
|
||||
}
|
||||
20
internal/httpapi/conv_test.go
Normal file
20
internal/httpapi/conv_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
37
internal/httpapi/dbload_test.go
Normal file
37
internal/httpapi/dbload_test.go
Normal 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["주임"])
|
||||
}
|
||||
}
|
||||
61
internal/httpapi/handlers.go
Normal file
61
internal/httpapi/handlers.go
Normal 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)) }
|
||||
225
internal/httpapi/handlers_accounting.go
Normal file
225
internal/httpapi/handlers_accounting.go
Normal 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
|
||||
345
internal/httpapi/handlers_attendance.go
Normal file
345
internal/httpapi/handlers_attendance.go
Normal 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)
|
||||
}
|
||||
59
internal/httpapi/handlers_dashboard.go
Normal file
59
internal/httpapi/handlers_dashboard.go
Normal 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)
|
||||
}
|
||||
450
internal/httpapi/handlers_incentive.go
Normal file
450
internal/httpapi/handlers_incentive.go
Normal 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,
|
||||
})
|
||||
}
|
||||
207
internal/httpapi/handlers_members.go
Normal file
207
internal/httpapi/handlers_members.go
Normal 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)
|
||||
}
|
||||
507
internal/httpapi/handlers_projects.go
Normal file
507
internal/httpapi/handlers_projects.go
Normal 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
78
internal/httpapi/perms.go
Normal 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
165
internal/httpapi/router.go
Normal 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)
|
||||
}
|
||||
161
internal/incentive/engine.go
Normal file
161
internal/incentive/engine.go
Normal 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 // 0–100
|
||||
IsPartner bool
|
||||
}
|
||||
|
||||
// Stage is a computed (kind, scope) money bucket for a project.
|
||||
type Stage struct {
|
||||
Kind string `json:"kind"`
|
||||
Scope string `json:"scope"`
|
||||
Amount float64 `json:"amount"`
|
||||
Pct float64 `json:"pct"`
|
||||
}
|
||||
|
||||
// UserAlloc is one member's computed allocation for a stage.
|
||||
type UserAlloc struct {
|
||||
Email string `json:"email"`
|
||||
Kind string `json:"kind"`
|
||||
Scope string `json:"scope"`
|
||||
Portion float64 `json:"portion"`
|
||||
Amount float64 `json:"amount"`
|
||||
Points float64 `json:"points"`
|
||||
}
|
||||
|
||||
const (
|
||||
KindDeposit = "deposit"
|
||||
KindMiddle = "middle"
|
||||
KindFinal = "final"
|
||||
ScopeBE = "be"
|
||||
ScopeNonBE = "non_be"
|
||||
)
|
||||
|
||||
// SplitScopes returns the BE and non-BE totals for a contract.
|
||||
func SplitScopes(total, be float64) (beAmount, nonBE float64) {
|
||||
if be > total {
|
||||
be = total
|
||||
}
|
||||
if be < 0 {
|
||||
be = 0
|
||||
}
|
||||
return be, math.Max(0, total-be)
|
||||
}
|
||||
|
||||
// ComputeStages produces the six (kind×scope) buckets for a project.
|
||||
func ComputeStages(total, be float64, cfg Config) []Stage {
|
||||
beAmt, nonBE := SplitScopes(total, be)
|
||||
mk := func(scope string, base float64) []Stage {
|
||||
return []Stage{
|
||||
{Kind: KindDeposit, Scope: scope, Pct: cfg.DepositPct, Amount: base * cfg.DepositPct / 100},
|
||||
{Kind: KindMiddle, Scope: scope, Pct: cfg.MiddlePct, Amount: base * cfg.MiddlePct / 100},
|
||||
{Kind: KindFinal, Scope: scope, Pct: cfg.FinalPct, Amount: base * cfg.FinalPct / 100},
|
||||
}
|
||||
}
|
||||
out := mk(ScopeBE, beAmt)
|
||||
out = append(out, mk(ScopeNonBE, nonBE)...)
|
||||
return out
|
||||
}
|
||||
|
||||
// ComputeAllocs distributes each stage's money to members and converts to points.
|
||||
// - BE: every member gets stageAmount × portion%, then ÷ pointRate.
|
||||
// - non-BE: only the partner pool (NonBEPartnerPct of the stage) is distributed,
|
||||
// among partners weighted by their portion (the company share is company
|
||||
// income, not a member incentive).
|
||||
func ComputeAllocs(stages []Stage, members []MemberPortion, cfg Config) []UserAlloc {
|
||||
rate := cfg.PointRate
|
||||
if rate <= 0 {
|
||||
rate = 1
|
||||
}
|
||||
partnerPortionSum := 0.0
|
||||
for _, m := range members {
|
||||
if m.IsPartner {
|
||||
partnerPortionSum += m.Portion
|
||||
}
|
||||
}
|
||||
var out []UserAlloc
|
||||
for _, st := range stages {
|
||||
switch st.Scope {
|
||||
case ScopeBE:
|
||||
for _, m := range members {
|
||||
amt := st.Amount * m.Portion / 100
|
||||
if amt == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, UserAlloc{Email: m.Email, Kind: st.Kind, Scope: st.Scope,
|
||||
Portion: m.Portion, Amount: amt, Points: amt / rate})
|
||||
}
|
||||
case ScopeNonBE:
|
||||
pool := st.Amount * cfg.NonBEPartnerPct / 100
|
||||
if pool == 0 || partnerPortionSum == 0 {
|
||||
continue
|
||||
}
|
||||
for _, m := range members {
|
||||
if !m.IsPartner {
|
||||
continue
|
||||
}
|
||||
share := pool * (m.Portion / partnerPortionSum)
|
||||
out = append(out, UserAlloc{Email: m.Email, Kind: st.Kind, Scope: st.Scope,
|
||||
Portion: m.Portion, Amount: share, Points: share / rate})
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Settlement is one member's quarterly calculation result.
|
||||
type Settlement struct {
|
||||
Email string `json:"email"`
|
||||
Rank string `json:"rank"`
|
||||
Quota float64 `json:"quota"`
|
||||
PointsCumul float64 `json:"pointsCumul"`
|
||||
ExcessPoints float64 `json:"excessPoints"`
|
||||
PaidPointsYTD float64 `json:"paidPointsYtd"`
|
||||
PayoutPoints float64 `json:"payoutPoints"`
|
||||
PayoutAmount float64 `json:"payoutAmount"`
|
||||
}
|
||||
|
||||
// ComputeSettlement derives the incremental payout for one member: points beyond
|
||||
// the rank quota, minus what was already paid earlier in the year.
|
||||
func ComputeSettlement(email, rank string, pointsCumul, paidYTD float64, cfg Config) Settlement {
|
||||
quota := cfg.RankQuota[rank]
|
||||
excess := math.Max(0, pointsCumul-quota)
|
||||
payout := math.Max(0, excess-paidYTD)
|
||||
return Settlement{
|
||||
Email: email,
|
||||
Rank: rank,
|
||||
Quota: quota,
|
||||
PointsCumul: pointsCumul,
|
||||
ExcessPoints: excess,
|
||||
PaidPointsYTD: paidYTD,
|
||||
PayoutPoints: payout,
|
||||
PayoutAmount: payout * cfg.PointRate,
|
||||
}
|
||||
}
|
||||
63
internal/models/accounting.go
Normal file
63
internal/models/accounting.go
Normal file
@ -0,0 +1,63 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Transaction kinds for the ledger. 인센티브 지급 links the accounting side to the
|
||||
// incentive engine so the "가상 포인트 vs 실제 현금" gap can be reconciled.
|
||||
const (
|
||||
TxnIncome = "income" // 수입 (계약 입금 등)
|
||||
TxnExpense = "expense" // 비용
|
||||
TxnTax = "tax" // 세금
|
||||
TxnPayroll = "payroll" // 급여
|
||||
TxnIncentive = "incentive" // 인센티브 지급
|
||||
)
|
||||
|
||||
// Account is a chart-of-accounts entry (계정과목).
|
||||
type Account struct {
|
||||
Base
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"` // income | expense | tax | asset | liability
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
func (m *Account) BeforeCreate(*gorm.DB) error { m.ensureID(); return nil }
|
||||
|
||||
// Transaction is a single ledger line. Optional ProjectID / MemberEmail tie it to
|
||||
// project profitability and per-member incentive payouts.
|
||||
type Transaction struct {
|
||||
Base
|
||||
Date string `gorm:"index" json:"date"` // YYYY-MM-DD
|
||||
Kind string `gorm:"index" json:"kind"`
|
||||
AccountID *string `json:"accountId"`
|
||||
Amount float64 `json:"amount"` // signed (income +, expense/tax/payroll −)
|
||||
ProjectID *string `gorm:"index" json:"projectId"`
|
||||
MemberEmail *string `json:"memberEmail"`
|
||||
Counterparty string `json:"counterparty"`
|
||||
Memo string `json:"memo"`
|
||||
CreatedBy string `json:"createdBy"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (m *Transaction) BeforeCreate(*gorm.DB) error { m.ensureID(); return nil }
|
||||
|
||||
// TaxRecord captures periodic tax obligations (부가세/원천세 등) for the dashboard.
|
||||
type TaxRecord struct {
|
||||
Base
|
||||
Period string `json:"period"` // YYYY-MM or YYYY-Q
|
||||
Type string `json:"type"`
|
||||
Base_ float64 `gorm:"column:base" json:"base"`
|
||||
Amount float64 `json:"amount"`
|
||||
DueDate string `json:"dueDate"`
|
||||
Paid bool `json:"paid"`
|
||||
Memo string `json:"memo"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (m *TaxRecord) BeforeCreate(*gorm.DB) error { m.ensureID(); return nil }
|
||||
109
internal/models/attendance.go
Normal file
109
internal/models/attendance.go
Normal file
@ -0,0 +1,109 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Request status shared by leave/overtime approval workflows.
|
||||
const (
|
||||
StatusPending = "pending"
|
||||
StatusApproved = "approved"
|
||||
StatusRejected = "rejected"
|
||||
StatusCanceled = "canceled"
|
||||
)
|
||||
|
||||
// Leave types (한국 근로기준법 기준). 연차 draws down the annual balance; the rest
|
||||
// are 인정/무급 categories that still count toward worked time where applicable.
|
||||
const (
|
||||
LeaveAnnual = "annual" // 연차
|
||||
LeaveHalfAM = "half_am" // 오전 반차
|
||||
LeaveHalfPM = "half_pm" // 오후 반차
|
||||
LeavePublic = "public" // 공가
|
||||
LeaveSick = "sick" // 병가
|
||||
LeaveFamily = "family" // 경조사
|
||||
LeaveUnpaid = "unpaid" // 무급
|
||||
)
|
||||
|
||||
// Attendance is one day's clock record for a member (date is YYYY-MM-DD in KST).
|
||||
type Attendance struct {
|
||||
Base
|
||||
MemberEmail string `gorm:"index" json:"memberEmail"`
|
||||
Date string `gorm:"index" json:"date"` // YYYY-MM-DD
|
||||
ClockIn *time.Time `json:"clockIn"`
|
||||
ClockOut *time.Time `json:"clockOut"`
|
||||
WorkMinutes int `json:"workMinutes"` // computed net worked minutes
|
||||
Source string `json:"source"` // web | admin | auto
|
||||
Note string `json:"note"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (m *Attendance) BeforeCreate(*gorm.DB) error { m.ensureID(); return nil }
|
||||
|
||||
// LeaveRequest is a member-submitted leave application. Members may only create;
|
||||
// admins approve/reject. Days is fractional to support 반차 (0.5).
|
||||
type LeaveRequest struct {
|
||||
Base
|
||||
MemberEmail string `gorm:"index" json:"memberEmail"`
|
||||
Type string `json:"type"`
|
||||
StartDate string `json:"startDate"` // YYYY-MM-DD
|
||||
EndDate string `json:"endDate"`
|
||||
Days float64 `json:"days"`
|
||||
Reason string `json:"reason"`
|
||||
Status string `gorm:"index" json:"status"`
|
||||
Approver string `json:"approver"`
|
||||
DecidedAt *time.Time `json:"decidedAt"`
|
||||
DecisionMemo string `json:"decisionMemo"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (m *LeaveRequest) BeforeCreate(*gorm.DB) error { m.ensureID(); return nil }
|
||||
|
||||
// OvertimeRequest is a member-submitted overtime application (관리자만 확인/승인).
|
||||
type OvertimeRequest struct {
|
||||
Base
|
||||
MemberEmail string `gorm:"index" json:"memberEmail"`
|
||||
Date string `json:"date"`
|
||||
Minutes int `json:"minutes"`
|
||||
Reason string `json:"reason"`
|
||||
Status string `gorm:"index" json:"status"`
|
||||
Approver string `json:"approver"`
|
||||
DecidedAt *time.Time `json:"decidedAt"`
|
||||
DecisionMemo string `json:"decisionMemo"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (m *OvertimeRequest) BeforeCreate(*gorm.DB) error { m.ensureID(); return nil }
|
||||
|
||||
// WorkPolicy is the company-wide work-hours policy used by the monthly timesheet
|
||||
// roll-up. Singleton-ish (one active row); admin editable.
|
||||
type WorkPolicy struct {
|
||||
Base
|
||||
Name string `json:"name"`
|
||||
WeeklyHours float64 `json:"weeklyHours"` // 주 소정근로시간 (기본 40)
|
||||
DailyStandardMin int `json:"dailyStandardMin"` // 일 소정근로분 (기본 480)
|
||||
CoreStart string `json:"coreStart"` // "09:00"
|
||||
CoreEnd string `json:"coreEnd"` // "18:00"
|
||||
LunchMinutes int `json:"lunchMinutes"` // 휴게(기본 60)
|
||||
AnnualLeaveBase float64 `json:"annualLeaveBase"` // 1년 미만/이상 기준 부여일
|
||||
Active bool `json:"active"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (m *WorkPolicy) BeforeCreate(*gorm.DB) error { m.ensureID(); return nil }
|
||||
|
||||
// LeaveBalance tracks per-member annual leave usage for a given year.
|
||||
type LeaveBalance struct {
|
||||
Base
|
||||
MemberEmail string `gorm:"index" json:"memberEmail"`
|
||||
Year int `gorm:"index" json:"year"`
|
||||
Granted float64 `json:"granted"`
|
||||
Used float64 `json:"used"`
|
||||
}
|
||||
|
||||
func (m *LeaveBalance) BeforeCreate(*gorm.DB) error { m.ensureID(); return nil }
|
||||
112
internal/models/incentive.go
Normal file
112
internal/models/incentive.go
Normal 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
70
internal/models/member.go
Normal 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
39
internal/models/models.go
Normal 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
164
internal/models/project.go
Normal file
@ -0,0 +1,164 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Company → Product → Version is the consulting hierarchy. A Project is created
|
||||
// per (company, product, version) engagement and is otherwise independent.
|
||||
type Company struct {
|
||||
Base
|
||||
Name string `json:"name"`
|
||||
Code string `json:"code"`
|
||||
Note string `json:"note"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
func (m *Company) BeforeCreate(*gorm.DB) error { m.ensureID(); return nil }
|
||||
|
||||
type Product struct {
|
||||
Base
|
||||
CompanyID string `gorm:"index" json:"companyId"`
|
||||
Name string `json:"name"`
|
||||
Code string `json:"code"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
func (m *Product) BeforeCreate(*gorm.DB) error { m.ensureID(); return nil }
|
||||
|
||||
type Version struct {
|
||||
Base
|
||||
ProductID string `gorm:"index" json:"productId"`
|
||||
Label string `json:"label"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
func (m *Version) BeforeCreate(*gorm.DB) error { m.ensureID(); return nil }
|
||||
|
||||
// Consulting metadata enums (stored as free strings for flexibility).
|
||||
const (
|
||||
ScopeText = "text" // 글
|
||||
ScopeGraphic = "graphic" // 그림
|
||||
ScopeBoth = "both" // 글+그림
|
||||
)
|
||||
|
||||
// Project is the core engagement record. The bracketed [admin-only] fields in the
|
||||
// spec live on Contract / PaymentSplit, not here, so Project is safe to expose to
|
||||
// any member who belongs to it.
|
||||
type Project struct {
|
||||
Base
|
||||
Name string `json:"name"`
|
||||
CompanyID string `gorm:"index" json:"companyId"`
|
||||
ProductID string `json:"productId"`
|
||||
VersionID string `json:"versionId"`
|
||||
CompanyName string `json:"companyName"` // denormalized 업체명 snapshot
|
||||
ProductName string `json:"productName"`
|
||||
VersionName string `json:"versionName"`
|
||||
ConsultingType string `json:"consultingType"` // 컨설팅 종류
|
||||
Country string `json:"country"` // 제출 국가
|
||||
Scope string `json:"scope"` // text | graphic | both
|
||||
PMEmail string `json:"pmEmail"` // 프로젝트 PM
|
||||
Cautions string `json:"cautions"` // 주의사항 (구성원 공개)
|
||||
Status string `json:"status"` // planned | active | hold | done | dropped
|
||||
StartDate string `json:"startDate"`
|
||||
DueDate string `json:"dueDate"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (m *Project) BeforeCreate(*gorm.DB) error { m.ensureID(); return nil }
|
||||
|
||||
// ProjectMember links a Member to a Project with a portion (기여도, 0–100) that
|
||||
// drives incentive distribution. Role is a free label (작업자/리뷰어/...).
|
||||
type ProjectMember struct {
|
||||
Base
|
||||
ProjectID string `gorm:"index" json:"projectId"`
|
||||
MemberEmail string `gorm:"index" json:"memberEmail"`
|
||||
Portion float64 `json:"portion"` // 기여도 percent
|
||||
Role string `json:"role"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
func (m *ProjectMember) BeforeCreate(*gorm.DB) error { m.ensureID(); return nil }
|
||||
|
||||
// ClientContact is a 업체 담당자 (직무/이름/연락처).
|
||||
type ClientContact struct {
|
||||
Base
|
||||
ProjectID string `gorm:"index" json:"projectId"`
|
||||
Name string `json:"name"`
|
||||
Title string `json:"title"` // 직무
|
||||
Phone string `json:"phone"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
func (m *ClientContact) BeforeCreate(*gorm.DB) error { m.ensureID(); return nil }
|
||||
|
||||
// ProjectTask feeds both the Gantt and Kanban views and matches the real
|
||||
// calendar via Start/End (YYYY-MM-DD). Lane is the Kanban column.
|
||||
type ProjectTask struct {
|
||||
Base
|
||||
ProjectID string `gorm:"index" json:"projectId"`
|
||||
Title string `json:"title"`
|
||||
Lane string `json:"lane"` // todo | doing | review | done
|
||||
Start string `json:"start"` // YYYY-MM-DD
|
||||
End string `json:"end"`
|
||||
Assignee string `json:"assignee"`
|
||||
OrderIdx int `json:"orderIdx"`
|
||||
Progress int `json:"progress"` // 0–100
|
||||
DependsOn datatypes.JSONSlice[string] `json:"dependsOn"`
|
||||
Color string `json:"color"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (m *ProjectTask) BeforeCreate(*gorm.DB) error { m.ensureID(); return nil }
|
||||
|
||||
// Contract holds the [admin-only] commercial terms of a project. BE is the
|
||||
// break-even floor (손해가 안 나는 최소 금액). Exposed ONLY to admins.
|
||||
type Contract struct {
|
||||
Base
|
||||
ProjectID string `gorm:"uniqueIndex" json:"projectId"`
|
||||
TotalAmount float64 `json:"totalAmount"` // 계약 금액
|
||||
BEAmount float64 `json:"beAmount"` // BE (break-even 최소 금액)
|
||||
AdminCaution string `json:"adminCaution"` // 관리자 주의사항
|
||||
Memo string `json:"memo"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (m *Contract) BeforeCreate(*gorm.DB) error { m.ensureID(); return nil }
|
||||
|
||||
// ContractFile is an [admin-only] attachment (계약서/기타 자료, 여러개 가능) in S3.
|
||||
type ContractFile struct {
|
||||
Base
|
||||
ProjectID string `gorm:"index" json:"projectId"`
|
||||
Kind string `json:"kind"` // contract | other
|
||||
Filename string `json:"filename"`
|
||||
S3Key string `json:"s3Key"`
|
||||
Size int64 `json:"size"`
|
||||
UploadedBy string `json:"uploadedBy"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
func (m *ContractFile) BeforeCreate(*gorm.DB) error { m.ensureID(); return nil }
|
||||
|
||||
// PaymentSplit is one [admin-only] custom installment of the contract amount.
|
||||
// Amounts arrive in arbitrary splits across dates, so every field is editable.
|
||||
type PaymentSplit struct {
|
||||
Base
|
||||
ProjectID string `gorm:"index" json:"projectId"`
|
||||
Label string `json:"label"`
|
||||
Amount float64 `json:"amount"`
|
||||
ExpectedDate string `json:"expectedDate"` // 예상 일정
|
||||
PaidDate string `json:"paidDate"` // 실제 입금일 (빈값=미입금)
|
||||
Paid bool `json:"paid"`
|
||||
Memo string `json:"memo"`
|
||||
OrderIdx int `json:"orderIdx"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (m *PaymentSplit) BeforeCreate(*gorm.DB) error { m.ensureID(); return nil }
|
||||
190
internal/seed/seed.go
Normal file
190
internal/seed/seed.go
Normal 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
197
internal/storage/storage.go
Normal 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
|
||||
}
|
||||
78
internal/worktime/worktime.go
Normal file
78
internal/worktime/worktime.go
Normal 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 (Mon–Fri) 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,
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user