new rev sys
This commit is contained in:
BIN
Portifolio
BIN
Portifolio
Binary file not shown.
@@ -35,46 +35,28 @@ func InitDB(db *sql.DB) {
|
||||
UNIQUE(type, year, idx)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS revenue_reports (
|
||||
CREATE TABLE IF NOT EXISTS category (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
company_id INTEGER NOT NULL,
|
||||
period_id INTEGER NOT NULL,
|
||||
parent_id INTEGER,
|
||||
name TEXT NOT NULL,
|
||||
FOREIGN KEY (company_id) REFERENCES companies(id),
|
||||
FOREIGN KEY (period_id) REFERENCES periods(id),
|
||||
UNIQUE(company_id, period_id)
|
||||
FOREIGN KEY (parent_id) REFERENCES category(id),
|
||||
UNIQUE(company_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS revenue_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
report_id INTEGER NOT NULL,
|
||||
currency_id INTEGER NOT NULL,
|
||||
category TEXT NOT NULL CHECK(category IN ('product', 'location', 'total')),
|
||||
label TEXT NOT NULL,
|
||||
value REAL NOT NULL,
|
||||
FOREIGN KEY (report_id) REFERENCES revenue_reports(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,
|
||||
currency_id INTEGER NOT NULL,
|
||||
category_id INTEGER NOT NULL,
|
||||
period_id INTEGER NOT NULL,
|
||||
value REAL 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)
|
||||
);
|
||||
`
|
||||
FOREIGN KEY (currency_id) REFERENCES currencies(id),
|
||||
FOREIGN KEY (category_id) REFERENCES category(id),
|
||||
FOREIGN KEY (period_id) REFERENCES periods(id)
|
||||
);`
|
||||
|
||||
if _, err := db.Exec(schema); err != nil {
|
||||
log.Fatal("Failed to create tables:", err)
|
||||
|
||||
43
internal/database/revenue.go
Normal file
43
internal/database/revenue.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"Portifolio/internal/model"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
func GetCategoryByName(db *sql.DB, companyID int, name string) (model.RevenueCategory, error) {
|
||||
var rc model.RevenueCategory
|
||||
err := db.QueryRow(
|
||||
`SELECT id, company_id, parent_id, name FROM category WHERE company_id = ? AND name = ?`,
|
||||
companyID, name,
|
||||
).Scan(&rc.ID, &rc.CompanyID, &rc.ParentID, &rc.Name)
|
||||
if err == sql.ErrNoRows {
|
||||
return rc, fmt.Errorf("category %q not found for company %d", name, companyID)
|
||||
}
|
||||
if err != nil {
|
||||
return rc, fmt.Errorf("get category by name: %w", err)
|
||||
}
|
||||
return rc, nil
|
||||
}
|
||||
|
||||
func GetPeriodByID(db *sql.DB, periodID int) (model.Period, error) {
|
||||
var p model.Period
|
||||
var start, end string
|
||||
err := db.QueryRow(
|
||||
`SELECT type, year, idx, start_date, end_date FROM periods WHERE id = ?`,
|
||||
periodID,
|
||||
).Scan(&p.Type, &p.Year, &p.Index, &start, &end)
|
||||
if err == sql.ErrNoRows {
|
||||
return p, fmt.Errorf("period %d not found", periodID)
|
||||
}
|
||||
if err != nil {
|
||||
return p, fmt.Errorf("get period by id: %w", err)
|
||||
}
|
||||
p.Start, _ = time.Parse("2006-01-02", start)
|
||||
p.End, _ = time.Parse("2006-01-02", end)
|
||||
return p, nil
|
||||
}
|
||||
@@ -11,48 +11,6 @@ import (
|
||||
_ "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 {
|
||||
@@ -62,7 +20,6 @@ func AddRevenueEntryHandler(db *sql.DB) http.HandlerFunc {
|
||||
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 {
|
||||
@@ -83,7 +40,7 @@ func AddRevenueEntryHandler(db *sql.DB) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
if err := service.InsertRevenue(db, input.CompanyID, input.CurrencyID, input.Category, input.Label, input.Value, period); err != nil {
|
||||
if err := service.InsertRevenue(db, input.CompanyID, input.CurrencyID, input.Category, nil, input.Value, period); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -111,20 +68,3 @@ func GetRevenueReportHandler(db *sql.DB) http.HandlerFunc {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PeriodType defines the granularity of the revenue entry
|
||||
type PeriodType string
|
||||
@@ -13,6 +17,7 @@ const (
|
||||
|
||||
// Period holds the actual time range for a revenue entry
|
||||
type Period struct {
|
||||
ID int
|
||||
Type PeriodType
|
||||
Year int
|
||||
Index int // Q1=1 Q2=2 Q3=3 Q4=4 | H1=1 H2=2 | FY=1
|
||||
@@ -20,22 +25,69 @@ type Period struct {
|
||||
End time.Time
|
||||
}
|
||||
|
||||
// RevenueCategory is what the revenue is broken down by
|
||||
// RevenueCategory is now just a string — categories are dynamic per company
|
||||
type RevenueCategory = string
|
||||
func (p *Period) Insert(db *sql.DB) error {
|
||||
_, 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, end_date=excluded.end_date`,
|
||||
string(p.Type), p.Year, p.Index, p.Start.Format("2006-01-02"), p.End.Format("2006-01-02"),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert period: %w", err)
|
||||
}
|
||||
|
||||
// Built-in reserved categories
|
||||
const (
|
||||
CategoryTotal RevenueCategory = "total" // always required, never custom
|
||||
)
|
||||
var id int
|
||||
err = db.QueryRow(
|
||||
`SELECT id FROM periods WHERE type = ? AND year = ? AND idx = ?`,
|
||||
string(p.Type), p.Year, p.Index,
|
||||
).Scan(&id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("select period: %w", err)
|
||||
}
|
||||
p.ID = id
|
||||
return nil
|
||||
}
|
||||
|
||||
// CategoryDef defines a custom category for a specific company
|
||||
// e.g. Company: Apple, Name: "product", Labels: ["iPhone", "Mac", "Services"]
|
||||
type CategoryDef struct {
|
||||
type RevenueCategory struct {
|
||||
ID int
|
||||
Company *Company
|
||||
CompanyID int
|
||||
ParentID *int
|
||||
Name string // e.g. "product", "location", "segment"
|
||||
Labels []string // populated from category_labels table
|
||||
}
|
||||
|
||||
func (rc *RevenueCategory) Validate() error {
|
||||
if rc.ID == 0 {
|
||||
return fmt.Errorf("No ID Set")
|
||||
} else if rc.Name == "" {
|
||||
return fmt.Errorf("No Name found")
|
||||
} else if rc.CompanyID == 0 {
|
||||
return fmt.Errorf("No Company Set")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rc *RevenueCategory) Insert(db *sql.DB) error {
|
||||
if err := rc.Validate(); err != nil {
|
||||
return fmt.Errorf("failed to insert: %w", err)
|
||||
}
|
||||
|
||||
_, err := db.Exec(
|
||||
`INSERT INTO category (company_id, parent_id, name) VALUES (?, ?, ?)
|
||||
ON CONFLICT(company_id, name) DO UPDATE SET parent_id=excluded.parent_id`,
|
||||
rc.CompanyID, rc.ParentID, rc.Name,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert category: %w", err)
|
||||
}
|
||||
|
||||
err = db.QueryRow(
|
||||
`SELECT id FROM category WHERE company_id = ? AND name = ?`,
|
||||
rc.CompanyID, rc.Name,
|
||||
).Scan(&rc.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("select category id: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Revenue is a single line in a financial report
|
||||
@@ -43,82 +95,7 @@ type Revenue struct {
|
||||
ID int
|
||||
Company *Company
|
||||
Currency *Currency
|
||||
Category RevenueCategory
|
||||
Label string // e.g. "North America", "iPhone", "Total"
|
||||
Category *RevenueCategory
|
||||
Period *Period
|
||||
Value float64
|
||||
Period Period
|
||||
}
|
||||
|
||||
// RevenueReport groups revenue lines for a company/period
|
||||
// and verifies that sub-categories sum to total
|
||||
type RevenueReport struct {
|
||||
Company *Company
|
||||
Period Period
|
||||
Entries []Revenue
|
||||
}
|
||||
|
||||
// Total returns the sum of all entries matching a category
|
||||
func (r *RevenueReport) Total(category RevenueCategory) float64 {
|
||||
var sum float64
|
||||
for _, e := range r.Entries {
|
||||
if e.Category == category {
|
||||
sum += e.Value
|
||||
}
|
||||
}
|
||||
return sum
|
||||
}
|
||||
|
||||
// SumByCategory returns a map of category → sum for all entries
|
||||
func (r *RevenueReport) SumByCategory() map[RevenueCategory]float64 {
|
||||
sums := make(map[RevenueCategory]float64)
|
||||
for _, e := range r.Entries {
|
||||
sums[e.Category] += e.Value
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"Portifolio/internal/database"
|
||||
"Portifolio/internal/model"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
@@ -9,69 +10,64 @@ import (
|
||||
_ "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)
|
||||
func InsertRevenue(db *sql.DB, companyID, currencyID int, categoryName string, parentID *int, value float64, period model.Period) error {
|
||||
|
||||
period, err := database.GetPeriodByID(db, period.ID)
|
||||
if err != nil {
|
||||
err = period.Insert(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
reportID, err := getOrCreateReport(db, companyID, periodID)
|
||||
}
|
||||
// Getting Category, if error, trying to insert the category with the company.
|
||||
category, err := database.GetCategoryByName(db, companyID, categoryName)
|
||||
if err != nil {
|
||||
category = model.RevenueCategory{
|
||||
CompanyID: companyID,
|
||||
ParentID: parentID,
|
||||
Name: categoryName,
|
||||
}
|
||||
err := category.Insert(db)
|
||||
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"),
|
||||
`INSERT INTO revenue_entries (company_id, currency_id, category_id, period_id, value)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
companyID, currencyID, category.ID, period.ID, value,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return fmt.Errorf("insert revenue_entries: %w", 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
|
||||
return nil
|
||||
}
|
||||
|
||||
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,
|
||||
WITH RECURSIVE tree AS (
|
||||
SELECT id, name, parent_id
|
||||
FROM category
|
||||
WHERE company_id = ? AND parent_id IS NULL
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT c.id, c.name, c.parent_id
|
||||
FROM category c
|
||||
JOIN tree t ON c.parent_id = t.id
|
||||
)
|
||||
SELECT e.id, t.name, 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,
|
||||
JOIN tree t ON e.category_id = t.id
|
||||
JOIN periods p ON e.period_id = p.id
|
||||
WHERE e.company_id = ? AND p.type = ? AND p.year = ? AND p.idx = ?`,
|
||||
companyID, companyID, string(periodType), year, idx,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("query revenue: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
@@ -80,56 +76,14 @@ func GetRevenue(db *sql.DB, companyID int, periodType model.PeriodType, year, id
|
||||
var e model.Revenue
|
||||
var p model.Period
|
||||
var start, end string
|
||||
if err := rows.Scan(&e.ID, &e.Category, &e.Label, &e.Value,
|
||||
if err := rows.Scan(&e.ID, &e.Category, &e.Value,
|
||||
&p.Type, &p.Year, &p.Index, &start, &end); err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("scan revenue row: %w", err)
|
||||
}
|
||||
p.Start, _ = time.Parse("2006-01-02", start)
|
||||
p.End, _ = time.Parse("2006-01-02", end)
|
||||
e.Period = p
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -10,144 +10,120 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
func AddRevenue(scanner *bufio.Scanner, db *sql.DB) {
|
||||
fmt.Print(" Company ID: ")
|
||||
func promptInt(scanner *bufio.Scanner, label string) (int, error) {
|
||||
fmt.Print(label)
|
||||
scanner.Scan()
|
||||
companyID, err := strconv.Atoi(strings.TrimSpace(scanner.Text()))
|
||||
v, err := strconv.Atoi(strings.TrimSpace(scanner.Text()))
|
||||
if err != nil {
|
||||
fmt.Println(" ✗ Invalid company ID")
|
||||
return
|
||||
return 0, fmt.Errorf("invalid %s", label)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func promptFloat(scanner *bufio.Scanner, label string) (float64, error) {
|
||||
fmt.Print(label)
|
||||
scanner.Scan()
|
||||
v, err := strconv.ParseFloat(strings.TrimSpace(scanner.Text()), 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid %s", label)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func promptString(scanner *bufio.Scanner, label string) string {
|
||||
fmt.Print(label)
|
||||
scanner.Scan()
|
||||
return strings.TrimSpace(scanner.Text())
|
||||
}
|
||||
|
||||
func promptPeriod(scanner *bufio.Scanner) (model.Period, error) {
|
||||
periodType := strings.ToUpper(promptString(scanner, " Period type (Q/H/Y): "))
|
||||
year, err := promptInt(scanner, " Year: ")
|
||||
if err != nil {
|
||||
return model.Period{}, err
|
||||
}
|
||||
|
||||
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)
|
||||
return model.FullYearPeriod(year), nil
|
||||
case "Q", "H":
|
||||
label := map[string]string{"Q": " Index (1-4): ", "H": " Index (1-2): "}[periodType]
|
||||
idx, err := promptInt(scanner, label)
|
||||
if err != nil {
|
||||
return model.Period{}, err
|
||||
}
|
||||
if periodType == "Q" {
|
||||
return model.QuarterPeriod(year, idx), nil
|
||||
}
|
||||
return model.HalfYearPeriod(year, idx), nil
|
||||
default:
|
||||
fmt.Println(" ✗ Invalid period type")
|
||||
return model.Period{}, fmt.Errorf("invalid period type: %s", periodType)
|
||||
}
|
||||
}
|
||||
|
||||
func AddRevenue(scanner *bufio.Scanner, db *sql.DB) {
|
||||
companyID, err := promptInt(scanner, " Company ID: ")
|
||||
if err != nil {
|
||||
fmt.Println(" ✗", err)
|
||||
return
|
||||
}
|
||||
currencyID, err := promptInt(scanner, " Currency ID: ")
|
||||
if err != nil {
|
||||
fmt.Println(" ✗", err)
|
||||
return
|
||||
}
|
||||
period, err := promptPeriod(scanner)
|
||||
if err != nil {
|
||||
fmt.Println(" ✗", err)
|
||||
return
|
||||
}
|
||||
category := promptString(scanner, " Category name: ")
|
||||
|
||||
var parentID *int
|
||||
parentStr := promptString(scanner, " Parent category ID (leave blank for root): ")
|
||||
if parentStr != "" {
|
||||
pid, err := strconv.Atoi(parentStr)
|
||||
if err != nil {
|
||||
fmt.Println(" ✗ Invalid parent ID")
|
||||
return
|
||||
}
|
||||
parentID = &pid
|
||||
}
|
||||
|
||||
value, err := promptFloat(scanner, " Value: ")
|
||||
if err != nil {
|
||||
fmt.Println(" ✗", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := service.InsertRevenue(db, companyID, currencyID, category, label, value, period); err != nil {
|
||||
if err := service.InsertRevenue(db, companyID, currencyID, category, parentID, value, period); err != nil {
|
||||
fmt.Println(" ✗ Error:", err)
|
||||
return
|
||||
}
|
||||
fmt.Printf(" ✓ Revenue entry added: %s / %s = %.2f (%s)\n", category, label, value, period.String())
|
||||
fmt.Printf(" ✓ Revenue added: %s = %.2f (%s)\n", category, 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()))
|
||||
companyID, err := promptInt(scanner, " Company ID: ")
|
||||
if err != nil {
|
||||
fmt.Println(" ✗ Invalid company ID")
|
||||
fmt.Println(" ✗", err)
|
||||
return
|
||||
}
|
||||
period, err := promptPeriod(scanner)
|
||||
if err != nil {
|
||||
fmt.Println(" ✗", err)
|
||||
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)
|
||||
entries, err := service.GetRevenue(db, companyID, period.Type, period.Year, period.Index)
|
||||
if err != nil {
|
||||
fmt.Println(" ✗ Error:", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("\n %-12s %-20s %12s\n", "CATEGORY", "LABEL", "VALUE")
|
||||
fmt.Println(" " + strings.Repeat("-", 46))
|
||||
fmt.Printf("\n %-20s %12s\n", "CATEGORY", "VALUE")
|
||||
fmt.Println(" " + strings.Repeat("-", 34))
|
||||
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)
|
||||
fmt.Printf(" %-20s %12.2f\n", e.Category, e.Value)
|
||||
}
|
||||
}
|
||||
|
||||
4
main.go
4
main.go
@@ -42,10 +42,8 @@ func main() {
|
||||
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")
|
||||
go func() {
|
||||
@@ -88,8 +86,6 @@ func runShell(db *sql.DB) {
|
||||
shell.AddRevenue(scanner, db)
|
||||
case "list-revenue":
|
||||
shell.ListRevenue(scanner, db)
|
||||
case "sum-revenue":
|
||||
shell.SumRevenue(scanner, db)
|
||||
|
||||
case "help":
|
||||
fmt.Println("\nCommands:")
|
||||
|
||||
Reference in New Issue
Block a user