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
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)
|
|
}
|
|
}
|
|
|