an updated and hopefully faster version of the ST Toolbox
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

348 lines
8.2 KiB

package web
import (
"bytes"
"encoding/csv"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"time"
"marmic/servicetrade-toolbox/internal/api"
"marmic/servicetrade-toolbox/internal/middleware"
)
var invoiceClockReportHeader = []string{
"invoice_number",
"invoice_id",
"job_id",
"job_number",
"assignment_type",
"vendor_name",
"tech_names",
"enroute_events",
"onsite_events",
"error",
}
// InvoiceClockReportHandler processes uploaded CSVs of invoice numbers and returns a clock-event report.
func InvoiceClockReportHandler(w http.ResponseWriter, r *http.Request) {
session, ok := r.Context().Value(middleware.SessionKey).(*api.Session)
if !ok {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if err := r.ParseMultipartForm(10 << 20); err != nil {
http.Error(w, fmt.Sprintf("Unable to parse form: %v", err), http.StatusBadRequest)
return
}
file, _, err := r.FormFile("invoiceCsv")
if err != nil {
http.Error(w, fmt.Sprintf("Error retrieving file: %v", err), http.StatusBadRequest)
return
}
defer file.Close()
csvReader := csv.NewReader(file)
records, err := csvReader.ReadAll()
if err != nil {
http.Error(w, fmt.Sprintf("Error reading CSV file: %v", err), http.StatusBadRequest)
return
}
if len(records) == 0 {
http.Error(w, "CSV file is empty", http.StatusBadRequest)
return
}
invoiceNumbers := extractInvoiceNumbers(records)
if len(invoiceNumbers) == 0 {
http.Error(w, "No invoice numbers found in CSV", http.StatusBadRequest)
return
}
var buffer bytes.Buffer
writer := csv.NewWriter(&buffer)
if err := writer.Write(invoiceClockReportHeader); err != nil {
http.Error(w, fmt.Sprintf("Error writing CSV header: %v", err), http.StatusInternalServerError)
return
}
for _, invoiceNumber := range invoiceNumbers {
row := buildInvoiceClockReportRow(session, invoiceNumber)
if err := writer.Write(row); err != nil {
http.Error(w, fmt.Sprintf("Error writing CSV row: %v", err), http.StatusInternalServerError)
return
}
}
writer.Flush()
if err := writer.Error(); err != nil {
http.Error(w, fmt.Sprintf("Error finalizing CSV: %v", err), http.StatusInternalServerError)
return
}
filename := fmt.Sprintf("invoice-clock-report-%s.csv", time.Now().Format("20060102-150405"))
w.Header().Set("Content-Type", "text/csv")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
w.Header().Set("Cache-Control", "no-store")
w.Write(buffer.Bytes())
}
func extractInvoiceNumbers(records [][]string) []string {
if len(records) == 0 {
return nil
}
headers := make([]string, len(records[0]))
for i, header := range records[0] {
headers[i] = strings.ToLower(strings.TrimSpace(header))
}
columnIndex := 0
hasHeader := false
for i, header := range headers {
if header == "invoice_number" || header == "invoicenumber" || header == "invoice" {
columnIndex = i
hasHeader = true
break
}
}
var numbers []string
startRow := 0
if hasHeader {
startRow = 1
}
for _, row := range records[startRow:] {
if columnIndex >= len(row) {
continue
}
number := strings.TrimSpace(row[columnIndex])
if number != "" {
numbers = append(numbers, number)
}
}
return numbers
}
func buildInvoiceClockReportRow(session *api.Session, invoiceIdentifier string) []string {
row := make([]string, len(invoiceClockReportHeader))
row[0] = invoiceIdentifier
invoice, err := session.GetInvoice(invoiceIdentifier)
if err != nil {
row[len(row)-1] = fmt.Sprintf("invoice lookup error: %v", err)
return row
}
if invoice == nil {
row[len(row)-1] = "invoice not found"
return row
}
invoiceID := valueToString(invoice["id"])
row[1] = invoiceID
jobInfo, ok := invoice["job"].(map[string]interface{})
if !ok {
row[len(row)-1] = "invoice missing job information"
return row
}
jobID := valueToString(jobInfo["id"])
jobNumber := valueToString(jobInfo["number"])
row[2] = jobID
row[3] = jobNumber
var errors []string
jobDetails, err := session.GetJobDetails(jobID)
if err != nil {
errors = append(errors, fmt.Sprintf("job details error: %v", err))
}
clockData, err := session.GetJobClockEvents(jobID)
if err != nil {
errors = append(errors, fmt.Sprintf("clock events error: %v", err))
}
techNames := collectTechNames(jobDetails, clockData)
vendorName := extractVendorName(jobDetails, invoice)
assignmentType := determineAssignmentType(techNames, vendorName)
row[4] = assignmentType
row[5] = vendorName
row[6] = strings.Join(techNames, ", ")
if clockData != nil {
row[7] = formatActivityEvents(clockData.PairedEvents, "enroute")
row[8] = formatActivityEvents(clockData.PairedEvents, "onsite")
}
if len(errors) > 0 {
row[len(row)-1] = strings.Join(errors, "; ")
}
return row
}
func collectTechNames(jobDetails map[string]interface{}, clockData *api.ClockEventData) []string {
seen := map[string]struct{}{}
if jobDetails != nil {
if appointments, ok := jobDetails["appointments"].([]interface{}); ok {
for _, appt := range appointments {
apptMap, ok := appt.(map[string]interface{})
if !ok {
continue
}
if techs, ok := apptMap["techs"].([]interface{}); ok {
for _, tech := range techs {
if techMap, ok := tech.(map[string]interface{}); ok {
name := strings.TrimSpace(valueToString(techMap["name"]))
if name != "" {
seen[name] = struct{}{}
}
}
}
}
}
}
if current, ok := jobDetails["currentAppointment"].(map[string]interface{}); ok {
if techs, ok := current["techs"].([]interface{}); ok {
for _, tech := range techs {
if techMap, ok := tech.(map[string]interface{}); ok {
name := strings.TrimSpace(valueToString(techMap["name"]))
if name != "" {
seen[name] = struct{}{}
}
}
}
}
}
}
if clockData != nil {
for _, event := range clockData.PairedEvents {
name := strings.TrimSpace(event.Start.User.Name)
if name != "" {
seen[name] = struct{}{}
}
name = strings.TrimSpace(event.End.User.Name)
if name != "" {
seen[name] = struct{}{}
}
}
}
if len(seen) == 0 {
return nil
}
var names []string
for name := range seen {
names = append(names, name)
}
sort.Strings(names)
return names
}
func extractVendorName(jobDetails map[string]interface{}, invoice map[string]interface{}) string {
if jobDetails != nil {
if vendor, ok := jobDetails["vendor"].(map[string]interface{}); ok {
if name := strings.TrimSpace(valueToString(vendor["name"])); name != "" {
return name
}
}
}
if vendor, ok := invoice["vendor"].(map[string]interface{}); ok {
if name := strings.TrimSpace(valueToString(vendor["name"])); name != "" {
return name
}
}
return ""
}
func determineAssignmentType(techNames []string, vendorName string) string {
if len(techNames) > 0 {
return "Technician"
}
if vendorName != "" {
return "Vendor"
}
return "Unknown"
}
func formatActivityEvents(events []api.ClockPairedEvent, activity string) string {
var parts []string
activityLower := strings.ToLower(activity)
for _, e := range events {
if strings.ToLower(e.Start.Activity) != activityLower {
continue
}
tech := strings.TrimSpace(e.Start.User.Name)
if tech == "" {
tech = "Unknown Tech"
}
start := formatTimestamp(e.Start.EventTime)
end := formatTimestamp(e.End.EventTime)
var durationText string
if e.ElapsedTime > 0 {
duration := time.Duration(e.ElapsedTime) * time.Second
durationText = duration.String()
}
detail := fmt.Sprintf("%s: %s -> %s", tech, start, end)
if durationText != "" {
detail = fmt.Sprintf("%s (%s)", detail, durationText)
}
parts = append(parts, detail)
}
return strings.Join(parts, " | ")
}
func formatTimestamp(epoch int64) string {
if epoch == 0 {
return ""
}
return time.Unix(epoch, 0).UTC().Format(time.RFC3339)
}
func valueToString(value interface{}) string {
switch v := value.(type) {
case string:
return v
case fmt.Stringer:
return v.String()
case float64:
return strconv.FormatInt(int64(v), 10)
case int64:
return strconv.FormatInt(v, 10)
case int:
return strconv.Itoa(v)
default:
if v == nil {
return ""
}
return fmt.Sprintf("%v", v)
}
}