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