spin-backend/internal/httpapi/handlers_accounting.go
theorose49 dcf8b415db
All checks were successful
build-and-push / build (push) Successful in 32s
feat: 회사/제품/버전 PATCH·DELETE, 세금 DELETE, 기준정보 nav
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 11:45:43 +09:00

234 lines
6.4 KiB
Go
Raw Permalink 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)
}
func (s *Server) handleDeleteTax(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
s.db.Delete(&models.TaxRecord{}, "id = ?", chi.URLParam(r, "taxId"))
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
// ---- 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