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