spin-backend/internal/httpapi/handlers_attendance.go
theorose49 df09d23662
All checks were successful
build-and-push / build (push) Successful in 35s
feat: 인턴 직급 + 초과근무 관리자 집계화 + SSO 로그아웃 URL + 디바이스/FCM
- 직급에 인턴 추가(기본 할당량 15), 직책(position)은 UI에서 제거(컬럼은 유지)
- 초과근무: 유저 신청 제거 → 관리자 근무관리에서 실제 출퇴근 기록 기반 자동 집계
- 로그아웃: infra 공통 LOGOUT_URL(/me로 전달) 사용 → oauth2-proxy 종료 + Keycloak end-session
- (이전 커밋 포함) Device 등록 + FCM HTTP v1 sender + notify 연동

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 10:55:53 +09:00

370 lines
10 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))
}
// Overtime is OBSERVED from real attendance (worked beyond the daily standard),
// not user-applied — only admins see it (via the 근무 관리 timesheet).
otMin := 0
for _, a := range att {
if extra := a.WorkMinutes - pol.DailyStandardMin; extra > 0 {
otMin += extra
}
}
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"`
}
var leaveLabels = map[string]string{
models.LeaveAnnual: "연차", models.LeaveHalfAM: "오전 반차", models.LeaveHalfPM: "오후 반차",
models.LeavePublic: "공가", models.LeaveSick: "병가", models.LeaveFamily: "경조사", models.LeaveUnpaid: "무급",
}
func leaveTypeLabel(t string) string {
if l, ok := leaveLabels[t]; ok {
return l
}
return t
}
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)
verdict := "반려되었습니다"
if d.Approve {
verdict = "승인되었습니다"
}
s.notify(lv.MemberEmail, "leave", "휴가 신청이 "+verdict,
"["+leaveTypeLabel(lv.Type)+"] "+lv.StartDate+" 신청이 "+verdict+".", "/attendance")
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)
verdict := "반려되었습니다"
if d.Approve {
verdict = "승인되었습니다"
}
s.notify(o.MemberEmail, "overtime", "초과근무 신청이 "+verdict, o.Date+" 초과근무 신청이 "+verdict+".", "/attendance")
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)
}