verstak/internal/core/worklog/worklog.go

243 lines
5.9 KiB
Go

package worklog
import (
"database/sql"
"fmt"
"strings"
"time"
"verstak/internal/core/storage"
"verstak/internal/core/util"
)
// Entry represents a worklog record.
type Entry struct {
ID string `json:"id"`
NodeID string `json:"node_id"`
StartedAt *time.Time `json:"started_at,omitempty"`
EndedAt *time.Time `json:"ended_at,omitempty"`
Date string `json:"date"`
Minutes *int `json:"minutes,omitempty"`
Approximate bool `json:"approximate"`
Billable bool `json:"billable"`
Summary string `json:"summary"`
Details string `json:"details,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Service manages worklog entries.
type Service struct {
db *storage.DB
}
// NewService creates a worklog service.
func NewService(db *storage.DB) *Service {
return &Service{db: db}
}
// Add inserts a new worklog entry.
func (s *Service) Add(nodeID, summary, details string, minutes int, approximate, billable bool) (*Entry, error) {
if nodeID == "" {
return nil, fmt.Errorf("node_id required")
}
if summary == "" {
return nil, fmt.Errorf("summary required")
}
e := &Entry{
ID: util.UUID7(),
NodeID: nodeID,
Summary: summary,
Details: details,
Date: time.Now().UTC().Format("2006-01-02"),
Minutes: &minutes,
Approximate: approximate,
Billable: billable,
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
_, err := s.db.Exec(
`INSERT INTO worklog_entries (id,node_id,date,minutes,approximate,billable,
summary,details,created_at,updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?)`,
e.ID, e.NodeID, e.Date, e.Minutes, boolInt(e.Approximate),
boolInt(e.Billable), e.Summary, e.Details,
e.CreatedAt.Format(time.RFC3339), e.UpdatedAt.Format(time.RFC3339),
)
if err != nil {
return nil, err
}
return e, nil
}
// Update modifies an existing entry.
func (s *Service) Update(id, summary, details string, minutes int, approximate, billable bool) error {
t := time.Now().UTC().Format(time.RFC3339)
res, err := s.db.Exec(
`UPDATE worklog_entries SET summary=?, details=?, minutes=?,
approximate=?, billable=?, updated_at=? WHERE id=?`,
summary, details, &minutes, boolInt(approximate), boolInt(billable), t, id,
)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("entry not found")
}
return nil
}
// Get returns an entry by ID.
func (s *Service) Get(id string) (*Entry, error) {
rows, err := s.db.Query(
`SELECT id,node_id,started_at,ended_at,date,minutes,approximate,
billable,summary,details,created_at,updated_at
FROM worklog_entries WHERE id=?`, id)
if err != nil {
return nil, err
}
defer rows.Close()
if !rows.Next() {
return nil, fmt.Errorf("entry not found")
}
return scanEntry(rows)
}
// ListByNode returns entries for a node, newest first.
func (s *Service) ListByNode(nodeID string) ([]Entry, error) {
rows, err := s.db.Query(
`SELECT id,node_id,started_at,ended_at,date,minutes,approximate,
billable,summary,details,created_at,updated_at
FROM worklog_entries WHERE node_id=? ORDER BY date DESC, created_at DESC`, nodeID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Entry
for rows.Next() {
e, err := scanEntry(rows)
if err != nil {
return nil, err
}
out = append(out, *e)
}
return out, rows.Err()
}
// Delete removes an entry.
func (s *Service) Delete(id string) error {
res, err := s.db.Exec("DELETE FROM worklog_entries WHERE id=?", id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("entry not found")
}
return nil
}
// SumMinutes returns total minutes for a node.
func (s *Service) SumMinutes(nodeID string) (int, error) {
var total sql.NullInt64
err := s.db.QueryRow(
`SELECT SUM(minutes) FROM worklog_entries WHERE node_id=?`, nodeID,
).Scan(&total)
if err != nil {
return 0, err
}
if !total.Valid {
return 0, nil
}
return int(total.Int64), nil
}
// Report generates a text report for a node's worklog.
func (s *Service) Report(nodeID string) (string, error) {
entries, err := s.ListByNode(nodeID)
if err != nil {
return "", err
}
var b strings.Builder
b.WriteString(fmt.Sprintf("Отчёт по работе (узел: %s)\n", nodeID))
b.WriteString(strings.Repeat("─", 40) + "\n\n")
totalMin := 0
for _, e := range entries {
date := e.Date
duration := "—"
if e.Minutes != nil {
m := *e.Minutes
totalMin += m
duration = fmt.Sprintf("%dч %dм", m/60, m%60)
}
approx := ""
if e.Approximate {
approx = " ~"
}
b.WriteString(fmt.Sprintf("%s %s%s\n", date, duration, approx))
b.WriteString(fmt.Sprintf(" %s\n", e.Summary))
if e.Details != "" {
b.WriteString(fmt.Sprintf(" %s\n", e.Details))
}
b.WriteString("\n")
}
b.WriteString(strings.Repeat("─", 40) + "\n")
b.WriteString(fmt.Sprintf("Итого: %dч %dм\n", totalMin/60, totalMin%60))
return b.String(), nil
}
// --- scanner ---
type rowScanner interface {
Scan(dest ...interface{}) error
}
func scanEntry(s rowScanner) (*Entry, error) {
var e Entry
var startedAt, endedAt, details sql.NullString
var minutes sql.NullInt64
var createdStr, updatedStr string
var approxInt, billInt int
err := s.Scan(
&e.ID, &e.NodeID, &startedAt, &endedAt, &e.Date, &minutes,
&approxInt, &billInt, &e.Summary, &details, &createdStr, &updatedStr,
)
if err != nil {
return nil, err
}
if startedAt.Valid {
t, _ := time.Parse(time.RFC3339, startedAt.String)
e.StartedAt = &t
}
if endedAt.Valid {
t, _ := time.Parse(time.RFC3339, endedAt.String)
e.EndedAt = &t
}
if minutes.Valid {
m := int(minutes.Int64)
e.Minutes = &m
}
e.Approximate = approxInt == 1
e.Billable = billInt == 1
if details.Valid {
e.Details = details.String
}
e.CreatedAt, _ = time.Parse(time.RFC3339, createdStr)
e.UpdatedAt, _ = time.Parse(time.RFC3339, updatedStr)
return &e, nil
}
func boolInt(b bool) int {
if b {
return 1
}
return 0
}