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>
226 lines
6.2 KiB
Go
226 lines
6.2 KiB
Go
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
|