2 changed files with 426 additions and 0 deletions
@ -0,0 +1,78 @@ |
|||||
|
package api |
||||
|
|
||||
|
import ( |
||||
|
"encoding/json" |
||||
|
"fmt" |
||||
|
"io" |
||||
|
"net/http" |
||||
|
) |
||||
|
|
||||
|
// ClockEventData represents the response data returned from the ServiceTrade clock endpoint.
|
||||
|
type ClockEventData struct { |
||||
|
TotalPages int `json:"totalPages"` |
||||
|
Page int `json:"page"` |
||||
|
ActivityTime *ClockActivityMap `json:"activityTime"` |
||||
|
PairedEvents []ClockPairedEvent `json:"pairedEvents"` |
||||
|
UnpairedEvents []ClockEvent `json:"unpairedEvents"` |
||||
|
} |
||||
|
|
||||
|
// ClockActivityMap captures total elapsed seconds for each activity type.
|
||||
|
type ClockActivityMap struct { |
||||
|
Onsite int64 `json:"onsite"` |
||||
|
Offsite int64 `json:"offsite"` |
||||
|
Enroute int64 `json:"enroute"` |
||||
|
Onbreak int64 `json:"onbreak"` |
||||
|
} |
||||
|
|
||||
|
// ClockPairedEvent represents a paired start/end clock event.
|
||||
|
type ClockPairedEvent struct { |
||||
|
ElapsedTime int64 `json:"elapsedTime"` |
||||
|
Start ClockEvent `json:"start"` |
||||
|
End ClockEvent `json:"end"` |
||||
|
} |
||||
|
|
||||
|
// ClockEvent represents an individual clock event.
|
||||
|
type ClockEvent struct { |
||||
|
EventTime int64 `json:"eventTime"` |
||||
|
EventType string `json:"eventType"` |
||||
|
Activity string `json:"activity"` |
||||
|
Source string `json:"source"` |
||||
|
User ClockUser `json:"user"` |
||||
|
} |
||||
|
|
||||
|
// ClockUser represents a technician associated with a clock event.
|
||||
|
type ClockUser struct { |
||||
|
Name string `json:"name"` |
||||
|
Email string `json:"email"` |
||||
|
} |
||||
|
|
||||
|
type clockResponse struct { |
||||
|
Data ClockEventData `json:"data"` |
||||
|
} |
||||
|
|
||||
|
// GetJobClockEvents retrieves paired clock events for a given job identifier.
|
||||
|
func (s *Session) GetJobClockEvents(jobID string) (*ClockEventData, error) { |
||||
|
endpoint := fmt.Sprintf("/clock?jobId=%s", jobID) |
||||
|
resp, err := s.DoRequest(http.MethodGet, endpoint, nil) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
defer resp.Body.Close() |
||||
|
|
||||
|
body, err := io.ReadAll(resp.Body) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("error reading response body: %w", err) |
||||
|
} |
||||
|
|
||||
|
if resp.StatusCode != http.StatusOK { |
||||
|
return nil, fmt.Errorf("API returned error: %s - %s", resp.Status, string(body)) |
||||
|
} |
||||
|
|
||||
|
var result clockResponse |
||||
|
if err := json.Unmarshal(body, &result); err != nil { |
||||
|
return nil, fmt.Errorf("error unmarshalling clock response: %w", err) |
||||
|
} |
||||
|
|
||||
|
return &result.Data, nil |
||||
|
} |
||||
|
|
||||
@ -0,0 +1,348 @@ |
|||||
|
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) |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue