From f83724b995e7c688bcc219be2cc7844aebeffeb5 Mon Sep 17 00:00:00 2001 From: theorose49 Date: Sun, 28 Jun 2026 08:57:35 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20spin=20=EB=B0=B1=EC=97=94=EB=93=9C=20?= =?UTF-8?q?=EC=A0=84=EC=B2=B4=20=EA=B5=AC=ED=98=84=20(=EA=B7=BC=EB=AC=B4?= =?UTF-8?q?=C2=B7=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=C2=B7=EC=9D=B8?= =?UTF-8?q?=EC=84=BC=ED=8B=B0=EB=B8=8C=C2=B7=ED=9A=8C=EA=B3=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- Dockerfile | 23 +- cmd/server/main.go | 61 +++ go.mod | 46 +++ go.sum | 119 ++++++ internal/config/config.go | 121 ++++++ internal/db/db.go | 49 +++ internal/httpapi/auth.go | 209 ++++++++++ internal/httpapi/conv_test.go | 20 + internal/httpapi/dbload_test.go | 37 ++ internal/httpapi/handlers.go | 61 +++ internal/httpapi/handlers_accounting.go | 225 +++++++++++ internal/httpapi/handlers_attendance.go | 345 ++++++++++++++++ internal/httpapi/handlers_dashboard.go | 59 +++ internal/httpapi/handlers_incentive.go | 450 +++++++++++++++++++++ internal/httpapi/handlers_members.go | 207 ++++++++++ internal/httpapi/handlers_projects.go | 507 ++++++++++++++++++++++++ internal/httpapi/perms.go | 78 ++++ internal/httpapi/router.go | 165 ++++++++ internal/incentive/engine.go | 161 ++++++++ internal/models/accounting.go | 63 +++ internal/models/attendance.go | 109 +++++ internal/models/incentive.go | 112 ++++++ internal/models/member.go | 70 ++++ internal/models/models.go | 39 ++ internal/models/project.go | 164 ++++++++ internal/seed/seed.go | 190 +++++++++ internal/storage/storage.go | 197 +++++++++ internal/worktime/worktime.go | 78 ++++ 28 files changed, 3960 insertions(+), 5 deletions(-) create mode 100644 cmd/server/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/config/config.go create mode 100644 internal/db/db.go create mode 100644 internal/httpapi/auth.go create mode 100644 internal/httpapi/conv_test.go create mode 100644 internal/httpapi/dbload_test.go create mode 100644 internal/httpapi/handlers.go create mode 100644 internal/httpapi/handlers_accounting.go create mode 100644 internal/httpapi/handlers_attendance.go create mode 100644 internal/httpapi/handlers_dashboard.go create mode 100644 internal/httpapi/handlers_incentive.go create mode 100644 internal/httpapi/handlers_members.go create mode 100644 internal/httpapi/handlers_projects.go create mode 100644 internal/httpapi/perms.go create mode 100644 internal/httpapi/router.go create mode 100644 internal/incentive/engine.go create mode 100644 internal/models/accounting.go create mode 100644 internal/models/attendance.go create mode 100644 internal/models/incentive.go create mode 100644 internal/models/member.go create mode 100644 internal/models/models.go create mode 100644 internal/models/project.go create mode 100644 internal/seed/seed.go create mode 100644 internal/storage/storage.go create mode 100644 internal/worktime/worktime.go diff --git a/Dockerfile b/Dockerfile index fff15c3..41076e0 100644 --- a/Dockerfile +++ b/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"] diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..2245837 --- /dev/null +++ b/cmd/server/main.go @@ -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) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..109a244 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1148460 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..8cc0a15 --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..4c013c1 --- /dev/null +++ b/internal/db/db.go @@ -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 +} diff --git a/internal/httpapi/auth.go b/internal/httpapi/auth.go new file mode 100644 index 0000000..b6d3d3c --- /dev/null +++ b/internal/httpapi/auth.go @@ -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 +} diff --git a/internal/httpapi/conv_test.go b/internal/httpapi/conv_test.go new file mode 100644 index 0000000..e095fc1 --- /dev/null +++ b/internal/httpapi/conv_test.go @@ -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) + } +} diff --git a/internal/httpapi/dbload_test.go b/internal/httpapi/dbload_test.go new file mode 100644 index 0000000..e000506 --- /dev/null +++ b/internal/httpapi/dbload_test.go @@ -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["주임"]) + } +} diff --git a/internal/httpapi/handlers.go b/internal/httpapi/handlers.go new file mode 100644 index 0000000..90b5c3a --- /dev/null +++ b/internal/httpapi/handlers.go @@ -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)) } diff --git a/internal/httpapi/handlers_accounting.go b/internal/httpapi/handlers_accounting.go new file mode 100644 index 0000000..96e3927 --- /dev/null +++ b/internal/httpapi/handlers_accounting.go @@ -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 diff --git a/internal/httpapi/handlers_attendance.go b/internal/httpapi/handlers_attendance.go new file mode 100644 index 0000000..9448bf2 --- /dev/null +++ b/internal/httpapi/handlers_attendance.go @@ -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) +} diff --git a/internal/httpapi/handlers_dashboard.go b/internal/httpapi/handlers_dashboard.go new file mode 100644 index 0000000..e1e587b --- /dev/null +++ b/internal/httpapi/handlers_dashboard.go @@ -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) +} diff --git a/internal/httpapi/handlers_incentive.go b/internal/httpapi/handlers_incentive.go new file mode 100644 index 0000000..d0474a2 --- /dev/null +++ b/internal/httpapi/handlers_incentive.go @@ -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, + }) +} diff --git a/internal/httpapi/handlers_members.go b/internal/httpapi/handlers_members.go new file mode 100644 index 0000000..22ce9aa --- /dev/null +++ b/internal/httpapi/handlers_members.go @@ -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) +} diff --git a/internal/httpapi/handlers_projects.go b/internal/httpapi/handlers_projects.go new file mode 100644 index 0000000..f85a346 --- /dev/null +++ b/internal/httpapi/handlers_projects.go @@ -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 diff --git a/internal/httpapi/perms.go b/internal/httpapi/perms.go new file mode 100644 index 0000000..5954434 --- /dev/null +++ b/internal/httpapi/perms.go @@ -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, + }) +} diff --git a/internal/httpapi/router.go b/internal/httpapi/router.go new file mode 100644 index 0000000..e5fec85 --- /dev/null +++ b/internal/httpapi/router.go @@ -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) +} diff --git a/internal/incentive/engine.go b/internal/incentive/engine.go new file mode 100644 index 0000000..3f0ba04 --- /dev/null +++ b/internal/incentive/engine.go @@ -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, + } +} diff --git a/internal/models/accounting.go b/internal/models/accounting.go new file mode 100644 index 0000000..e15b070 --- /dev/null +++ b/internal/models/accounting.go @@ -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 } diff --git a/internal/models/attendance.go b/internal/models/attendance.go new file mode 100644 index 0000000..918efa9 --- /dev/null +++ b/internal/models/attendance.go @@ -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 } diff --git a/internal/models/incentive.go b/internal/models/incentive.go new file mode 100644 index 0000000..dd1e650 --- /dev/null +++ b/internal/models/incentive.go @@ -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 } diff --git a/internal/models/member.go b/internal/models/member.go new file mode 100644 index 0000000..c156281 --- /dev/null +++ b/internal/models/member.go @@ -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 } diff --git a/internal/models/models.go b/internal/models/models.go new file mode 100644 index 0000000..88fd6d6 --- /dev/null +++ b/internal/models/models.go @@ -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 diff --git a/internal/models/project.go b/internal/models/project.go new file mode 100644 index 0000000..75f7a97 --- /dev/null +++ b/internal/models/project.go @@ -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 } diff --git a/internal/seed/seed.go b/internal/seed/seed.go new file mode 100644 index 0000000..ef51e92 --- /dev/null +++ b/internal/seed/seed.go @@ -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]) + } +} diff --git a/internal/storage/storage.go b/internal/storage/storage.go new file mode 100644 index 0000000..ce83c1f --- /dev/null +++ b/internal/storage/storage.go @@ -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 +} diff --git a/internal/worktime/worktime.go b/internal/worktime/worktime.go new file mode 100644 index 0000000..e1394c9 --- /dev/null +++ b/internal/worktime/worktime.go @@ -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, + } +}