spin-backend/internal/httpapi/handlers_accounting.go
theorose49 f83724b995
All checks were successful
build-and-push / build (push) Successful in 39s
feat: spin 백엔드 전체 구현 (근무·프로젝트·인센티브·회계)
- 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>
2026-06-28 08:57:35 +09:00

226 lines
6.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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