an updated and hopefully faster version of the ST Toolbox
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.
 
 
 
 

793 lines
19 KiB

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