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