243 lines
5.9 KiB
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
|
|
}
|