package web import ( "bytes" "encoding/csv" "fmt" "log" "net/http" "strconv" "strings" "sync" "time" "github.com/xuri/excelize/v2" "marmic/servicetrade-toolbox/internal/api" "marmic/servicetrade-toolbox/internal/middleware" ) var reportHeader = []string{ "Customer PO", "id", "Link", "Clock In", "Clock Out", } // InvoiceClockReportHandler processes a CSV upload and returns a CSV matching the requested template. 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) < 2 { http.Error(w, "CSV must include a header row and at least one data row", http.StatusBadRequest) return } header := records[0] columns := detectInputColumns(header) if columns.jobID == -1 && columns.invoice == -1 { http.Error(w, "Input must include a job id column or an invoice number column", http.StatusBadRequest) return } log.Printf("Invoice clock report: detected columns jobID=%d invoice=%d customerPO=%d", columns.jobID, columns.invoice, columns.customerPO) tasks := make([]rowTask, 0, len(records)-1) for rowIdx := 1; rowIdx < len(records); rowIdx++ { row := ensureRowLength(records[rowIdx], len(header)) task := rowTask{ index: rowIdx - 1, rawID: cleanIdentifier(getRowValue(row, columns.jobID)), invoiceNumber: strings.TrimSpace(getRowValue(row, columns.invoice)), customerPO: strings.TrimSpace(getRowValue(row, columns.customerPO)), } tasks = append(tasks, task) } f := excelize.NewFile() sheet := f.GetSheetName(0) f.SetSheetName(sheet, "Report") sheet = "Report" headerStyle, _ := f.NewStyle(&excelize.Style{ Font: &excelize.Font{Bold: true, Size: 12}, Alignment: &excelize.Alignment{ Horizontal: "center", Vertical: "center", WrapText: true, }, }) textStyle, _ := f.NewStyle(&excelize.Style{ NumFmt: 49, // treat as text Alignment: &excelize.Alignment{ Vertical: "top", WrapText: true, }, }) bodyWrapStyle, _ := f.NewStyle(&excelize.Style{ Alignment: &excelize.Alignment{ Vertical: "top", WrapText: true, }, }) for idx, title := range reportHeader { cell, _ := excelize.CoordinatesToCellName(idx+1, 1) if err := f.SetCellStr(sheet, cell, title); err != nil { http.Error(w, fmt.Sprintf("Error writing header cell: %v", err), http.StatusInternalServerError) return } if err := f.SetCellStyle(sheet, cell, cell, headerStyle); err != nil { http.Error(w, fmt.Sprintf("Error styling header cell: %v", err), http.StatusInternalServerError) return } } if err := f.SetPanes(sheet, &excelize.Panes{ Freeze: true, YSplit: 1, TopLeftCell: "A2", ActivePane: "bottomLeft", }); err != nil { log.Printf("Warning: unable to freeze header row: %v", err) } if err := f.SetColWidth(sheet, "A", "A", 25); err != nil { log.Printf("Warning: unable to set column width: %v", err) } if err := f.SetColWidth(sheet, "B", "C", 40); err != nil { log.Printf("Warning: unable to set column width: %v", err) } if err := f.SetColWidth(sheet, "D", "E", 28); err != nil { log.Printf("Warning: unable to set column width: %v", err) } if len(tasks) == 0 { var buffer bytes.Buffer if err := f.Write(&buffer); err != nil { http.Error(w, fmt.Sprintf("Error finalizing XLSX: %v", err), http.StatusInternalServerError) return } if err := f.Close(); err != nil { log.Printf("Warning: error closing workbook: %v", err) } filename := fmt.Sprintf("invoice-clock-report-%s.xlsx", time.Now().Format("20060102-150405")) w.Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) w.Header().Set("Cache-Control", "no-store") w.Write(buffer.Bytes()) return } limiter := newRateLimiter(5) defer limiter.Stop() ctx := &workerContext{ session: session, caches: newSharedCaches(), limiter: limiter, lookup: newLookupState(5), } workerCount := 6 if len(tasks) < workerCount { workerCount = len(tasks) } if workerCount < 1 { workerCount = 1 } jobsCh := make(chan rowTask) resultsCh := make(chan rowResult, workerCount) var wg sync.WaitGroup for i := 0; i < workerCount; i++ { wg.Add(1) go func() { defer wg.Done() for task := range jobsCh { resultsCh <- processRow(task, ctx) } }() } go func() { for _, task := range tasks { jobsCh <- task } close(jobsCh) }() go func() { wg.Wait() close(resultsCh) }() pending := make(map[int]rowResult) nextIndex := 0 var writeErr error for res := range resultsCh { if writeErr != nil { continue } pending[res.index] = res for { data, ok := pending[nextIndex] if !ok { break } if err := writeExcelRow(f, sheet, data, nextIndex, textStyle, bodyWrapStyle); err != nil { writeErr = err break } delete(pending, nextIndex) nextIndex++ } } if writeErr != nil { http.Error(w, fmt.Sprintf("Error writing XLSX: %v", writeErr), http.StatusInternalServerError) return } var buffer bytes.Buffer if err := f.Write(&buffer); err != nil { http.Error(w, fmt.Sprintf("Error finalizing XLSX: %v", err), http.StatusInternalServerError) return } if err := f.Close(); err != nil { log.Printf("Warning: error closing workbook: %v", err) } filename := fmt.Sprintf("invoice-clock-report-%s.xlsx", time.Now().Format("20060102-150405")) w.Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) w.Header().Set("Cache-Control", "no-store") w.Write(buffer.Bytes()) } type inputColumns struct { jobID int invoice int customerPO int } func detectInputColumns(headers []string) inputColumns { var cols inputColumns cols.jobID = -1 cols.invoice = -1 cols.customerPO = -1 normalized := make([]string, len(headers)) original := make([]string, len(headers)) for i, header := range headers { normalized[i] = normalizeHeaderValue(header) original[i] = strings.ToLower(strings.TrimSpace(header)) } jobPriority := []string{"id", "jobid", "job_id"} for _, candidate := range jobPriority { if cols.jobID != -1 { break } for i, name := range normalized { if name == candidate { cols.jobID = i break } } } if cols.jobID == -1 { for i, name := range normalized { if strings.Contains(name, "job") && strings.Contains(name, "id") { cols.jobID = i break } } } invoiceCandidates := []string{"invoicenumber", "invoice", "refnumber", "ref"} for _, candidate := range invoiceCandidates { if cols.invoice != -1 { break } for i, name := range normalized { if name == candidate { cols.invoice = i break } } } customerExact := []string{"customer_po", "customerpo", "customer purchase order", "customer_po_number"} for _, candidate := range customerExact { if cols.customerPO != -1 { break } for i, name := range original { if name == candidate { cols.customerPO = i break } } } if cols.customerPO == -1 { for i, name := range normalized { if strings.Contains(name, "customerpo") && !strings.Contains(name, "postal") { cols.customerPO = i break } } } if cols.customerPO == -1 && cols.invoice != -1 { invoiceHeader := normalized[cols.invoice] if strings.Contains(invoiceHeader, "customerpo") { cols.customerPO = cols.invoice } } return cols } func normalizeHeaderValue(header string) string { h := strings.ToLower(strings.TrimSpace(header)) replacements := []string{" ", "_", "-", "."} for _, repl := range replacements { h = strings.ReplaceAll(h, repl, "") } return h } func ensureRowLength(row []string, length int) []string { if len(row) >= length { return row } res := make([]string, length) copy(res, row) return res } func getRowValue(row []string, index int) string { if index < 0 || index >= len(row) { return "" } return row[index] } func cleanIdentifier(value string) string { value = strings.TrimSpace(value) if value == "" { return "" } if strings.HasPrefix(value, "'") { value = strings.TrimPrefix(value, "'") } if dot := strings.IndexRune(value, '.'); dot > 0 { if decimalPart := value[dot+1:]; len(decimalPart) == 0 || strings.Trim(decimalPart, "0") == "" { return value[:dot] } } return value } type rowTask struct { index int rawID string invoiceNumber string customerPO string } type rowResult struct { index int customerPO string jobID string link string clockIn string clockOut string } type workerContext struct { session *api.Session caches *sharedCaches limiter *rateLimiter lookup *lookupState } type sharedCaches struct { mu sync.RWMutex invoices map[string]map[string]interface{} clocks map[string]clockSummary } func newSharedCaches() *sharedCaches { return &sharedCaches{ invoices: make(map[string]map[string]interface{}), clocks: make(map[string]clockSummary), } } func (c *sharedCaches) getInvoice(key string) (map[string]interface{}, bool) { c.mu.RLock() defer c.mu.RUnlock() inv, ok := c.invoices[key] return inv, ok } func (c *sharedCaches) setInvoice(key string, invoice map[string]interface{}) { c.mu.Lock() defer c.mu.Unlock() c.invoices[key] = invoice } func (c *sharedCaches) getClock(jobID string) (clockSummary, bool) { c.mu.RLock() defer c.mu.RUnlock() summary, ok := c.clocks[jobID] return summary, ok } func (c *sharedCaches) setClock(jobID string, summary clockSummary) { c.mu.Lock() defer c.mu.Unlock() c.clocks[jobID] = summary } type lookupState struct { mu sync.Mutex jobIDEnabled bool invoiceIDEnabled bool jobIDFailures int invoiceIDFailures int threshold int jobIDDisabledLogged bool invoiceIDDisabledLogged bool } func newLookupState(threshold int) *lookupState { return &lookupState{ jobIDEnabled: true, invoiceIDEnabled: true, threshold: threshold, } } func (s *lookupState) shouldTryJobID() bool { s.mu.Lock() defer s.mu.Unlock() return s.jobIDEnabled } func (s *lookupState) recordJobIDResult(success bool) { s.mu.Lock() defer s.mu.Unlock() if success { s.jobIDFailures = 0 if !s.jobIDEnabled { s.jobIDEnabled = true s.jobIDDisabledLogged = false } return } if !s.jobIDEnabled { return } s.jobIDFailures++ if s.jobIDFailures >= s.threshold { s.jobIDEnabled = false if !s.jobIDDisabledLogged { log.Printf("Disabling job ID lookups after %d consecutive failures", s.jobIDFailures) s.jobIDDisabledLogged = true } } } func (s *lookupState) shouldTryInvoiceID() bool { s.mu.Lock() defer s.mu.Unlock() return s.invoiceIDEnabled } func (s *lookupState) recordInvoiceIDResult(success bool) { s.mu.Lock() defer s.mu.Unlock() if success { s.invoiceIDFailures = 0 if !s.invoiceIDEnabled { s.invoiceIDEnabled = true s.invoiceIDDisabledLogged = false } return } if !s.invoiceIDEnabled { return } s.invoiceIDFailures++ if s.invoiceIDFailures >= s.threshold { s.invoiceIDEnabled = false if !s.invoiceIDDisabledLogged { log.Printf("Disabling invoice ID lookups after %d consecutive failures", s.invoiceIDFailures) s.invoiceIDDisabledLogged = true } } } type rateLimiter struct { ticker *time.Ticker } func newRateLimiter(rps int) *rateLimiter { if rps <= 0 { rps = 5 } interval := time.Second / time.Duration(rps) return &rateLimiter{ticker: time.NewTicker(interval)} } func (rl *rateLimiter) Wait() { if rl == nil || rl.ticker == nil { return } <-rl.ticker.C } func (rl *rateLimiter) Stop() { if rl == nil || rl.ticker == nil { return } rl.ticker.Stop() } func processRow(task rowTask, ctx *workerContext) rowResult { result := rowResult{ index: task.index, customerPO: strings.TrimSpace(task.customerPO), } rawID := strings.TrimSpace(task.rawID) invoiceNumber := strings.TrimSpace(task.invoiceNumber) displayRow := task.index + 1 if rawID != "" { if ctx.lookup.shouldTryJobID() { log.Printf("Row %d: attempting job clock lookup for id=%s", displayRow, rawID) summary, err := fetchClockSummary(ctx.session, rawID, ctx.caches, ctx.limiter) if err == nil { ctx.lookup.recordJobIDResult(true) result.jobID = rawID result.link = buildJobLink(rawID) result.clockIn = summary.ClockIn result.clockOut = summary.ClockOut finalizeCustomerPO(&result, invoiceNumber, rawID) return result } log.Printf("Row %d: clock lookup failed for job %s: %v", displayRow, rawID, err) ctx.lookup.recordJobIDResult(false) } else { log.Printf("Row %d: skipping job id lookup (disabled)", displayRow) } } var invoiceData map[string]interface{} if rawID != "" { if ctx.lookup.shouldTryInvoiceID() { log.Printf("Row %d: falling back to invoice id %s", displayRow, rawID) inv, err := fetchInvoice(ctx.session, rawID, ctx.caches, ctx.limiter) if err == nil && inv != nil { ctx.lookup.recordInvoiceIDResult(true) invoiceData = inv } else { if err != nil { log.Printf("Invoice lookup failed for id %s (row %d): %v", rawID, displayRow, err) } ctx.lookup.recordInvoiceIDResult(false) } } else { log.Printf("Row %d: skipping invoice id lookup (disabled)", displayRow) } } if invoiceData == nil && invoiceNumber != "" { log.Printf("Row %d: falling back to invoice number %s", displayRow, invoiceNumber) inv, err := fetchInvoice(ctx.session, invoiceNumber, ctx.caches, ctx.limiter) if err != nil { log.Printf("Invoice lookup failed for number %s (row %d): %v", invoiceNumber, displayRow, err) } else if inv != nil { invoiceData = inv } } if invoiceData != nil { applyInvoiceFields(&result, invoiceData) jobFromInvoice := extractJobIDFromInvoice(invoiceData) if jobFromInvoice != "" { summary, err := fetchClockSummary(ctx.session, jobFromInvoice, ctx.caches, ctx.limiter) if err != nil { log.Printf("Row %d: clock lookup failed for invoice-derived job %s: %v", displayRow, jobFromInvoice, err) } else { result.clockIn = summary.ClockIn result.clockOut = summary.ClockOut } result.jobID = jobFromInvoice result.link = buildJobLink(jobFromInvoice) } } if result.jobID == "" { log.Printf("Row %d: no job id could be determined; leaving link blank", displayRow) } finalizeCustomerPO(&result, invoiceNumber, rawID) return result } func applyInvoiceFields(result *rowResult, invoice map[string]interface{}) { if invoice == nil { return } if result.customerPO == "" { if po := strings.TrimSpace(valueToString(invoice["customerPo"])); po != "" { result.customerPO = po } } } func finalizeCustomerPO(result *rowResult, invoiceNumber, rawID string) { if result.customerPO != "" { return } if invoiceNumber != "" { result.customerPO = invoiceNumber return } if rawID != "" { result.customerPO = rawID } } func buildJobLink(jobID string) string { if jobID == "" { return "" } return fmt.Sprintf("https://app.servicetrade.com/jobs/%s", jobID) } func writeExcelRow(f *excelize.File, sheet string, result rowResult, index int, textStyle, bodyStyle int) error { excelRow := index + 2 values := []string{ result.customerPO, result.jobID, result.link, result.clockIn, result.clockOut, } for colIdx, value := range values { cell, _ := excelize.CoordinatesToCellName(colIdx+1, excelRow) style := bodyStyle if colIdx == 1 { style = textStyle } if err := f.SetCellStr(sheet, cell, value); err != nil { return err } if err := f.SetCellStyle(sheet, cell, cell, style); err != nil { return err } if colIdx == 2 && value != "" { if err := f.SetCellHyperLink(sheet, cell, value, "External"); err != nil { log.Printf("Warning: unable to set hyperlink for cell %s: %v", cell, err) } } } return nil } func fetchInvoice(session *api.Session, identifier string, caches *sharedCaches, limiter *rateLimiter) (map[string]interface{}, error) { if identifier == "" { return nil, nil } if invoice, ok := caches.getInvoice(identifier); ok { return invoice, nil } if limiter != nil { limiter.Wait() } invoice, err := session.GetInvoice(identifier) if err != nil { return nil, err } if invoice != nil { caches.setInvoice(identifier, invoice) } return invoice, nil } func extractJobIDFromInvoice(invoice map[string]interface{}) string { if invoice == nil { return "" } jobInfo, ok := invoice["job"].(map[string]interface{}) if !ok { return "" } return cleanIdentifier(valueToString(jobInfo["id"])) } type clockSummary struct { ClockIn string ClockOut string } func fetchClockSummary(session *api.Session, jobID string, caches *sharedCaches, limiter *rateLimiter) (clockSummary, error) { if jobID == "" { return clockSummary{}, nil } if summary, ok := caches.getClock(jobID); ok { return summary, nil } if limiter != nil { limiter.Wait() } data, err := session.GetJobClockEvents(jobID) if err != nil { return clockSummary{}, err } var earliestStart int64 var latestEnd int64 for _, event := range data.PairedEvents { if event.Start.EventTime != 0 { if earliestStart == 0 || event.Start.EventTime < earliestStart { earliestStart = event.Start.EventTime } } if event.End.EventTime != 0 { if event.End.EventTime > latestEnd { latestEnd = event.End.EventTime } } } summary := clockSummary{ ClockIn: formatTimestampPlain(earliestStart), ClockOut: formatTimestampPlain(latestEnd), } caches.setClock(jobID, summary) return summary, nil } func formatTimestampPlain(epoch int64) string { if epoch == 0 { return "" } local := time.Unix(epoch, 0).In(time.Local) return local.Format("2006-01-02 15:04:05 MST") } 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) } }