mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-05-20 05:21:57 +00:00
Merge branch 'codex/export-progress' into 'development'
feat: export input progress report for expenses, marketings, purchases, and recordings See merge request mbugroup/lti-api!431
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user