All checks were successful
build-and-push / build (push) Successful in 39s
- config/db/storage/auth/router/perms: eQMS 규약 미러링, 권한 2-tier (관리자 전체 / 구성원 본인·신청만), oauth2-proxy 헤더 인증 + DEV_AUTH mock - 모델: 구성원/부서, 근무(출퇴근·휴가·공가·초과), 프로젝트(회사/제품/버전· 작업자portion·담당자·태스크·계약·첨부·분할입금), 인센티브(설정·단계· 유저배분·분기정산), 회계(거래·세금) - internal/worktime: 근로기준법 월 집계 엔진 - internal/incentive: BE/non-BE × 계약금/중도금/잔금 3단계 계산 + 시뮬레이션 - 시드 데이터, Go 멀티스테이지 Dockerfile - ADMIN_GROUPS 기본값 'admin' (전 내부 앱 공통 그룹) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
346 lines
9.6 KiB
Go
346 lines
9.6 KiB
Go
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)
|
|
}
|