adding revenue handlers and shell
This commit is contained in:
BIN
Portifolio
BIN
Portifolio
Binary file not shown.
@@ -53,7 +53,28 @@ func InitDB(db *sql.DB) {
|
|||||||
value REAL NOT NULL,
|
value REAL NOT NULL,
|
||||||
FOREIGN KEY (report_id) REFERENCES revenue_reports(id),
|
FOREIGN KEY (report_id) REFERENCES revenue_reports(id),
|
||||||
FOREIGN KEY (currency_id) REFERENCES currencies(id)
|
FOREIGN KEY (currency_id) REFERENCES currencies(id)
|
||||||
);`
|
);
|
||||||
|
|
||||||
|
-- one row per category type per company
|
||||||
|
-- e.g. Apple has "product" and "location"
|
||||||
|
CREATE TABLE IF NOT EXISTS category_defs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
company_id INTEGER NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (company_id) REFERENCES companies(id),
|
||||||
|
UNIQUE(company_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- the actual labels belonging to each category
|
||||||
|
-- e.g. category_def "product" → "iPhone", "Mac", "Services"
|
||||||
|
CREATE TABLE IF NOT EXISTS category_labels (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
category_def_id INTEGER NOT NULL,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (category_def_id) REFERENCES category_defs(id),
|
||||||
|
UNIQUE(category_def_id, label)
|
||||||
|
);
|
||||||
|
`
|
||||||
|
|
||||||
if _, err := db.Exec(schema); err != nil {
|
if _, err := db.Exec(schema); err != nil {
|
||||||
log.Fatal("Failed to create tables:", err)
|
log.Fatal("Failed to create tables:", err)
|
||||||
|
|||||||
44
internal/handlers/currency.go
Normal file
44
internal/handlers/currency.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"Portifolio/internal/model"
|
||||||
|
"Portifolio/internal/service"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func AddCurrencyHandler(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var input model.CurrencyInput
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||||
|
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := service.InsertCurrency(db, input)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"status": "created", "id": id})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetCurrenciesHandler(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
currencies, err := service.GetAllCurrencies(db)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(currencies)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,47 +6,104 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"runtime"
|
||||||
|
"time"
|
||||||
|
|
||||||
_ "github.com/mattn/go-sqlite3"
|
_ "github.com/mattn/go-sqlite3"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var startTime = time.Now()
|
||||||
|
|
||||||
func HealthHandler(db *sql.DB) http.HandlerFunc {
|
func HealthHandler(db *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
dbStatus := "ok"
|
health := checkHealth(db)
|
||||||
if err := db.Ping(); err != nil {
|
|
||||||
dbStatus = "error: " + err.Error()
|
if health["status"] != "ok" {
|
||||||
w.WriteHeader(http.StatusServiceUnavailable)
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
}
|
}
|
||||||
|
|
||||||
json.NewEncoder(w).Encode(map[string]string{
|
json.NewEncoder(w).Encode(health)
|
||||||
"status": "ok",
|
|
||||||
"database": dbStatus,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func checkHealth(db *sql.DB) map[string]any {
|
||||||
|
// database
|
||||||
|
dbStatus := "ok"
|
||||||
|
dbLatency := ""
|
||||||
|
t := time.Now()
|
||||||
|
if err := db.Ping(); err != nil {
|
||||||
|
dbStatus = "error: " + err.Error()
|
||||||
|
} else {
|
||||||
|
dbLatency = time.Since(t).String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// db pool stats
|
||||||
|
stats := db.Stats()
|
||||||
|
|
||||||
|
// overall status
|
||||||
|
status := "ok"
|
||||||
|
if dbStatus != "ok" {
|
||||||
|
status = "degraded"
|
||||||
|
}
|
||||||
|
|
||||||
|
var mem runtime.MemStats
|
||||||
|
runtime.ReadMemStats(&mem)
|
||||||
|
|
||||||
|
return map[string]any{
|
||||||
|
"status": status,
|
||||||
|
"uptime": time.Since(startTime).String(),
|
||||||
|
"database": map[string]any{
|
||||||
|
"status": dbStatus,
|
||||||
|
"latency": dbLatency,
|
||||||
|
"open_connections": stats.OpenConnections,
|
||||||
|
"in_use": stats.InUse,
|
||||||
|
"idle": stats.Idle,
|
||||||
|
},
|
||||||
|
"memory": map[string]any{
|
||||||
|
"alloc_mb": bToMb(mem.Alloc),
|
||||||
|
"sys_mb": bToMb(mem.Sys),
|
||||||
|
"num_gc": mem.NumGC,
|
||||||
|
},
|
||||||
|
"go_version": runtime.Version(),
|
||||||
|
"goroutines": runtime.NumGoroutine(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func bToMb(b uint64) float64 {
|
||||||
|
return float64(b) / 1024 / 1024
|
||||||
|
}
|
||||||
|
|
||||||
func AddCompanyHandler(db *sql.DB) http.HandlerFunc {
|
func AddCompanyHandler(db *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var input model.CompanyInput
|
var input model.CompanyInput
|
||||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := service.AddCompany(input, db); err != nil {
|
id, err := service.InsertCompany(db, input)
|
||||||
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusCreated)
|
w.WriteHeader(http.StatusCreated)
|
||||||
json.NewEncoder(w).Encode(map[string]string{"status": "created"})
|
json.NewEncoder(w).Encode(map[string]any{"status": "created", "id": id})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetCompaniesHandler(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
companies, err := service.GetAllCompanies(db)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(companies)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
130
internal/handlers/revenue.go
Normal file
130
internal/handlers/revenue.go
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"Portifolio/internal/model"
|
||||||
|
"Portifolio/internal/service"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func AddRevenueReportHandler(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var input struct {
|
||||||
|
CompanyID int `json:"company_id"`
|
||||||
|
PeriodType string `json:"period_type"`
|
||||||
|
Year int `json:"year"`
|
||||||
|
Index int `json:"index"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||||
|
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var period model.Period
|
||||||
|
switch input.PeriodType {
|
||||||
|
case "Q":
|
||||||
|
period = model.QuarterPeriod(input.Year, input.Index)
|
||||||
|
case "H":
|
||||||
|
period = model.HalfYearPeriod(input.Year, input.Index)
|
||||||
|
case "Y":
|
||||||
|
period = model.FullYearPeriod(input.Year)
|
||||||
|
default:
|
||||||
|
http.Error(w, "invalid period_type (Q/H/Y)", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := service.InsertPeriod(db, period)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"status": "created",
|
||||||
|
"period_id": id,
|
||||||
|
"period": period.String(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func AddRevenueEntryHandler(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var input struct {
|
||||||
|
CompanyID int `json:"company_id"`
|
||||||
|
CurrencyID int `json:"currency_id"`
|
||||||
|
PeriodType string `json:"period_type"`
|
||||||
|
Year int `json:"year"`
|
||||||
|
Index int `json:"index"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Value float64 `json:"value"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||||
|
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var period model.Period
|
||||||
|
switch input.PeriodType {
|
||||||
|
case "Q":
|
||||||
|
period = model.QuarterPeriod(input.Year, input.Index)
|
||||||
|
case "H":
|
||||||
|
period = model.HalfYearPeriod(input.Year, input.Index)
|
||||||
|
case "Y":
|
||||||
|
period = model.FullYearPeriod(input.Year)
|
||||||
|
default:
|
||||||
|
http.Error(w, "invalid period_type", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := service.InsertRevenue(db, input.CompanyID, input.CurrencyID, input.Category, input.Label, input.Value, period); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"status": "created"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetRevenueReportHandler(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
companyID, _ := strconv.Atoi(r.URL.Query().Get("company_id"))
|
||||||
|
periodType := r.URL.Query().Get("period_type")
|
||||||
|
year, _ := strconv.Atoi(r.URL.Query().Get("year"))
|
||||||
|
idx, _ := strconv.Atoi(r.URL.Query().Get("index"))
|
||||||
|
|
||||||
|
entries, err := service.GetRevenue(db, companyID, model.PeriodType(periodType), year, idx)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetRevenueSumHandler(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
companyID, _ := strconv.Atoi(r.URL.Query().Get("company_id"))
|
||||||
|
periodType := r.URL.Query().Get("period_type")
|
||||||
|
year, _ := strconv.Atoi(r.URL.Query().Get("year"))
|
||||||
|
|
||||||
|
rs, err := service.SumRevenue(db, companyID, model.PeriodType(periodType), year)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(rs)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,14 +21,23 @@ type Period struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RevenueCategory is what the revenue is broken down by
|
// RevenueCategory is what the revenue is broken down by
|
||||||
type RevenueCategory string
|
// RevenueCategory is now just a string — categories are dynamic per company
|
||||||
|
type RevenueCategory = string
|
||||||
|
|
||||||
|
// Built-in reserved categories
|
||||||
const (
|
const (
|
||||||
CategoryProduct RevenueCategory = "product"
|
CategoryTotal RevenueCategory = "total" // always required, never custom
|
||||||
CategoryLocation RevenueCategory = "location"
|
|
||||||
CategoryTotal RevenueCategory = "total"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// CategoryDef defines a custom category for a specific company
|
||||||
|
// e.g. Company: Apple, Name: "product", Labels: ["iPhone", "Mac", "Services"]
|
||||||
|
type CategoryDef struct {
|
||||||
|
ID int
|
||||||
|
Company *Company
|
||||||
|
Name string // e.g. "product", "location", "segment"
|
||||||
|
Labels []string // populated from category_labels table
|
||||||
|
}
|
||||||
|
|
||||||
// Revenue is a single line in a financial report
|
// Revenue is a single line in a financial report
|
||||||
type Revenue struct {
|
type Revenue struct {
|
||||||
ID int
|
ID int
|
||||||
@@ -59,11 +68,57 @@ func (r *RevenueReport) Total(category RevenueCategory) float64 {
|
|||||||
return sum
|
return sum
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate checks that product + location sums match the total entry
|
// SumByCategory returns a map of category → sum for all entries
|
||||||
func (r *RevenueReport) Validate() (bool, float64, float64) {
|
func (r *RevenueReport) SumByCategory() map[RevenueCategory]float64 {
|
||||||
total := r.Total(CategoryTotal)
|
sums := make(map[RevenueCategory]float64)
|
||||||
products := r.Total(CategoryProduct)
|
for _, e := range r.Entries {
|
||||||
locations := r.Total(CategoryLocation)
|
sums[e.Category] += e.Value
|
||||||
// both breakdowns should equal the total
|
}
|
||||||
return products == total && locations == total, products, locations
|
return sums
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate checks every category independently sums to total
|
||||||
|
func (r *RevenueReport) Validate() (bool, map[RevenueCategory]float64) {
|
||||||
|
total := r.Total(CategoryTotal)
|
||||||
|
sums := r.SumByCategory()
|
||||||
|
delete(sums, CategoryTotal) // don't compare total to itself
|
||||||
|
|
||||||
|
allMatch := true
|
||||||
|
for cat, sum := range sums {
|
||||||
|
if sum != total {
|
||||||
|
allMatch = false
|
||||||
|
_ = cat // caller can inspect the map to see which failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return allMatch, sums
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevSum aggregates revenue across multiple reports or periods
|
||||||
|
// Use this when you want e.g. FY = Q1+Q2+Q3+Q4
|
||||||
|
type RevSum struct {
|
||||||
|
Company *Company
|
||||||
|
Categories map[RevenueCategory]float64 // category → total across all periods
|
||||||
|
Labels map[string]float64 // label → total (e.g. "iPhone" across quarters)
|
||||||
|
Total float64
|
||||||
|
Periods []Period // which periods were summed
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRevSum(company *Company, reports []RevenueReport) *RevSum {
|
||||||
|
rs := &RevSum{
|
||||||
|
Company: company,
|
||||||
|
Categories: make(map[RevenueCategory]float64),
|
||||||
|
Labels: make(map[string]float64),
|
||||||
|
}
|
||||||
|
for _, report := range reports {
|
||||||
|
rs.Periods = append(rs.Periods, report.Period)
|
||||||
|
for _, e := range report.Entries {
|
||||||
|
if e.Category == CategoryTotal {
|
||||||
|
rs.Total += e.Value
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rs.Categories[e.Category] += e.Value
|
||||||
|
rs.Labels[e.Label] += e.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rs
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package service
|
|||||||
import (
|
import (
|
||||||
"Portifolio/internal/model"
|
"Portifolio/internal/model"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
)
|
)
|
||||||
|
|
||||||
func InsertCompany(db *sql.DB, input model.CompanyInput) (int, error) {
|
func InsertCompany(db *sql.DB, input model.CompanyInput) (int, error) {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package service
|
|||||||
import (
|
import (
|
||||||
"Portifolio/internal/model"
|
"Portifolio/internal/model"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
)
|
)
|
||||||
|
|
||||||
func InsertCurrency(db *sql.DB, input model.CurrencyInput) (int, error) {
|
func InsertCurrency(db *sql.DB, input model.CurrencyInput) (int, error) {
|
||||||
|
|||||||
135
internal/service/revenue.go
Normal file
135
internal/service/revenue.go
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"Portifolio/internal/model"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func InsertRevenue(db *sql.DB, companyID, currencyID int, category, label string, value float64, period model.Period) error {
|
||||||
|
periodID, err := InsertPeriod(db, period)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
reportID, err := getOrCreateReport(db, companyID, periodID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = db.Exec(
|
||||||
|
`INSERT INTO revenue_entries (report_id, currency_id, category, label, value) VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
reportID, currencyID, category, label, value,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func InsertPeriod(db *sql.DB, p model.Period) (int, error) {
|
||||||
|
res, err := db.Exec(
|
||||||
|
`INSERT INTO periods (type, year, idx, start_date, end_date) VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(type, year, idx) DO UPDATE SET start_date=excluded.start_date`,
|
||||||
|
string(p.Type), p.Year, p.Index, p.Start.Format("2006-01-02"), p.End.Format("2006-01-02"),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
id, err := res.LastInsertId()
|
||||||
|
return int(id), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func getOrCreateReport(db *sql.DB, companyID, periodID int) (int, error) {
|
||||||
|
var id int
|
||||||
|
err := db.QueryRow(
|
||||||
|
`SELECT id FROM revenue_reports WHERE company_id = ? AND period_id = ?`,
|
||||||
|
companyID, periodID,
|
||||||
|
).Scan(&id)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
res, err := db.Exec(
|
||||||
|
`INSERT INTO revenue_reports (company_id, period_id) VALUES (?, ?)`,
|
||||||
|
companyID, periodID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
lid, _ := res.LastInsertId()
|
||||||
|
return int(lid), nil
|
||||||
|
}
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetRevenue(db *sql.DB, companyID int, periodType model.PeriodType, year, idx int) ([]model.Revenue, error) {
|
||||||
|
rows, err := db.Query(`
|
||||||
|
SELECT e.id, e.category, e.label, e.value,
|
||||||
|
p.type, p.year, p.idx, p.start_date, p.end_date
|
||||||
|
FROM revenue_entries e
|
||||||
|
JOIN revenue_reports r ON e.report_id = r.id
|
||||||
|
JOIN periods p ON r.period_id = p.id
|
||||||
|
WHERE r.company_id = ? AND p.type = ? AND p.year = ? AND p.idx = ?`,
|
||||||
|
companyID, string(periodType), year, idx,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var entries []model.Revenue
|
||||||
|
for rows.Next() {
|
||||||
|
var e model.Revenue
|
||||||
|
var p model.Period
|
||||||
|
var start, end string
|
||||||
|
if err := rows.Scan(&e.ID, &e.Category, &e.Label, &e.Value,
|
||||||
|
&p.Type, &p.Year, &p.Index, &start, &end); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p.Start, _ = time.Parse("2006-01-02", start)
|
||||||
|
p.End, _ = time.Parse("2006-01-02", end)
|
||||||
|
e.Period = p
|
||||||
|
entries = append(entries, e)
|
||||||
|
}
|
||||||
|
return entries, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func SumRevenue(db *sql.DB, companyID int, periodType model.PeriodType, year int) (*model.RevSum, error) {
|
||||||
|
rows, err := db.Query(`
|
||||||
|
SELECT e.category, e.label, e.value, p.type, p.year, p.idx
|
||||||
|
FROM revenue_entries e
|
||||||
|
JOIN revenue_reports r ON e.report_id = r.id
|
||||||
|
JOIN periods p ON r.period_id = p.id
|
||||||
|
WHERE r.company_id = ? AND p.type = ? AND p.year = ?`,
|
||||||
|
companyID, string(periodType), year,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
rs := &model.RevSum{
|
||||||
|
Categories: make(map[model.RevenueCategory]float64),
|
||||||
|
Labels: make(map[string]float64),
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for rows.Next() {
|
||||||
|
var category, label string
|
||||||
|
var value float64
|
||||||
|
var pType model.PeriodType
|
||||||
|
var pYear, pIdx int
|
||||||
|
if err := rows.Scan(&category, &label, &value, &pType, &pYear, &pIdx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
key := fmt.Sprintf("%s-%d-%d", pType, pYear, pIdx)
|
||||||
|
if !seen[key] {
|
||||||
|
rs.Periods = append(rs.Periods, model.Period{Type: pType, Year: pYear, Index: pIdx})
|
||||||
|
seen[key] = true
|
||||||
|
}
|
||||||
|
if category == model.CategoryTotal {
|
||||||
|
rs.Total += value
|
||||||
|
} else {
|
||||||
|
rs.Categories[category] += value
|
||||||
|
rs.Labels[label] += value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rs, rows.Err()
|
||||||
|
}
|
||||||
@@ -52,3 +52,27 @@ func AddCompany(scanner *bufio.Scanner, db *sql.DB) {
|
|||||||
}
|
}
|
||||||
fmt.Printf(" ✓ Company '%s' added.\n", input.Name)
|
fmt.Printf(" ✓ Company '%s' added.\n", input.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ListCompanies(db *sql.DB) {
|
||||||
|
companies, err := service.GetAllCompanies(db)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(" ✗ Error:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(companies) == 0 {
|
||||||
|
fmt.Println(" No companies found.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\n %-5s %-20s %-10s %-15s %s\n", "ID", "NAME", "CURRENCY", "PRICE", "SHARES")
|
||||||
|
fmt.Println(" " + strings.Repeat("-", 60))
|
||||||
|
for _, c := range companies {
|
||||||
|
currency := "N/A"
|
||||||
|
if c.Currency != nil {
|
||||||
|
currency = c.Currency.Code
|
||||||
|
}
|
||||||
|
fmt.Printf(" %-5d %-20s %-10s %-15.2f %d\n",
|
||||||
|
c.ID, c.Name, currency, c.Price, c.SharesOutstanding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
153
internal/shell/revenue.go
Normal file
153
internal/shell/revenue.go
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
package shell
|
||||||
|
|
||||||
|
import (
|
||||||
|
"Portifolio/internal/model"
|
||||||
|
"Portifolio/internal/service"
|
||||||
|
"bufio"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func AddRevenue(scanner *bufio.Scanner, db *sql.DB) {
|
||||||
|
fmt.Print(" Company ID: ")
|
||||||
|
scanner.Scan()
|
||||||
|
companyID, err := strconv.Atoi(strings.TrimSpace(scanner.Text()))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(" ✗ Invalid company ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Print(" Currency ID: ")
|
||||||
|
scanner.Scan()
|
||||||
|
currencyID, err := strconv.Atoi(strings.TrimSpace(scanner.Text()))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(" ✗ Invalid currency ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Print(" Period type (Q/H/Y): ")
|
||||||
|
scanner.Scan()
|
||||||
|
periodType := strings.ToUpper(strings.TrimSpace(scanner.Text()))
|
||||||
|
|
||||||
|
fmt.Print(" Year: ")
|
||||||
|
scanner.Scan()
|
||||||
|
year, err := strconv.Atoi(strings.TrimSpace(scanner.Text()))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(" ✗ Invalid year")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Print(" Index (Q: 1-4 | H: 1-2 | Y: 1): ")
|
||||||
|
scanner.Scan()
|
||||||
|
idx, err := strconv.Atoi(strings.TrimSpace(scanner.Text()))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(" ✗ Invalid index")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Print(" Category (product/location/total): ")
|
||||||
|
scanner.Scan()
|
||||||
|
category := strings.TrimSpace(scanner.Text())
|
||||||
|
|
||||||
|
fmt.Print(" Label (e.g. iPhone, Americas): ")
|
||||||
|
scanner.Scan()
|
||||||
|
label := strings.TrimSpace(scanner.Text())
|
||||||
|
|
||||||
|
fmt.Print(" Value: ")
|
||||||
|
scanner.Scan()
|
||||||
|
value, err := strconv.ParseFloat(strings.TrimSpace(scanner.Text()), 64)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(" ✗ Invalid value")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var period model.Period
|
||||||
|
switch periodType {
|
||||||
|
case "Q":
|
||||||
|
period = model.QuarterPeriod(year, idx)
|
||||||
|
case "H":
|
||||||
|
period = model.HalfYearPeriod(year, idx)
|
||||||
|
case "Y":
|
||||||
|
period = model.FullYearPeriod(year)
|
||||||
|
default:
|
||||||
|
fmt.Println(" ✗ Invalid period type")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := service.InsertRevenue(db, companyID, currencyID, category, label, value, period); err != nil {
|
||||||
|
fmt.Println(" ✗ Error:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf(" ✓ Revenue entry added: %s / %s = %.2f (%s)\n", category, label, value, period.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListRevenue(scanner *bufio.Scanner, db *sql.DB) {
|
||||||
|
fmt.Print(" Company ID: ")
|
||||||
|
scanner.Scan()
|
||||||
|
companyID, err := strconv.Atoi(strings.TrimSpace(scanner.Text()))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(" ✗ Invalid company ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Print(" Period type (Q/H/Y): ")
|
||||||
|
scanner.Scan()
|
||||||
|
periodType := strings.ToUpper(strings.TrimSpace(scanner.Text()))
|
||||||
|
|
||||||
|
fmt.Print(" Year: ")
|
||||||
|
scanner.Scan()
|
||||||
|
year, _ := strconv.Atoi(strings.TrimSpace(scanner.Text()))
|
||||||
|
|
||||||
|
fmt.Print(" Index: ")
|
||||||
|
scanner.Scan()
|
||||||
|
idx, _ := strconv.Atoi(strings.TrimSpace(scanner.Text()))
|
||||||
|
|
||||||
|
entries, err := service.GetRevenue(db, companyID, model.PeriodType(periodType), year, idx)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(" ✗ Error:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\n %-12s %-20s %12s\n", "CATEGORY", "LABEL", "VALUE")
|
||||||
|
fmt.Println(" " + strings.Repeat("-", 46))
|
||||||
|
for _, e := range entries {
|
||||||
|
fmt.Printf(" %-12s %-20s %12.2f\n", e.Category, e.Label, e.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func SumRevenue(scanner *bufio.Scanner, db *sql.DB) {
|
||||||
|
fmt.Print(" Company ID: ")
|
||||||
|
scanner.Scan()
|
||||||
|
companyID, err := strconv.Atoi(strings.TrimSpace(scanner.Text()))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(" ✗ Invalid company ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Print(" Period type to sum (Q/H/Y): ")
|
||||||
|
scanner.Scan()
|
||||||
|
periodType := strings.ToUpper(strings.TrimSpace(scanner.Text()))
|
||||||
|
|
||||||
|
fmt.Print(" Year: ")
|
||||||
|
scanner.Scan()
|
||||||
|
year, _ := strconv.Atoi(strings.TrimSpace(scanner.Text()))
|
||||||
|
|
||||||
|
rs, err := service.SumRevenue(db, companyID, model.PeriodType(periodType), year)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(" ✗ Error:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\n Revenue Sum — FY%d\n", year)
|
||||||
|
fmt.Printf(" Total: %.2f\n\n", rs.Total)
|
||||||
|
fmt.Println(" By Category:")
|
||||||
|
for cat, sum := range rs.Categories {
|
||||||
|
fmt.Printf(" %-15s %.2f\n", cat, sum)
|
||||||
|
}
|
||||||
|
fmt.Println("\n By Label:")
|
||||||
|
for label, sum := range rs.Labels {
|
||||||
|
fmt.Printf(" %-20s %.2f\n", label, sum)
|
||||||
|
}
|
||||||
|
}
|
||||||
50
main.go
50
main.go
@@ -33,11 +33,19 @@ func main() {
|
|||||||
fmt.Println("Connected to SQLite database")
|
fmt.Println("Connected to SQLite database")
|
||||||
|
|
||||||
http.HandleFunc("/health", handlers.HealthHandler(db))
|
http.HandleFunc("/health", handlers.HealthHandler(db))
|
||||||
|
|
||||||
|
// Company
|
||||||
http.HandleFunc("POST /add/company", handlers.AddCompanyHandler(db))
|
http.HandleFunc("POST /add/company", handlers.AddCompanyHandler(db))
|
||||||
/*
|
http.HandleFunc("GET /companies", handlers.GetCompaniesHandler(db))
|
||||||
http.HandleFunc("GET /companies", handlers.AddCompanyHandler(db))
|
|
||||||
http.HandleFunc("GET /currencies", handlers.AddCompanyHandler(db))
|
// Currency
|
||||||
*/
|
http.HandleFunc("GET /currencies", handlers.GetCurrenciesHandler(db))
|
||||||
|
|
||||||
|
// Revenue
|
||||||
|
http.HandleFunc("POST /add/revenue/report", handlers.AddRevenueReportHandler(db))
|
||||||
|
http.HandleFunc("POST /add/revenue/entry", handlers.AddRevenueEntryHandler(db))
|
||||||
|
http.HandleFunc("GET /revenue/report", handlers.GetRevenueReportHandler(db))
|
||||||
|
http.HandleFunc("GET /revenue/sum", handlers.GetRevenueSumHandler(db))
|
||||||
|
|
||||||
fmt.Println("Server running on :8080")
|
fmt.Println("Server running on :8080")
|
||||||
go func() {
|
go func() {
|
||||||
@@ -49,7 +57,7 @@ func main() {
|
|||||||
|
|
||||||
func runShell(db *sql.DB) {
|
func runShell(db *sql.DB) {
|
||||||
scanner := bufio.NewScanner(os.Stdin)
|
scanner := bufio.NewScanner(os.Stdin)
|
||||||
fmt.Println("\nShell ready. Commands: add-company, help, exit")
|
fmt.Println("\nShell ready. Type 'help' for commands.")
|
||||||
|
|
||||||
for {
|
for {
|
||||||
fmt.Print("> ")
|
fmt.Print("> ")
|
||||||
@@ -63,20 +71,40 @@ func runShell(db *sql.DB) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch parts[0] {
|
switch parts[0] {
|
||||||
|
// Company
|
||||||
case "add-company":
|
case "add-company":
|
||||||
shell.AddCompany(scanner, db)
|
shell.AddCompany(scanner, db)
|
||||||
|
case "list-companies":
|
||||||
|
shell.ListCompanies(db)
|
||||||
|
|
||||||
|
// Currency
|
||||||
case "add-currency":
|
case "add-currency":
|
||||||
shell.AddCurrency(scanner, db)
|
shell.AddCurrency(scanner, db)
|
||||||
case "list-currency":
|
case "list-currency":
|
||||||
shell.ListCurrencies(db)
|
shell.ListCurrencies(db)
|
||||||
|
|
||||||
case "help":
|
// Revenue
|
||||||
fmt.Println("Commands:")
|
case "add-revenue":
|
||||||
fmt.Println(" add-company - add a new company interactively")
|
shell.AddRevenue(scanner, db)
|
||||||
fmt.Println(" add-currency - add a new currency interactively")
|
case "list-revenue":
|
||||||
fmt.Println(" list-currency - lists all currencies")
|
shell.ListRevenue(scanner, db)
|
||||||
|
case "sum-revenue":
|
||||||
|
shell.SumRevenue(scanner, db)
|
||||||
|
|
||||||
fmt.Println(" exit - quit")
|
case "help":
|
||||||
|
fmt.Println("\nCommands:")
|
||||||
|
fmt.Println(" --- Company ---")
|
||||||
|
fmt.Println(" add-company add a new company")
|
||||||
|
fmt.Println(" list-companies list all companies")
|
||||||
|
fmt.Println(" --- Currency ---")
|
||||||
|
fmt.Println(" add-currency add a new currency")
|
||||||
|
fmt.Println(" list-currency list all currencies")
|
||||||
|
fmt.Println(" --- Revenue ---")
|
||||||
|
fmt.Println(" add-revenue add a revenue entry interactively")
|
||||||
|
fmt.Println(" list-revenue list revenue for a company/period")
|
||||||
|
fmt.Println(" sum-revenue sum revenue across periods")
|
||||||
|
fmt.Println(" --- Other ---")
|
||||||
|
fmt.Println(" exit quit")
|
||||||
|
|
||||||
case "exit":
|
case "exit":
|
||||||
fmt.Println("Bye!")
|
fmt.Println("Bye!")
|
||||||
|
|||||||
Reference in New Issue
Block a user