From 5e7c51e9c2811f4c396b8d10ac254de7a35a76cb Mon Sep 17 00:00:00 2001 From: Adnan Zahir Date: Tue, 21 Apr 2026 21:24:19 +0700 Subject: [PATCH] feat: export input progress report for expenses, marketings, purchases, and recordings --- internal/common/exportprogress/export.go | 576 ++++++++++++++++++ internal/common/exportprogress/export_test.go | 126 ++++ .../controllers/expense.controller.go | 21 + .../repositories/expense.repository.go | 63 ++ .../repositories/expense_progress_test.go | 72 +++ .../expenses/services/expense.service.go | 10 + .../controllers/deliveryorder.controller.go | 22 + .../repositories/marketing_progress_test.go | 75 +++ .../repositories/salesorder.repository.go | 67 ++ .../services/deliveryorder.service.go | 10 + .../controllers/recording.controller.go | 21 + .../repositories/recording.repository.go | 61 ++ .../repositories/recording_progress_test.go | 68 +++ .../recordings/services/recording.service.go | 10 + .../controllers/purchase.controller.go | 21 + .../repositories/purchase.repository.go | 71 +++ .../repositories/purchase_progress_test.go | 74 +++ .../purchases/services/purchase.service.go | 10 + 18 files changed, 1378 insertions(+) create mode 100644 internal/common/exportprogress/export.go create mode 100644 internal/common/exportprogress/export_test.go create mode 100644 internal/modules/expenses/repositories/expense_progress_test.go create mode 100644 internal/modules/marketing/repositories/marketing_progress_test.go create mode 100644 internal/modules/production/recordings/repositories/recording_progress_test.go create mode 100644 internal/modules/purchases/repositories/purchase_progress_test.go diff --git a/internal/common/exportprogress/export.go b/internal/common/exportprogress/export.go new file mode 100644 index 00000000..ddff0a32 --- /dev/null +++ b/internal/common/exportprogress/export.go @@ -0,0 +1,576 @@ +package exportprogress + +import ( + "fmt" + "sort" + "strings" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/xuri/excelize/v2" +) + +const ( + UnassignedKandangName = "Farm-level / Unassigned" + jakartaTZ = "Asia/Jakarta" +) + +type Query struct { + StartDate time.Time + EndDate time.Time + StartDateRaw string + EndDateRaw string +} + +type Row struct { + Module string + FarmName string + KandangName string + ActivityDate time.Time + Count int +} + +type monthBlock struct { + Start time.Time + Weeks int +} + +func IsProgressExportRequest(c *fiber.Ctx) bool { + return strings.EqualFold(strings.TrimSpace(c.Query("export")), "excel") && + strings.EqualFold(strings.TrimSpace(c.Query("type")), "progress") +} + +func ParseQuery(c *fiber.Ctx) (*Query, error) { + location, err := time.LoadLocation(jakartaTZ) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "failed to load timezone configuration") + } + + startRaw := strings.TrimSpace(c.Query("start_date")) + endRaw := strings.TrimSpace(c.Query("end_date")) + if startRaw == "" || endRaw == "" { + return nil, fiber.NewError(fiber.StatusBadRequest, "start_date and end_date are required") + } + + startDate, err := time.ParseInLocation("2006-01-02", startRaw, location) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "start_date must use format YYYY-MM-DD") + } + endDate, err := time.ParseInLocation("2006-01-02", endRaw, location) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "end_date must use format YYYY-MM-DD") + } + if endDate.Before(startDate) { + return nil, fiber.NewError(fiber.StatusBadRequest, "end_date must be greater than or equal to start_date") + } + + return &Query{ + StartDate: startDate, + EndDate: endDate, + StartDateRaw: startRaw, + EndDateRaw: endRaw, + }, nil +} + +func BuildWorkbook(moduleTitle string, query *Query, rows []Row) ([]byte, error) { + file := excelize.NewFile() + defer file.Close() + + sheetName := moduleTitle + defaultSheet := file.GetSheetName(file.GetActiveSheetIndex()) + if defaultSheet != sheetName { + if err := file.SetSheetName(defaultSheet, sheetName); err != nil { + return nil, err + } + } + + location, err := time.LoadLocation(jakartaTZ) + if err != nil { + return nil, err + } + + titleStyle, metaStyle, monthStyle, weekStyle, dayHeaderStyle, farmStyle, textStyle, numberStyle, subtotalStyle, err := buildStyles(file) + if err != nil { + return nil, err + } + + months := monthBlocksBetween(query.StartDate, query.EndDate) + maxWeeks := 4 + for _, block := range months { + if block.Weeks > maxWeeks { + maxWeeks = block.Weeks + } + } + lastColName, err := excelize.ColumnNumberToName(1 + (maxWeeks * 7) + 1) + if err != nil { + return nil, err + } + + if err := file.MergeCell(sheetName, "A1", lastColName+"1"); err != nil { + return nil, err + } + if err := file.SetCellValue(sheetName, "A1", moduleTitle); err != nil { + return nil, err + } + if err := file.SetCellStyle(sheetName, "A1", lastColName+"1", titleStyle); err != nil { + return nil, err + } + + metaValue := fmt.Sprintf( + "Range: %s to %s | Generated at: %s", + query.StartDateRaw, + query.EndDateRaw, + time.Now().In(location).Format("2006-01-02 15:04:05 MST"), + ) + if err := file.MergeCell(sheetName, "A2", lastColName+"2"); err != nil { + return nil, err + } + if err := file.SetCellValue(sheetName, "A2", metaValue); err != nil { + return nil, err + } + if err := file.SetCellStyle(sheetName, "A2", lastColName+"2", metaStyle); err != nil { + return nil, err + } + + if err := applyColumnWidths(file, sheetName, maxWeeks); err != nil { + return nil, err + } + + grouped := groupRows(rows) + currentRow := 4 + for _, month := range months { + lastColIndex := 1 + (month.Weeks * 7) + 1 + monthLastCol, err := excelize.ColumnNumberToName(lastColIndex) + if err != nil { + return nil, err + } + + if err := renderMonthHeader(file, sheetName, currentRow, month, monthLastCol, monthStyle, weekStyle, dayHeaderStyle); err != nil { + return nil, err + } + currentRow += 4 + + monthData := grouped[month.Start.Format("2006-01")] + if len(monthData) == 0 { + if err := file.MergeCell(sheetName, "A"+fmt.Sprint(currentRow), monthLastCol+fmt.Sprint(currentRow)); err != nil { + return nil, err + } + if err := file.SetCellValue(sheetName, "A"+fmt.Sprint(currentRow), "No progress data"); err != nil { + return nil, err + } + if err := file.SetCellStyle(sheetName, "A"+fmt.Sprint(currentRow), monthLastCol+fmt.Sprint(currentRow), textStyle); err != nil { + return nil, err + } + currentRow += 2 + continue + } + + farms := sortedKeys(monthData) + for _, farm := range farms { + if err := file.MergeCell(sheetName, "A"+fmt.Sprint(currentRow), monthLastCol+fmt.Sprint(currentRow)); err != nil { + return nil, err + } + if err := file.SetCellValue(sheetName, "A"+fmt.Sprint(currentRow), farm); err != nil { + return nil, err + } + if err := file.SetCellStyle(sheetName, "A"+fmt.Sprint(currentRow), monthLastCol+fmt.Sprint(currentRow), farmStyle); err != nil { + return nil, err + } + currentRow++ + + kandangs := sortedKeys(monthData[farm]) + farmTotals := make(map[string]int) + farmGrandTotal := 0 + for _, kandang := range kandangs { + rowCounts := monthData[farm][kandang] + rowTotal := 0 + + if err := file.SetCellValue(sheetName, "A"+fmt.Sprint(currentRow), kandang); err != nil { + return nil, err + } + if err := file.SetCellStyle(sheetName, "A"+fmt.Sprint(currentRow), "A"+fmt.Sprint(currentRow), textStyle); err != nil { + return nil, err + } + + for dayKey, count := range rowCounts { + activityDate, err := time.ParseInLocation("2006-01-02", dayKey, location) + if err != nil { + return nil, err + } + colIndex := dayColumnIndex(month, activityDate) + colName, err := excelize.ColumnNumberToName(colIndex) + if err != nil { + return nil, err + } + if err := file.SetCellValue(sheetName, colName+fmt.Sprint(currentRow), count); err != nil { + return nil, err + } + if err := file.SetCellStyle(sheetName, colName+fmt.Sprint(currentRow), colName+fmt.Sprint(currentRow), numberStyle); err != nil { + return nil, err + } + rowTotal += count + farmTotals[dayKey] += count + farmGrandTotal += count + } + + if err := file.SetCellValue(sheetName, monthLastCol+fmt.Sprint(currentRow), rowTotal); err != nil { + return nil, err + } + if err := file.SetCellStyle(sheetName, monthLastCol+fmt.Sprint(currentRow), monthLastCol+fmt.Sprint(currentRow), subtotalStyle); err != nil { + return nil, err + } + if err := file.SetCellStyle(sheetName, "B"+fmt.Sprint(currentRow), prevColumn(monthLastCol)+fmt.Sprint(currentRow), numberStyle); err != nil { + return nil, err + } + + currentRow++ + } + + if err := file.SetCellValue(sheetName, "A"+fmt.Sprint(currentRow), "Subtotal"); err != nil { + return nil, err + } + if err := file.SetCellStyle(sheetName, "A"+fmt.Sprint(currentRow), "A"+fmt.Sprint(currentRow), subtotalStyle); err != nil { + return nil, err + } + for dayKey, count := range farmTotals { + activityDate, err := time.ParseInLocation("2006-01-02", dayKey, location) + if err != nil { + return nil, err + } + colIndex := dayColumnIndex(month, activityDate) + colName, err := excelize.ColumnNumberToName(colIndex) + if err != nil { + return nil, err + } + if err := file.SetCellValue(sheetName, colName+fmt.Sprint(currentRow), count); err != nil { + return nil, err + } + } + if err := file.SetCellValue(sheetName, monthLastCol+fmt.Sprint(currentRow), farmGrandTotal); err != nil { + return nil, err + } + if err := file.SetCellStyle(sheetName, "A"+fmt.Sprint(currentRow), monthLastCol+fmt.Sprint(currentRow), subtotalStyle); err != nil { + return nil, err + } + currentRow += 2 + } + } + + if err := file.SetPanes(sheetName, &excelize.Panes{ + Freeze: true, + YSplit: 2, + TopLeftCell: "A3", + ActivePane: "bottomLeft", + }); err != nil { + return nil, err + } + + buffer, err := file.WriteToBuffer() + if err != nil { + return nil, err + } + return buffer.Bytes(), nil +} + +func buildStyles(file *excelize.File) (int, int, int, int, int, int, int, int, int, error) { + titleStyle, err := file.NewStyle(&excelize.Style{ + Font: &excelize.Font{Bold: true, Size: 18, Color: "1F2937"}, + Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"DCEBFA"}}, + Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"}, + }) + if err != nil { + return 0, 0, 0, 0, 0, 0, 0, 0, 0, err + } + metaStyle, err := file.NewStyle(&excelize.Style{ + Font: &excelize.Font{Italic: true, Color: "4B5563"}, + Alignment: &excelize.Alignment{Horizontal: "left", Vertical: "center"}, + }) + if err != nil { + return 0, 0, 0, 0, 0, 0, 0, 0, 0, err + } + monthStyle, err := file.NewStyle(&excelize.Style{ + Font: &excelize.Font{Bold: true, Color: "FFFFFF"}, + Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"1D4ED8"}}, + Alignment: &excelize.Alignment{Horizontal: "left", Vertical: "center"}, + }) + if err != nil { + return 0, 0, 0, 0, 0, 0, 0, 0, 0, err + } + weekStyle, err := file.NewStyle(&excelize.Style{ + Font: &excelize.Font{Bold: true, Color: "1F2937"}, + Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"DBEAFE"}}, + Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"}, + Border: []excelize.Border{{Type: "bottom", Color: "93C5FD", Style: 1}}, + }) + if err != nil { + return 0, 0, 0, 0, 0, 0, 0, 0, 0, err + } + dayHeaderStyle, err := file.NewStyle(&excelize.Style{ + Font: &excelize.Font{Bold: true, Color: "374151"}, + Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"EFF6FF"}}, + Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"}, + Border: []excelize.Border{ + {Type: "left", Color: "BFDBFE", Style: 1}, + {Type: "top", Color: "BFDBFE", Style: 1}, + {Type: "bottom", Color: "BFDBFE", Style: 1}, + {Type: "right", Color: "BFDBFE", Style: 1}, + }, + }) + if err != nil { + return 0, 0, 0, 0, 0, 0, 0, 0, 0, err + } + farmStyle, err := file.NewStyle(&excelize.Style{ + Font: &excelize.Font{Bold: true, Color: "111827"}, + Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"E5E7EB"}}, + Alignment: &excelize.Alignment{Horizontal: "left", Vertical: "center"}, + }) + if err != nil { + return 0, 0, 0, 0, 0, 0, 0, 0, 0, err + } + textStyle, err := file.NewStyle(&excelize.Style{ + Alignment: &excelize.Alignment{Horizontal: "left", Vertical: "center"}, + Border: []excelize.Border{ + {Type: "left", Color: "D1D5DB", Style: 1}, + {Type: "top", Color: "D1D5DB", Style: 1}, + {Type: "bottom", Color: "D1D5DB", Style: 1}, + {Type: "right", Color: "D1D5DB", Style: 1}, + }, + }) + if err != nil { + return 0, 0, 0, 0, 0, 0, 0, 0, 0, err + } + numberStyle, err := file.NewStyle(&excelize.Style{ + Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"}, + Border: []excelize.Border{ + {Type: "left", Color: "D1D5DB", Style: 1}, + {Type: "top", Color: "D1D5DB", Style: 1}, + {Type: "bottom", Color: "D1D5DB", Style: 1}, + {Type: "right", Color: "D1D5DB", Style: 1}, + }, + }) + if err != nil { + return 0, 0, 0, 0, 0, 0, 0, 0, 0, err + } + subtotalStyle, err := file.NewStyle(&excelize.Style{ + Font: &excelize.Font{Bold: true, Color: "1F2937"}, + Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"F3F4F6"}}, + Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"}, + Border: []excelize.Border{ + {Type: "left", Color: "9CA3AF", Style: 1}, + {Type: "top", Color: "9CA3AF", Style: 1}, + {Type: "bottom", Color: "9CA3AF", Style: 1}, + {Type: "right", Color: "9CA3AF", Style: 1}, + }, + }) + if err != nil { + return 0, 0, 0, 0, 0, 0, 0, 0, 0, err + } + + return titleStyle, metaStyle, monthStyle, weekStyle, dayHeaderStyle, farmStyle, textStyle, numberStyle, subtotalStyle, nil +} + +func applyColumnWidths(file *excelize.File, sheet string, maxWeeks int) error { + if err := file.SetColWidth(sheet, "A", "A", 28); err != nil { + return err + } + for col := 2; col <= 1+(maxWeeks*7); col++ { + colName, err := excelize.ColumnNumberToName(col) + if err != nil { + return err + } + if err := file.SetColWidth(sheet, colName, colName, 6); err != nil { + return err + } + } + totalCol, err := excelize.ColumnNumberToName(1 + (maxWeeks * 7) + 1) + if err != nil { + return err + } + return file.SetColWidth(sheet, totalCol, totalCol, 10) +} + +func renderMonthHeader(file *excelize.File, sheet string, startRow int, block monthBlock, monthLastCol string, monthStyle, weekStyle, dayHeaderStyle int) error { + if err := file.MergeCell(sheet, "A"+fmt.Sprint(startRow), monthLastCol+fmt.Sprint(startRow)); err != nil { + return err + } + if err := file.SetCellValue(sheet, "A"+fmt.Sprint(startRow), block.Start.Format("January 2006")); err != nil { + return err + } + if err := file.SetCellStyle(sheet, "A"+fmt.Sprint(startRow), monthLastCol+fmt.Sprint(startRow), monthStyle); err != nil { + return err + } + + if err := file.MergeCell(sheet, "A"+fmt.Sprint(startRow+1), "A"+fmt.Sprint(startRow+3)); err != nil { + return err + } + if err := file.SetCellValue(sheet, "A"+fmt.Sprint(startRow+1), "Kandang"); err != nil { + return err + } + if err := file.SetCellStyle(sheet, "A"+fmt.Sprint(startRow+1), "A"+fmt.Sprint(startRow+3), dayHeaderStyle); err != nil { + return err + } + + totalColIndex := 1 + (block.Weeks * 7) + 1 + totalColName, err := excelize.ColumnNumberToName(totalColIndex) + if err != nil { + return err + } + if err := file.MergeCell(sheet, totalColName+fmt.Sprint(startRow+1), totalColName+fmt.Sprint(startRow+3)); err != nil { + return err + } + if err := file.SetCellValue(sheet, totalColName+fmt.Sprint(startRow+1), "Total"); err != nil { + return err + } + if err := file.SetCellStyle(sheet, totalColName+fmt.Sprint(startRow+1), totalColName+fmt.Sprint(startRow+3), dayHeaderStyle); err != nil { + return err + } + + weekdayNames := []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"} + for week := 0; week < block.Weeks; week++ { + startCol := 2 + (week * 7) + endCol := startCol + 6 + startColName, err := excelize.ColumnNumberToName(startCol) + if err != nil { + return err + } + endColName, err := excelize.ColumnNumberToName(endCol) + if err != nil { + return err + } + if err := file.MergeCell(sheet, startColName+fmt.Sprint(startRow+1), endColName+fmt.Sprint(startRow+1)); err != nil { + return err + } + if err := file.SetCellValue(sheet, startColName+fmt.Sprint(startRow+1), fmt.Sprintf("Week %d", week+1)); err != nil { + return err + } + if err := file.SetCellStyle(sheet, startColName+fmt.Sprint(startRow+1), endColName+fmt.Sprint(startRow+1), weekStyle); err != nil { + return err + } + + for weekday := 0; weekday < 7; weekday++ { + colIndex := startCol + weekday + colName, err := excelize.ColumnNumberToName(colIndex) + if err != nil { + return err + } + if err := file.SetCellValue(sheet, colName+fmt.Sprint(startRow+2), weekdayNames[weekday]); err != nil { + return err + } + if err := file.SetCellStyle(sheet, colName+fmt.Sprint(startRow+2), colName+fmt.Sprint(startRow+2), dayHeaderStyle); err != nil { + return err + } + } + } + + daysInMonth := time.Date(block.Start.Year(), block.Start.Month()+1, 0, 0, 0, 0, 0, block.Start.Location()).Day() + for day := 1; day <= daysInMonth; day++ { + date := time.Date(block.Start.Year(), block.Start.Month(), day, 0, 0, 0, 0, block.Start.Location()) + colIndex := dayColumnIndex(block, date) + colName, err := excelize.ColumnNumberToName(colIndex) + if err != nil { + return err + } + if err := file.SetCellValue(sheet, colName+fmt.Sprint(startRow+3), day); err != nil { + return err + } + if err := file.SetCellStyle(sheet, colName+fmt.Sprint(startRow+3), colName+fmt.Sprint(startRow+3), dayHeaderStyle); err != nil { + return err + } + } + + return nil +} + +func groupRows(rows []Row) map[string]map[string]map[string]map[string]int { + grouped := make(map[string]map[string]map[string]map[string]int) + for _, row := range rows { + monthKey := row.ActivityDate.Format("2006-01") + if _, exists := grouped[monthKey]; !exists { + grouped[monthKey] = make(map[string]map[string]map[string]int) + } + farmName := strings.TrimSpace(row.FarmName) + if farmName == "" { + farmName = "Unknown Farm" + } + if _, exists := grouped[monthKey][farmName]; !exists { + grouped[monthKey][farmName] = make(map[string]map[string]int) + } + kandangName := strings.TrimSpace(row.KandangName) + if kandangName == "" { + kandangName = UnassignedKandangName + } + if _, exists := grouped[monthKey][farmName][kandangName]; !exists { + grouped[monthKey][farmName][kandangName] = make(map[string]int) + } + dayKey := row.ActivityDate.Format("2006-01-02") + grouped[monthKey][farmName][kandangName][dayKey] += row.Count + } + return grouped +} + +func monthBlocksBetween(startDate, endDate time.Time) []monthBlock { + location := startDate.Location() + current := time.Date(startDate.Year(), startDate.Month(), 1, 0, 0, 0, 0, location) + last := time.Date(endDate.Year(), endDate.Month(), 1, 0, 0, 0, 0, location) + + blocks := make([]monthBlock, 0) + for !current.After(last) { + blocks = append(blocks, monthBlock{ + Start: current, + Weeks: monthWeeks(current), + }) + current = current.AddDate(0, 1, 0) + } + return blocks +} + +func monthWeeks(monthStart time.Time) int { + daysInMonth := time.Date(monthStart.Year(), monthStart.Month()+1, 0, 0, 0, 0, 0, monthStart.Location()).Day() + offset := mondayIndex(monthStart.Weekday()) + totalSlots := offset + daysInMonth + weeks := totalSlots / 7 + if totalSlots%7 != 0 { + weeks++ + } + if weeks < 4 { + return 4 + } + return weeks +} + +func dayColumnIndex(block monthBlock, date time.Time) int { + day := date.Day() + offset := mondayIndex(block.Start.Weekday()) + position := offset + (day - 1) + return 2 + position +} + +func mondayIndex(weekday time.Weekday) int { + switch weekday { + case time.Sunday: + return 6 + default: + return int(weekday) - 1 + } +} + +func sortedKeys[V any](input map[string]V) []string { + keys := make([]string, 0, len(input)) + for key := range input { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +func prevColumn(col string) string { + index, err := excelize.ColumnNameToNumber(col) + if err != nil || index <= 1 { + return col + } + result, err := excelize.ColumnNumberToName(index - 1) + if err != nil { + return col + } + return result +} diff --git a/internal/common/exportprogress/export_test.go b/internal/common/exportprogress/export_test.go new file mode 100644 index 00000000..ded0a19d --- /dev/null +++ b/internal/common/exportprogress/export_test.go @@ -0,0 +1,126 @@ +package exportprogress + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/xuri/excelize/v2" +) + +func TestParseQuery(t *testing.T) { + app := fiber.New() + app.Get("/", func(c *fiber.Ctx) error { + query, err := ParseQuery(c) + if err != nil { + return err + } + return c.JSON(fiber.Map{ + "start": query.StartDateRaw, + "end": query.EndDateRaw, + }) + }) + + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/?export=excel&type=progress&start_date=2026-06-01&end_date=2026-07-15", nil)) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + + var payload map[string]string + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + t.Fatalf("failed decoding payload: %v", err) + } + if payload["start"] != "2026-06-01" || payload["end"] != "2026-07-15" { + t.Fatalf("unexpected payload: %+v", payload) + } +} + +func TestParseQueryInvalid(t *testing.T) { + app := fiber.New() + app.Get("/", func(c *fiber.Ctx) error { + _, err := ParseQuery(c) + return err + }) + + cases := []string{ + "/?export=excel&type=progress", + "/?export=excel&type=progress&start_date=2026-06-01&end_date=bad", + "/?export=excel&type=progress&start_date=2026-07-01&end_date=2026-06-01", + } + for _, target := range cases { + resp, err := app.Test(httptest.NewRequest(http.MethodGet, target, nil)) + if err != nil { + t.Fatalf("request failed for %s: %v", target, err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400 for %s, got %d", target, resp.StatusCode) + } + } +} + +func TestBuildWorkbook(t *testing.T) { + location, err := time.LoadLocation(jakartaTZ) + if err != nil { + t.Fatalf("failed loading location: %v", err) + } + + query := &Query{ + StartDate: time.Date(2026, 6, 1, 0, 0, 0, 0, location), + EndDate: time.Date(2026, 7, 31, 0, 0, 0, 0, location), + StartDateRaw: "2026-06-01", + EndDateRaw: "2026-07-31", + } + rows := []Row{ + {Module: "Expenses", FarmName: "Farm A", KandangName: "Kandang 1", ActivityDate: time.Date(2026, 6, 1, 0, 0, 0, 0, location), Count: 3}, + {Module: "Expenses", FarmName: "Farm A", KandangName: "Kandang 1", ActivityDate: time.Date(2026, 7, 15, 0, 0, 0, 0, location), Count: 2}, + } + + content, err := BuildWorkbook("Expenses", query, rows) + if err != nil { + t.Fatalf("BuildWorkbook failed: %v", err) + } + + file, err := excelize.OpenReader(bytes.NewReader(content)) + if err != nil { + t.Fatalf("failed opening workbook: %v", err) + } + defer file.Close() + + if got := file.GetSheetName(file.GetActiveSheetIndex()); got != "Expenses" { + t.Fatalf("unexpected sheet name: %s", got) + } + + title, err := file.GetCellValue("Expenses", "A1") + if err != nil { + t.Fatalf("failed reading title: %v", err) + } + if title != "Expenses" { + t.Fatalf("unexpected title: %s", title) + } + + monthTitle, err := file.GetCellValue("Expenses", "A4") + if err != nil { + t.Fatalf("failed reading first month title: %v", err) + } + if monthTitle != "June 2026" { + t.Fatalf("unexpected first month title: %s", monthTitle) + } + + firstCount, err := file.GetCellValue("Expenses", "B9") + if err != nil { + t.Fatalf("failed reading representative count cell: %v", err) + } + if firstCount != "3" { + t.Fatalf("unexpected representative count: %s", firstCount) + } +} diff --git a/internal/modules/expenses/controllers/expense.controller.go b/internal/modules/expenses/controllers/expense.controller.go index 365c46ce..013b97c6 100644 --- a/internal/modules/expenses/controllers/expense.controller.go +++ b/internal/modules/expenses/controllers/expense.controller.go @@ -6,7 +6,9 @@ import ( "math" "strconv" "strings" + "time" + "gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress" m "gitlab.com/mbugroup/lti-api.git/internal/middleware" "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/dto" service "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/services" @@ -29,6 +31,25 @@ func NewExpenseController(expenseService service.ExpenseService) *ExpenseControl } func (u *ExpenseController) GetAll(c *fiber.Ctx) error { + if exportprogress.IsProgressExportRequest(c) { + query, err := exportprogress.ParseQuery(c) + if err != nil { + return err + } + rows, err := u.ExpenseService.GetProgressRows(c, query) + if err != nil { + return err + } + content, err := exportprogress.BuildWorkbook("Expenses", query, rows) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "failed to generate progress excel file") + } + filename := fmt.Sprintf("expenses_progress_%s.xlsx", time.Now().Format("20060102_150405")) + c.Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) + return c.Status(fiber.StatusOK).Send(content) + } + query := &validation.Query{ Page: c.QueryInt("page", 1), Limit: c.QueryInt("limit", 10), diff --git a/internal/modules/expenses/repositories/expense.repository.go b/internal/modules/expenses/repositories/expense.repository.go index 8796c761..08da6c27 100644 --- a/internal/modules/expenses/repositories/expense.repository.go +++ b/internal/modules/expenses/repositories/expense.repository.go @@ -6,6 +6,7 @@ import ( "fmt" "time" + "gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress" "gitlab.com/mbugroup/lti-api.git/internal/common/repository" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" "gitlab.com/mbugroup/lti-api.git/internal/utils" @@ -20,6 +21,7 @@ type ExpenseRepository interface { WithProjectFlockKandangFilter(pfkID, kandangID uint) func(*gorm.DB) *gorm.DB CountUnfinishedByProjectFlockKandang(ctx context.Context, pfkID, kandangID uint, isFinished func(*entity.Approval) bool) (int64, error) DeleteOne(ctx context.Context, id uint) error + GetProgressRows(ctx context.Context, startDate, endDate time.Time, allowedLocationIDs []uint, restrict bool) ([]exportprogress.Row, error) } type ExpenseRepositoryImpl struct { @@ -130,3 +132,64 @@ func (r *ExpenseRepositoryImpl) DeleteOne(ctx context.Context, id uint) error { } return nil } + +func (r *ExpenseRepositoryImpl) GetProgressRows(ctx context.Context, startDate, endDate time.Time, allowedLocationIDs []uint, restrict bool) ([]exportprogress.Row, error) { + const unassignedSQL = "'" + exportprogress.UnassignedKandangName + "'" + query := r.DB().WithContext(ctx). + Table("expenses AS e"). + Select(` + 'Expenses' AS module, + COALESCE(pf.flock_name, loc.name, fallback_loc.name, 'Unknown Farm') AS farm_name, + COALESCE(k.name, `+unassignedSQL+`, 'Unknown Kandang') AS kandang_name, + DATE(e.transaction_date) AS activity_date, + COUNT(*) AS count + `). + Joins("LEFT JOIN (SELECT DISTINCT expense_id, project_flock_kandang_id, kandang_id FROM expense_nonstocks) en ON en.expense_id = e.id"). + Joins("LEFT JOIN project_flock_kandangs pfk ON pfk.id = en.project_flock_kandang_id"). + Joins("LEFT JOIN project_flocks pf ON pf.id = pfk.project_flock_id"). + Joins("LEFT JOIN kandangs k ON k.id = COALESCE(en.kandang_id, pfk.kandang_id)"). + Joins("LEFT JOIN locations loc ON loc.id = k.location_id"). + Joins("LEFT JOIN locations fallback_loc ON fallback_loc.id = e.location_id"). + Where("e.deleted_at IS NULL"). + Where("DATE(e.transaction_date) >= DATE(?)", startDate). + Where("DATE(e.transaction_date) <= DATE(?)", endDate) + + if restrict { + if len(allowedLocationIDs) == 0 { + return []exportprogress.Row{}, nil + } + query = query.Where("e.location_id IN ?", allowedLocationIDs) + } + + type progressRowResult struct { + Module string + FarmName string + KandangName string + ActivityDate string + Count int + } + scanned := make([]progressRowResult, 0) + err := query. + Group("DATE(e.transaction_date), COALESCE(pf.flock_name, loc.name, fallback_loc.name, 'Unknown Farm'), COALESCE(k.name, " + unassignedSQL + ", 'Unknown Kandang')"). + Order("activity_date ASC, farm_name ASC, kandang_name ASC"). + Scan(&scanned).Error + if err != nil { + return nil, err + } + + rows := make([]exportprogress.Row, 0, len(scanned)) + for _, item := range scanned { + activityDate, err := time.Parse("2006-01-02", item.ActivityDate) + if err != nil { + return nil, err + } + rows = append(rows, exportprogress.Row{ + Module: item.Module, + FarmName: item.FarmName, + KandangName: item.KandangName, + ActivityDate: activityDate, + Count: item.Count, + }) + } + return rows, nil +} diff --git a/internal/modules/expenses/repositories/expense_progress_test.go b/internal/modules/expenses/repositories/expense_progress_test.go new file mode 100644 index 00000000..e93d3061 --- /dev/null +++ b/internal/modules/expenses/repositories/expense_progress_test.go @@ -0,0 +1,72 @@ +package repository + +import ( + "context" + "testing" + "time" + + "gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" +) + +func TestExpenseRepositoryGetProgressRows(t *testing.T) { + db := openExpenseProgressTestDB(t) + repo := NewExpenseRepository(db) + + mustExec(t, db, `CREATE TABLE locations (id INTEGER PRIMARY KEY, name TEXT)`) + mustExec(t, db, `CREATE TABLE project_flocks (id INTEGER PRIMARY KEY, flock_name TEXT)`) + mustExec(t, db, `CREATE TABLE kandangs (id INTEGER PRIMARY KEY, name TEXT, location_id INTEGER)`) + mustExec(t, db, `CREATE TABLE project_flock_kandangs (id INTEGER PRIMARY KEY, project_flock_id INTEGER, kandang_id INTEGER)`) + mustExec(t, db, `CREATE TABLE expenses (id INTEGER PRIMARY KEY, location_id INTEGER, transaction_date DATE, deleted_at DATETIME)`) + mustExec(t, db, `CREATE TABLE expense_nonstocks (id INTEGER PRIMARY KEY, expense_id INTEGER, project_flock_kandang_id INTEGER, kandang_id INTEGER)`) + + mustExec(t, db, `INSERT INTO locations (id, name) VALUES (1, 'Farm Location')`) + mustExec(t, db, `INSERT INTO project_flocks (id, flock_name) VALUES (1, 'Farm A')`) + mustExec(t, db, `INSERT INTO kandangs (id, name, location_id) VALUES (1, 'Kandang 1', 1), (2, 'Kandang 2', 1)`) + mustExec(t, db, `INSERT INTO project_flock_kandangs (id, project_flock_id, kandang_id) VALUES (1, 1, 1), (2, 1, 2)`) + mustExec(t, db, `INSERT INTO expenses (id, location_id, transaction_date, deleted_at) VALUES (1, 1, '2026-06-10', NULL), (2, 1, '2026-06-10', NULL)`) + mustExec(t, db, `INSERT INTO expense_nonstocks (id, expense_id, project_flock_kandang_id, kandang_id) VALUES + (1, 1, 1, NULL), + (2, 1, 1, NULL), + (3, 1, 2, NULL)`) + + rows, err := repo.GetProgressRows(context.Background(), time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC), time.Date(2026, 6, 30, 0, 0, 0, 0, time.UTC), nil, false) + if err != nil { + t.Fatalf("GetProgressRows failed: %v", err) + } + + if len(rows) != 3 { + t.Fatalf("expected 3 grouped rows, got %d", len(rows)) + } + assertProgressRow(t, rows, "Farm A", "Kandang 1", "2026-06-10", 1) + assertProgressRow(t, rows, "Farm A", "Kandang 2", "2026-06-10", 1) + assertProgressRow(t, rows, "Farm Location", "Farm-level / Unassigned", "2026-06-10", 1) +} + +func openExpenseProgressTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{}) + if err != nil { + t.Fatalf("failed opening sqlite db: %v", err) + } + return db +} + +func mustExec(t *testing.T, db *gorm.DB, query string, args ...any) { + t.Helper() + if err := db.Exec(query, args...).Error; err != nil { + t.Fatalf("exec failed for %q: %v", query, err) + } +} + +func assertProgressRow(t *testing.T, rows []exportprogress.Row, farm, kandang, date string, count int) { + t.Helper() + for _, row := range rows { + if row.FarmName == farm && row.KandangName == kandang && row.ActivityDate.Format("2006-01-02") == date && row.Count == count { + return + } + } + t.Fatalf("expected row farm=%s kandang=%s date=%s count=%d, got %+v", farm, kandang, date, count, rows) +} diff --git a/internal/modules/expenses/services/expense.service.go b/internal/modules/expenses/services/expense.service.go index 082e2dd6..c410fbd0 100644 --- a/internal/modules/expenses/services/expense.service.go +++ b/internal/modules/expenses/services/expense.service.go @@ -7,6 +7,7 @@ import ( "fmt" "time" + "gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" @@ -38,6 +39,7 @@ type ExpenseService interface { DeleteDocument(ctx *fiber.Ctx, expenseID uint, documentID uint64, isRealization bool) error Approval(ctx *fiber.Ctx, req *validation.ApprovalRequest, approvalType string) ([]expenseDto.ExpenseDetailDTO, error) BulkApproveToStatus(ctx *fiber.Ctx, req *validation.BulkApprovalRequest, target approvalutils.ApprovalStep) ([]expenseDto.ExpenseDetailDTO, error) + GetProgressRows(ctx *fiber.Ctx, query *exportprogress.Query) ([]exportprogress.Row, error) } type expenseService struct { @@ -156,6 +158,14 @@ func (s expenseService) GetOne(c *fiber.Ctx, id uint) (*expenseDto.ExpenseDetail return &responseDTO, nil } +func (s expenseService) GetProgressRows(c *fiber.Ctx, query *exportprogress.Query) ([]exportprogress.Row, error) { + locationScope, err := middleware.ResolveLocationScope(c, s.Repository.DB()) + if err != nil { + return nil, err + } + return s.Repository.GetProgressRows(c.Context(), query.StartDate, query.EndDate, locationScope.IDs, locationScope.Restrict) +} + func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expenseDto.ExpenseDetailDTO, error) { if err := s.Validate.Struct(req); err != nil { return nil, err diff --git a/internal/modules/marketing/controllers/deliveryorder.controller.go b/internal/modules/marketing/controllers/deliveryorder.controller.go index 6fb5c8fc..04323bd9 100644 --- a/internal/modules/marketing/controllers/deliveryorder.controller.go +++ b/internal/modules/marketing/controllers/deliveryorder.controller.go @@ -1,10 +1,13 @@ package controller import ( + "fmt" "math" "strconv" "strings" + "time" + "gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress" m "gitlab.com/mbugroup/lti-api.git/internal/middleware" "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/dto" service "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/services" @@ -27,6 +30,25 @@ func NewDeliveryOrdersController(deliveryOrdersService service.DeliveryOrdersSer } func (u *DeliveryOrdersController) GetAll(c *fiber.Ctx) error { + if exportprogress.IsProgressExportRequest(c) { + query, err := exportprogress.ParseQuery(c) + if err != nil { + return err + } + rows, err := u.DeliveryOrdersService.GetProgressRows(c, query) + if err != nil { + return err + } + content, err := exportprogress.BuildWorkbook("Marketings", query, rows) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "failed to generate progress excel file") + } + filename := fmt.Sprintf("marketings_progress_%s.xlsx", time.Now().Format("20060102_150405")) + c.Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) + return c.Status(fiber.StatusOK).Send(content) + } + parseUintListParam := func(param string) ([]uint, error) { if param == "" { return nil, nil diff --git a/internal/modules/marketing/repositories/marketing_progress_test.go b/internal/modules/marketing/repositories/marketing_progress_test.go new file mode 100644 index 00000000..f50fce9c --- /dev/null +++ b/internal/modules/marketing/repositories/marketing_progress_test.go @@ -0,0 +1,75 @@ +package repository + +import ( + "context" + "testing" + "time" + + "gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" +) + +func TestMarketingRepositoryGetProgressRows(t *testing.T) { + db := openMarketingProgressTestDB(t) + repo := NewMarketingRepository(db) + + mustExecMarketing(t, db, `CREATE TABLE locations (id INTEGER PRIMARY KEY, name TEXT)`) + mustExecMarketing(t, db, `CREATE TABLE project_flocks (id INTEGER PRIMARY KEY, flock_name TEXT)`) + mustExecMarketing(t, db, `CREATE TABLE kandangs (id INTEGER PRIMARY KEY, name TEXT, location_id INTEGER)`) + mustExecMarketing(t, db, `CREATE TABLE project_flock_kandangs (id INTEGER PRIMARY KEY, project_flock_id INTEGER, kandang_id INTEGER)`) + mustExecMarketing(t, db, `CREATE TABLE warehouses (id INTEGER PRIMARY KEY, location_id INTEGER, kandang_id INTEGER)`) + mustExecMarketing(t, db, `CREATE TABLE product_warehouses (id INTEGER PRIMARY KEY, warehouse_id INTEGER, project_flock_kandang_id INTEGER)`) + mustExecMarketing(t, db, `CREATE TABLE marketings (id INTEGER PRIMARY KEY, so_date DATE, deleted_at DATETIME)`) + mustExecMarketing(t, db, `CREATE TABLE marketing_products (id INTEGER PRIMARY KEY, marketing_id INTEGER, product_warehouse_id INTEGER)`) + + mustExecMarketing(t, db, `INSERT INTO locations (id, name) VALUES (1, 'Location A'), (2, 'Location B')`) + mustExecMarketing(t, db, `INSERT INTO project_flocks (id, flock_name) VALUES (1, 'Farm A')`) + mustExecMarketing(t, db, `INSERT INTO kandangs (id, name, location_id) VALUES (1, 'Kandang 1', 1)`) + mustExecMarketing(t, db, `INSERT INTO project_flock_kandangs (id, project_flock_id, kandang_id) VALUES (1, 1, 1)`) + mustExecMarketing(t, db, `INSERT INTO warehouses (id, location_id, kandang_id) VALUES (1, 1, 1), (2, 2, NULL)`) + mustExecMarketing(t, db, `INSERT INTO product_warehouses (id, warehouse_id, project_flock_kandang_id) VALUES (1, 1, 1), (2, 2, NULL)`) + mustExecMarketing(t, db, `INSERT INTO marketings (id, so_date, deleted_at) VALUES (1, '2026-06-12', NULL), (2, '2026-06-12', NULL)`) + mustExecMarketing(t, db, `INSERT INTO marketing_products (id, marketing_id, product_warehouse_id) VALUES + (1, 1, 1), + (2, 1, 1), + (3, 2, 2)`) + + rows, err := repo.GetProgressRows(context.Background(), time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC), time.Date(2026, 6, 30, 0, 0, 0, 0, time.UTC), nil, false) + if err != nil { + t.Fatalf("GetProgressRows failed: %v", err) + } + + if len(rows) != 2 { + t.Fatalf("expected 2 grouped rows, got %d", len(rows)) + } + assertProgressRowMarketing(t, rows, "Farm A", "Kandang 1", "2026-06-12", 1) + assertProgressRowMarketing(t, rows, "Location B", "Farm-level / Unassigned", "2026-06-12", 1) +} + +func openMarketingProgressTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{}) + if err != nil { + t.Fatalf("failed opening sqlite db: %v", err) + } + return db +} + +func mustExecMarketing(t *testing.T, db *gorm.DB, query string, args ...any) { + t.Helper() + if err := db.Exec(query, args...).Error; err != nil { + t.Fatalf("exec failed for %q: %v", query, err) + } +} + +func assertProgressRowMarketing(t *testing.T, rows []exportprogress.Row, farm, kandang, date string, count int) { + t.Helper() + for _, row := range rows { + if row.FarmName == farm && row.KandangName == kandang && row.ActivityDate.Format("2006-01-02") == date && row.Count == count { + return + } + } + t.Fatalf("expected row farm=%s kandang=%s date=%s count=%d, got %+v", farm, kandang, date, count, rows) +} diff --git a/internal/modules/marketing/repositories/salesorder.repository.go b/internal/modules/marketing/repositories/salesorder.repository.go index 51351e55..4053478e 100644 --- a/internal/modules/marketing/repositories/salesorder.repository.go +++ b/internal/modules/marketing/repositories/salesorder.repository.go @@ -3,7 +3,9 @@ package repository import ( "context" "fmt" + "time" + "gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress" "gitlab.com/mbugroup/lti-api.git/internal/common/repository" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" "gorm.io/gorm" @@ -14,6 +16,7 @@ type MarketingRepository interface { IdExists(ctx context.Context, id uint) (bool, error) GetNextSequence(ctx context.Context) (uint, error) NextSoNumber(ctx context.Context, tx *gorm.DB) (string, error) + GetProgressRows(ctx context.Context, startDate, endDate time.Time, allowedLocationIDs []uint, restrict bool) ([]exportprogress.Row, error) } type MarketingRepositoryImpl struct { @@ -55,3 +58,67 @@ func (r *MarketingRepositoryImpl) NextSoNumber(ctx context.Context, tx *gorm.DB) return soNumber, nil } + +func (r *MarketingRepositoryImpl) GetProgressRows(ctx context.Context, startDate, endDate time.Time, allowedLocationIDs []uint, restrict bool) ([]exportprogress.Row, error) { + const unassignedSQL = "'" + exportprogress.UnassignedKandangName + "'" + subQuery := r.DB().WithContext(ctx). + Table("marketings AS m"). + Select(` + DISTINCT m.id AS marketing_id, + 'Marketings' AS module, + COALESCE(pf.flock_name, loc.name, 'Unknown Farm') AS farm_name, + COALESCE(k.name, `+unassignedSQL+`, 'Unknown Kandang') AS kandang_name, + DATE(m.so_date) AS activity_date + `). + Joins("JOIN marketing_products mp ON mp.marketing_id = m.id"). + Joins("JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id"). + Joins("JOIN warehouses w ON w.id = pw.warehouse_id"). + Joins("LEFT JOIN project_flock_kandangs pfk ON pfk.id = pw.project_flock_kandang_id"). + Joins("LEFT JOIN project_flocks pf ON pf.id = pfk.project_flock_id"). + Joins("LEFT JOIN kandangs k ON k.id = COALESCE(pfk.kandang_id, w.kandang_id)"). + Joins("LEFT JOIN locations loc ON loc.id = COALESCE(k.location_id, w.location_id)"). + Where("m.deleted_at IS NULL"). + Where("DATE(m.so_date) >= DATE(?)", startDate). + Where("DATE(m.so_date) <= DATE(?)", endDate) + + if restrict { + if len(allowedLocationIDs) == 0 { + return []exportprogress.Row{}, nil + } + subQuery = subQuery.Where("w.location_id IN ?", allowedLocationIDs) + } + + type progressRowResult struct { + Module string + FarmName string + KandangName string + ActivityDate string + Count int + } + scanned := make([]progressRowResult, 0) + err := r.DB().WithContext(ctx). + Table("(?) AS progress_rows", subQuery). + Select("module, farm_name, kandang_name, activity_date, COUNT(*) AS count"). + Group("module, farm_name, kandang_name, activity_date"). + Order("activity_date ASC, farm_name ASC, kandang_name ASC"). + Scan(&scanned).Error + if err != nil { + return nil, err + } + + rows := make([]exportprogress.Row, 0, len(scanned)) + for _, item := range scanned { + activityDate, err := time.Parse("2006-01-02", item.ActivityDate) + if err != nil { + return nil, err + } + rows = append(rows, exportprogress.Row{ + Module: item.Module, + FarmName: item.FarmName, + KandangName: item.KandangName, + ActivityDate: activityDate, + Count: item.Count, + }) + } + return rows, nil +} diff --git a/internal/modules/marketing/services/deliveryorder.service.go b/internal/modules/marketing/services/deliveryorder.service.go index b49b14ef..692333c4 100644 --- a/internal/modules/marketing/services/deliveryorder.service.go +++ b/internal/modules/marketing/services/deliveryorder.service.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" fifoV2 "gitlab.com/mbugroup/lti-api.git/internal/common/service/fifo_stock_v2" @@ -34,6 +35,7 @@ type DeliveryOrdersService interface { CreateOne(ctx *fiber.Ctx, req *validation.DeliveryOrderCreate) (*dto.MarketingDetailDTO, error) UpdateOne(ctx *fiber.Ctx, req *validation.DeliveryOrderUpdate, id uint) (*dto.MarketingDetailDTO, error) BulkApproveToStatus(ctx *fiber.Ctx, req *validation.BulkApprovalRequest, target approvalutils.ApprovalStep) ([]dto.MarketingDetailDTO, error) + GetProgressRows(ctx *fiber.Ctx, query *exportprogress.Query) ([]exportprogress.Row, error) } type deliveryOrdersService struct { @@ -249,6 +251,14 @@ func (s deliveryOrdersService) GetAll(c *fiber.Ctx, params *validation.DeliveryO return result, total, nil } +func (s deliveryOrdersService) GetProgressRows(c *fiber.Ctx, query *exportprogress.Query) ([]exportprogress.Row, error) { + scope, err := m.ResolveLocationScope(c, s.MarketingRepo.DB()) + if err != nil { + return nil, err + } + return s.MarketingRepo.GetProgressRows(c.Context(), query.StartDate, query.EndDate, scope.IDs, scope.Restrict) +} + func (s deliveryOrdersService) GetOne(c *fiber.Ctx, id uint) (*dto.MarketingDetailDTO, error) { if err := m.EnsureMarketingAccess(c, s.MarketingRepo.DB(), id); err != nil { return nil, err diff --git a/internal/modules/production/recordings/controllers/recording.controller.go b/internal/modules/production/recordings/controllers/recording.controller.go index be26fd44..795874da 100644 --- a/internal/modules/production/recordings/controllers/recording.controller.go +++ b/internal/modules/production/recordings/controllers/recording.controller.go @@ -1,11 +1,13 @@ package controller import ( + "fmt" "math" "strconv" "strings" "time" + "gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress" "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/dto" service "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations" @@ -28,6 +30,25 @@ func (u *RecordingController) GetAll(c *fiber.Ctx) error { projectFlockID := c.QueryInt("project_flock_kandang_id", 0) exportType := strings.TrimSpace(c.Query("export")) + if exportprogress.IsProgressExportRequest(c) { + query, err := exportprogress.ParseQuery(c) + if err != nil { + return err + } + rows, err := u.RecordingService.GetProgressRows(c, query) + if err != nil { + return err + } + content, err := exportprogress.BuildWorkbook("Recordings", query, rows) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "failed to generate progress excel file") + } + filename := fmt.Sprintf("recordings_progress_%s.xlsx", time.Now().Format("20060102_150405")) + c.Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) + return c.Status(fiber.StatusOK).Send(content) + } + page := c.QueryInt("page", 1) limit := c.QueryInt("limit", 10) offset := (page - 1) * limit diff --git a/internal/modules/production/recordings/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go index 1f01f50b..b14ad489 100644 --- a/internal/modules/production/recordings/repositories/recording.repository.go +++ b/internal/modules/production/recordings/repositories/recording.repository.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress" "gitlab.com/mbugroup/lti-api.git/internal/common/repository" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" "gorm.io/gorm" @@ -74,6 +75,7 @@ type RecordingRepository interface { GetProjectFlockKandangIDsByPopulationWarehouseIDs(ctx context.Context, tx *gorm.DB, productWarehouseIDs []uint) ([]uint, error) ResyncProjectFlockPopulationUsage(ctx context.Context, tx *gorm.DB, projectFlockKandangID uint) error ValidateProductWarehousesByFlags(ctx context.Context, ids []uint, flags []string) (uint, error) + GetProgressRows(ctx context.Context, startDate, endDate time.Time, allowedLocationIDs []uint, restrict bool) ([]exportprogress.Row, error) } type RecordingRepositoryImpl struct { @@ -250,6 +252,65 @@ func (r *RecordingRepositoryImpl) GetLatestByProjectFlockKandangID(ctx context.C return &record, nil } +func (r *RecordingRepositoryImpl) GetProgressRows(ctx context.Context, startDate, endDate time.Time, allowedLocationIDs []uint, restrict bool) ([]exportprogress.Row, error) { + const unassignedSQL = "'" + exportprogress.UnassignedKandangName + "'" + query := r.DB().WithContext(ctx). + Table("recordings AS r"). + Select(` + 'Recordings' AS module, + COALESCE(pf.flock_name, loc.name, 'Unknown Farm') AS farm_name, + COALESCE(k.name, `+unassignedSQL+`, 'Unknown Kandang') AS kandang_name, + DATE(r.record_datetime) AS activity_date, + COUNT(*) AS count + `). + Joins("JOIN project_flock_kandangs pfk ON pfk.id = r.project_flock_kandangs_id"). + Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id"). + Joins("JOIN kandangs k ON k.id = pfk.kandang_id"). + Joins("LEFT JOIN locations loc ON loc.id = k.location_id"). + Where("r.deleted_at IS NULL"). + Where("DATE(r.record_datetime) >= DATE(?)", startDate). + Where("DATE(r.record_datetime) <= DATE(?)", endDate) + + if restrict { + if len(allowedLocationIDs) == 0 { + return []exportprogress.Row{}, nil + } + query = query.Where("pf.location_id IN ?", allowedLocationIDs) + } + + type progressRowResult struct { + Module string + FarmName string + KandangName string + ActivityDate string + Count int + } + scanned := make([]progressRowResult, 0) + err := query. + Group("DATE(r.record_datetime), COALESCE(pf.flock_name, loc.name, 'Unknown Farm'), COALESCE(k.name, " + unassignedSQL + ", 'Unknown Kandang')"). + Order("activity_date ASC, farm_name ASC, kandang_name ASC"). + Scan(&scanned).Error + if err != nil { + return nil, err + } + + rows := make([]exportprogress.Row, 0, len(scanned)) + for _, item := range scanned { + activityDate, err := time.Parse("2006-01-02", item.ActivityDate) + if err != nil { + return nil, err + } + rows = append(rows, exportprogress.Row{ + Module: item.Module, + FarmName: item.FarmName, + KandangName: item.KandangName, + ActivityDate: activityDate, + Count: item.Count, + }) + } + return rows, nil +} + func (r *RecordingRepositoryImpl) ListByProjectFlockKandangID(ctx context.Context, tx *gorm.DB, projectFlockKandangId uint, from *time.Time) ([]entity.Recording, error) { if projectFlockKandangId == 0 { return nil, errors.New("project_flock_kandang_id is required") diff --git a/internal/modules/production/recordings/repositories/recording_progress_test.go b/internal/modules/production/recordings/repositories/recording_progress_test.go new file mode 100644 index 00000000..1daecef6 --- /dev/null +++ b/internal/modules/production/recordings/repositories/recording_progress_test.go @@ -0,0 +1,68 @@ +package repository + +import ( + "context" + "testing" + "time" + + "gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" +) + +func TestRecordingRepositoryGetProgressRows(t *testing.T) { + db := openRecordingProgressTestDB(t) + repo := NewRecordingRepository(db) + + mustExecRecording(t, db, `CREATE TABLE locations (id INTEGER PRIMARY KEY, name TEXT)`) + mustExecRecording(t, db, `CREATE TABLE project_flocks (id INTEGER PRIMARY KEY, flock_name TEXT, location_id INTEGER)`) + mustExecRecording(t, db, `CREATE TABLE kandangs (id INTEGER PRIMARY KEY, name TEXT, location_id INTEGER)`) + mustExecRecording(t, db, `CREATE TABLE project_flock_kandangs (id INTEGER PRIMARY KEY, project_flock_id INTEGER, kandang_id INTEGER)`) + mustExecRecording(t, db, `CREATE TABLE recordings (id INTEGER PRIMARY KEY, project_flock_kandangs_id INTEGER, record_datetime DATETIME, deleted_at DATETIME)`) + + mustExecRecording(t, db, `INSERT INTO locations (id, name) VALUES (1, 'Location A')`) + mustExecRecording(t, db, `INSERT INTO project_flocks (id, flock_name, location_id) VALUES (1, 'Farm A', 1)`) + mustExecRecording(t, db, `INSERT INTO kandangs (id, name, location_id) VALUES (1, 'Kandang 1', 1)`) + mustExecRecording(t, db, `INSERT INTO project_flock_kandangs (id, project_flock_id, kandang_id) VALUES (1, 1, 1)`) + mustExecRecording(t, db, `INSERT INTO recordings (id, project_flock_kandangs_id, record_datetime, deleted_at) VALUES + (1, 1, '2026-06-03 08:00:00', NULL), + (2, 1, '2026-06-03 10:00:00', NULL), + (3, 1, '2026-07-01 08:00:00', NULL)`) + + rows, err := repo.GetProgressRows(context.Background(), time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC), time.Date(2026, 6, 30, 0, 0, 0, 0, time.UTC), nil, false) + if err != nil { + t.Fatalf("GetProgressRows failed: %v", err) + } + + if len(rows) != 1 { + t.Fatalf("expected 1 grouped row, got %d", len(rows)) + } + assertProgressRowRecording(t, rows, "Farm A", "Kandang 1", "2026-06-03", 2) +} + +func openRecordingProgressTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{}) + if err != nil { + t.Fatalf("failed opening sqlite db: %v", err) + } + return db +} + +func mustExecRecording(t *testing.T, db *gorm.DB, query string, args ...any) { + t.Helper() + if err := db.Exec(query, args...).Error; err != nil { + t.Fatalf("exec failed for %q: %v", query, err) + } +} + +func assertProgressRowRecording(t *testing.T, rows []exportprogress.Row, farm, kandang, date string, count int) { + t.Helper() + for _, row := range rows { + if row.FarmName == farm && row.KandangName == kandang && row.ActivityDate.Format("2006-01-02") == date && row.Count == count { + return + } + } + t.Fatalf("expected row farm=%s kandang=%s date=%s count=%d, got %+v", farm, kandang, date, count, rows) +} diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index d3221872..8d9f5e1b 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" fifoV2 "gitlab.com/mbugroup/lti-api.git/internal/common/service/fifo_stock_v2" @@ -42,6 +43,7 @@ type RecordingService interface { UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Recording, error) DeleteOne(ctx *fiber.Ctx, id uint) error Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.Recording, error) + GetProgressRows(ctx *fiber.Ctx, query *exportprogress.Query) ([]exportprogress.Row, error) } type recordingService struct { @@ -202,6 +204,14 @@ func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti return recordings, total, nil } +func (s recordingService) GetProgressRows(c *fiber.Ctx, query *exportprogress.Query) ([]exportprogress.Row, error) { + scope, err := m.ResolveLocationScope(c, s.Repository.DB()) + if err != nil { + return nil, err + } + return s.Repository.GetProgressRows(c.Context(), query.StartDate, query.EndDate, scope.IDs, scope.Restrict) +} + func (s recordingService) GetOne(c *fiber.Ctx, id uint) (*entity.Recording, error) { if err := m.EnsureRecordingAccess(c, s.Repository.DB(), id); err != nil { return nil, err diff --git a/internal/modules/purchases/controllers/purchase.controller.go b/internal/modules/purchases/controllers/purchase.controller.go index 985349f0..c252e65e 100644 --- a/internal/modules/purchases/controllers/purchase.controller.go +++ b/internal/modules/purchases/controllers/purchase.controller.go @@ -7,7 +7,9 @@ import ( "mime/multipart" "strconv" "strings" + "time" + "gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress" "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/dto" service "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/services" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/validations" @@ -27,6 +29,25 @@ func NewPurchaseController(s service.PurchaseService) *PurchaseController { } func (ctrl *PurchaseController) GetAll(c *fiber.Ctx) error { + if exportprogress.IsProgressExportRequest(c) { + query, err := exportprogress.ParseQuery(c) + if err != nil { + return err + } + rows, err := ctrl.service.GetProgressRows(c, query) + if err != nil { + return err + } + content, err := exportprogress.BuildWorkbook("Purchases", query, rows) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "failed to generate progress excel file") + } + filename := fmt.Sprintf("purchases_progress_%s.xlsx", time.Now().Format("20060102_150405")) + c.Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) + return c.Status(fiber.StatusOK).Send(content) + } + query := &validation.Query{ Page: c.QueryInt("page", 1), Limit: c.QueryInt("limit", 10), diff --git a/internal/modules/purchases/repositories/purchase.repository.go b/internal/modules/purchases/repositories/purchase.repository.go index 9fdc9ebf..6d8de9c2 100644 --- a/internal/modules/purchases/repositories/purchase.repository.go +++ b/internal/modules/purchases/repositories/purchase.repository.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress" "gitlab.com/mbugroup/lti-api.git/internal/common/repository" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" utils "gitlab.com/mbugroup/lti-api.git/internal/utils" @@ -28,6 +29,7 @@ type PurchaseRepository interface { SoftDeleteByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) error GetItemsByProjectFlockID(ctx context.Context, projectFlockID uint) ([]entity.PurchaseItem, error) GetItemsByWarehouseKandang(ctx context.Context, projectFlockID uint) ([]entity.PurchaseItem, error) + GetProgressRows(ctx context.Context, startDate, endDate time.Time, allowedLocationIDs []uint, restrict bool) ([]exportprogress.Row, error) } type PurchaseRepositoryImpl struct { @@ -284,6 +286,75 @@ func (r *PurchaseRepositoryImpl) UpdateReceivingDetails( return nil } +func (r *PurchaseRepositoryImpl) GetProgressRows(ctx context.Context, startDate, endDate time.Time, allowedLocationIDs []uint, restrict bool) ([]exportprogress.Row, error) { + const unassignedSQL = "'" + exportprogress.UnassignedKandangName + "'" + subQuery := r.DB().WithContext(ctx). + Table("purchases AS p"). + Select(` + DISTINCT p.id AS purchase_id, + 'Purchases' AS module, + COALESCE(pf_explicit.flock_name, pf_active.flock_name, kandang_loc.name, warehouse_loc.name, 'Unknown Farm') AS farm_name, + COALESCE(k_explicit.name, k_active.name, wk.name, `+unassignedSQL+`, 'Unknown Kandang') AS kandang_name, + DATE(p.po_date) AS activity_date + `). + Joins("JOIN purchase_items pi ON pi.purchase_id = p.id"). + Joins("JOIN warehouses w ON w.id = pi.warehouse_id"). + Joins("LEFT JOIN project_flock_kandangs pfk_explicit ON pfk_explicit.id = pi.project_flock_kandang_id"). + Joins("LEFT JOIN project_flocks pf_explicit ON pf_explicit.id = pfk_explicit.project_flock_id"). + Joins("LEFT JOIN kandangs k_explicit ON k_explicit.id = pfk_explicit.kandang_id"). + Joins("LEFT JOIN project_flock_kandangs pfk_active ON pfk_active.kandang_id = w.kandang_id AND pfk_active.closed_at IS NULL"). + Joins("LEFT JOIN project_flocks pf_active ON pf_active.id = pfk_active.project_flock_id"). + Joins("LEFT JOIN kandangs k_active ON k_active.id = pfk_active.kandang_id"). + Joins("LEFT JOIN kandangs wk ON wk.id = w.kandang_id"). + Joins("LEFT JOIN locations kandang_loc ON kandang_loc.id = COALESCE(k_explicit.location_id, k_active.location_id, wk.location_id)"). + Joins("LEFT JOIN locations warehouse_loc ON warehouse_loc.id = w.location_id"). + Where("p.deleted_at IS NULL"). + Where("p.po_date IS NOT NULL"). + Where("DATE(p.po_date) >= DATE(?)", startDate). + Where("DATE(p.po_date) <= DATE(?)", endDate) + + if restrict { + if len(allowedLocationIDs) == 0 { + return []exportprogress.Row{}, nil + } + subQuery = subQuery.Where("w.location_id IN ?", allowedLocationIDs) + } + + type progressRowResult struct { + Module string + FarmName string + KandangName string + ActivityDate string + Count int + } + scanned := make([]progressRowResult, 0) + err := r.DB().WithContext(ctx). + Table("(?) AS progress_rows", subQuery). + Select("module, farm_name, kandang_name, activity_date, COUNT(*) AS count"). + Group("module, farm_name, kandang_name, activity_date"). + Order("activity_date ASC, farm_name ASC, kandang_name ASC"). + Scan(&scanned).Error + if err != nil { + return nil, err + } + + rows := make([]exportprogress.Row, 0, len(scanned)) + for _, item := range scanned { + activityDate, err := time.Parse("2006-01-02", item.ActivityDate) + if err != nil { + return nil, err + } + rows = append(rows, exportprogress.Row{ + Module: item.Module, + FarmName: item.FarmName, + KandangName: item.KandangName, + ActivityDate: activityDate, + Count: item.Count, + }) + } + return rows, nil +} + func (r *PurchaseRepositoryImpl) DeleteItems(ctx context.Context, purchaseID uint, itemIDs []uint) error { if len(itemIDs) == 0 { return errors.New("itemIDs cannot be empty") diff --git a/internal/modules/purchases/repositories/purchase_progress_test.go b/internal/modules/purchases/repositories/purchase_progress_test.go new file mode 100644 index 00000000..a36350d0 --- /dev/null +++ b/internal/modules/purchases/repositories/purchase_progress_test.go @@ -0,0 +1,74 @@ +package repositories + +import ( + "context" + "testing" + "time" + + "gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" +) + +func TestPurchaseRepositoryGetProgressRows(t *testing.T) { + db := openPurchaseProgressTestDB(t) + repo := NewPurchaseRepository(db) + + mustExecPurchase(t, db, `CREATE TABLE locations (id INTEGER PRIMARY KEY, name TEXT)`) + mustExecPurchase(t, db, `CREATE TABLE project_flocks (id INTEGER PRIMARY KEY, flock_name TEXT)`) + mustExecPurchase(t, db, `CREATE TABLE kandangs (id INTEGER PRIMARY KEY, name TEXT, location_id INTEGER)`) + mustExecPurchase(t, db, `CREATE TABLE project_flock_kandangs (id INTEGER PRIMARY KEY, project_flock_id INTEGER, kandang_id INTEGER, closed_at DATETIME)`) + mustExecPurchase(t, db, `CREATE TABLE warehouses (id INTEGER PRIMARY KEY, location_id INTEGER, kandang_id INTEGER)`) + mustExecPurchase(t, db, `CREATE TABLE purchases (id INTEGER PRIMARY KEY, po_date DATE, deleted_at DATETIME)`) + mustExecPurchase(t, db, `CREATE TABLE purchase_items (id INTEGER PRIMARY KEY, purchase_id INTEGER, warehouse_id INTEGER, project_flock_kandang_id INTEGER)`) + + mustExecPurchase(t, db, `INSERT INTO locations (id, name) VALUES (1, 'Location A'), (2, 'Location B')`) + mustExecPurchase(t, db, `INSERT INTO project_flocks (id, flock_name) VALUES (1, 'Farm A')`) + mustExecPurchase(t, db, `INSERT INTO kandangs (id, name, location_id) VALUES (1, 'Kandang 1', 1)`) + mustExecPurchase(t, db, `INSERT INTO project_flock_kandangs (id, project_flock_id, kandang_id, closed_at) VALUES (1, 1, 1, NULL)`) + mustExecPurchase(t, db, `INSERT INTO warehouses (id, location_id, kandang_id) VALUES (1, 1, 1), (2, 2, NULL)`) + mustExecPurchase(t, db, `INSERT INTO purchases (id, po_date, deleted_at) VALUES (1, '2026-06-05', NULL), (2, '2026-06-05', NULL), (3, NULL, NULL)`) + mustExecPurchase(t, db, `INSERT INTO purchase_items (id, purchase_id, warehouse_id, project_flock_kandang_id) VALUES + (1, 1, 1, 1), + (2, 1, 1, 1), + (3, 2, 2, NULL), + (4, 3, 1, 1)`) + + rows, err := repo.GetProgressRows(context.Background(), time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC), time.Date(2026, 6, 30, 0, 0, 0, 0, time.UTC), nil, false) + if err != nil { + t.Fatalf("GetProgressRows failed: %v", err) + } + + if len(rows) != 2 { + t.Fatalf("expected 2 grouped rows, got %d", len(rows)) + } + assertProgressRowPurchase(t, rows, "Farm A", "Kandang 1", "2026-06-05", 1) + assertProgressRowPurchase(t, rows, "Location B", "Farm-level / Unassigned", "2026-06-05", 1) +} + +func openPurchaseProgressTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{}) + if err != nil { + t.Fatalf("failed opening sqlite db: %v", err) + } + return db +} + +func mustExecPurchase(t *testing.T, db *gorm.DB, query string, args ...any) { + t.Helper() + if err := db.Exec(query, args...).Error; err != nil { + t.Fatalf("exec failed for %q: %v", query, err) + } +} + +func assertProgressRowPurchase(t *testing.T, rows []exportprogress.Row, farm, kandang, date string, count int) { + t.Helper() + for _, row := range rows { + if row.FarmName == farm && row.KandangName == kandang && row.ActivityDate.Format("2006-01-02") == date && row.Count == count { + return + } + } + t.Fatalf("expected row farm=%s kandang=%s date=%s count=%d, got %+v", farm, kandang, date, count, rows) +} diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index ec8617b8..486144c5 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" @@ -43,6 +44,7 @@ type PurchaseService interface { ReceiveProducts(ctx *fiber.Ctx, id uint, req *validation.ReceivePurchaseRequest) (*entity.Purchase, error) DeleteItems(ctx *fiber.Ctx, id uint, req *validation.DeletePurchaseItemsRequest) (*entity.Purchase, error) DeletePurchase(ctx *fiber.Ctx, id uint) error + GetProgressRows(ctx *fiber.Ctx, query *exportprogress.Query) ([]exportprogress.Row, error) } const ( @@ -446,6 +448,14 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti return purchases, total, nil } +func (s *purchaseService) GetProgressRows(c *fiber.Ctx, query *exportprogress.Query) ([]exportprogress.Row, error) { + scope, err := m.ResolveLocationScope(c, s.PurchaseRepo.DB()) + if err != nil { + return nil, err + } + return s.PurchaseRepo.GetProgressRows(c.Context(), query.StartDate, query.EndDate, scope.IDs, scope.Restrict) +} + func (s *purchaseService) GetOne(c *fiber.Ctx, id uint) (*entity.Purchase, error) { scope, err := m.ResolveLocationScope(c, s.PurchaseRepo.DB()) if err != nil {