mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-05-20 21:41:55 +00:00
Merge branch 'development' into 'production'
Development See merge request mbugroup/lti-api!436
This commit is contained in:
@@ -30,3 +30,4 @@ coverage/
|
||||
.idea/
|
||||
*.swp
|
||||
.DS_Store
|
||||
.gemini/
|
||||
|
||||
@@ -0,0 +1,604 @@
|
||||
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 ParseActivityDate(value string) (time.Time, error) {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return time.Time{}, fmt.Errorf("empty activity date")
|
||||
}
|
||||
|
||||
layouts := []string{
|
||||
"2006-01-02",
|
||||
time.RFC3339,
|
||||
time.RFC3339Nano,
|
||||
"2006-01-02 15:04:05Z07:00",
|
||||
"2006-01-02 15:04:05.999999999Z07:00",
|
||||
}
|
||||
for _, layout := range layouts {
|
||||
if parsed, err := time.Parse(layout, trimmed); err == nil {
|
||||
return parsed, nil
|
||||
}
|
||||
}
|
||||
|
||||
if len(trimmed) >= len("2006-01-02") {
|
||||
if parsed, err := time.Parse("2006-01-02", trimmed[:10]); err == nil {
|
||||
return parsed, nil
|
||||
}
|
||||
}
|
||||
|
||||
return time.Time{}, fmt.Errorf("unsupported activity date format: %s", value)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -151,7 +151,7 @@ func (r *HppRepositoryImpl) GetFeedUsageCost(ctx context.Context, projectFlockKa
|
||||
).
|
||||
Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase).
|
||||
Joins("LEFT JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", stockableAdjustment).
|
||||
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
|
||||
Where("rs.project_flock_kandang_id IN (?)", projectFlockKandangIDs).
|
||||
Where("r.record_datetime <= ?", *date).
|
||||
Where("f.name = ?", utils.FlagPakan).
|
||||
Scan(&total).Error
|
||||
@@ -202,7 +202,7 @@ func (r *HppRepositoryImpl) GetOvkUsageCost(ctx context.Context, projectFlockKan
|
||||
).
|
||||
Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase).
|
||||
Joins("LEFT JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", stockableAdjustment).
|
||||
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
|
||||
Where("rs.project_flock_kandang_id IN (?)", projectFlockKandangIDs).
|
||||
Where("r.record_datetime <= ?", *date).
|
||||
Where("EXISTS (SELECT 1 FROM flags f WHERE f.flagable_id = pw.product_id AND f.flagable_type = ? AND f.name IN ?)", entity.FlagableTypeProduct, flags).
|
||||
Scan(&total).Error
|
||||
|
||||
@@ -103,6 +103,7 @@ type HppV2CostRepository interface {
|
||||
GetProjectFlockKandangIDs(ctx context.Context, projectFlockId uint) ([]uint, error)
|
||||
GetLatestTransferInputByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint, period time.Time) (*HppV2LatestTransferInputRow, error)
|
||||
GetManualDepreciationInputByProjectFlockID(ctx context.Context, projectFlockID uint) (*HppV2ManualDepreciationInputRow, error)
|
||||
GetRecordingStockRoutingAdjustmentCostByProjectFlockID(ctx context.Context, projectFlockID uint, periodDate time.Time) (float64, error)
|
||||
GetFarmDepreciationSnapshotByProjectFlockIDAndPeriod(ctx context.Context, projectFlockID uint, periodDate time.Time) (*HppV2FarmDepreciationSnapshotRow, error)
|
||||
GetEarliestChickInDateByProjectFlockID(ctx context.Context, projectFlockID uint) (*time.Time, error)
|
||||
GetDepreciationPercents(ctx context.Context, houseTypes []string, maxDay int) (map[string]map[int]float64, error)
|
||||
@@ -249,6 +250,82 @@ func (r *HppV2RepositoryImpl) GetManualDepreciationInputByProjectFlockID(
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
func (r *HppV2RepositoryImpl) GetRecordingStockRoutingAdjustmentCostByProjectFlockID(
|
||||
ctx context.Context,
|
||||
projectFlockID uint,
|
||||
periodDate time.Time,
|
||||
) (float64, error) {
|
||||
if projectFlockID == 0 || periodDate.IsZero() {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
flags := []utils.FlagType{
|
||||
utils.FlagPakan,
|
||||
utils.FlagOVK,
|
||||
utils.FlagObat,
|
||||
utils.FlagVitamin,
|
||||
utils.FlagKimia,
|
||||
}
|
||||
transferExistsCondition := `
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM laying_transfer_targets AS ltt
|
||||
JOIN laying_transfers AS lt ON lt.id = ltt.laying_transfer_id
|
||||
WHERE ltt.deleted_at IS NULL
|
||||
AND lt.deleted_at IS NULL
|
||||
AND lt.executed_at IS NOT NULL
|
||||
AND ltt.target_project_flock_kandang_id = r.project_flock_kandangs_id
|
||||
AND COALESCE(DATE(lt.effective_move_date), DATE(lt.economic_cutoff_date), DATE(lt.transfer_date)) <= DATE(?)
|
||||
AND (
|
||||
SELECT a.action
|
||||
FROM approvals a
|
||||
WHERE a.approvable_type = ?
|
||||
AND a.approvable_id = lt.id
|
||||
ORDER BY a.id DESC
|
||||
LIMIT 1
|
||||
) = ?
|
||||
)
|
||||
`
|
||||
|
||||
var total float64
|
||||
err := r.db.WithContext(ctx).
|
||||
Table("recording_stocks AS rs").
|
||||
Select("COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0)").
|
||||
Joins("JOIN recordings AS r ON r.id = rs.recording_id AND r.deleted_at IS NULL").
|
||||
Joins("JOIN project_flock_kandangs AS pfk_rec ON pfk_rec.id = r.project_flock_kandangs_id").
|
||||
Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id").
|
||||
Joins(
|
||||
"JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?",
|
||||
fifo.UsableKeyRecordingStock.String(),
|
||||
fifo.StockableKeyPurchaseItems.String(),
|
||||
entity.StockAllocationStatusActive,
|
||||
entity.StockAllocationPurposeConsume,
|
||||
).
|
||||
Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id").
|
||||
Where("pfk_rec.project_flock_id = ?", projectFlockID).
|
||||
Where("DATE(r.record_datetime) <= DATE(?)", periodDate).
|
||||
Where(
|
||||
fmt.Sprintf(
|
||||
"((%s) AND rs.project_flock_kandang_id IS NOT NULL AND rs.project_flock_kandang_id <> r.project_flock_kandangs_id) OR (NOT (%s) AND rs.project_flock_kandang_id IS NULL)",
|
||||
transferExistsCondition,
|
||||
transferExistsCondition,
|
||||
),
|
||||
periodDate,
|
||||
string(utils.ApprovalWorkflowTransferToLaying),
|
||||
entity.ApprovalActionApproved,
|
||||
periodDate,
|
||||
string(utils.ApprovalWorkflowTransferToLaying),
|
||||
entity.ApprovalActionApproved,
|
||||
).
|
||||
Where("EXISTS (SELECT 1 FROM flags f WHERE f.flagable_id = pw.product_id AND f.flagable_type = ? AND f.name IN ?)", entity.FlagableTypeProduct, flags).
|
||||
Scan(&total).Error
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (r *HppV2RepositoryImpl) GetFarmDepreciationSnapshotByProjectFlockIDAndPeriod(
|
||||
ctx context.Context,
|
||||
projectFlockID uint,
|
||||
@@ -393,7 +470,7 @@ func (r *HppV2RepositoryImpl) ListUsageCostRowsByProductFlags(
|
||||
Joins("LEFT JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", stockableAdjustment).
|
||||
Joins("LEFT JOIN product_warehouses AS ast_pw ON ast_pw.id = ast.product_warehouse_id").
|
||||
Joins("LEFT JOIN products AS ast_prod ON ast_prod.id = ast_pw.product_id").
|
||||
Where("r.project_flock_kandangs_id IN ?", projectFlockKandangIDs).
|
||||
Where("rs.project_flock_kandang_id IN ?", projectFlockKandangIDs).
|
||||
Where("r.record_datetime <= ?", *date).
|
||||
Where("EXISTS (SELECT 1 FROM flags f WHERE f.flagable_id = pw.product_id AND f.flagable_type = ? AND f.name IN ?)", entity.FlagableTypeProduct, flagNames).
|
||||
Group(`
|
||||
@@ -755,7 +832,7 @@ func (r *HppV2RepositoryImpl) GetFeedUsageCost(ctx context.Context, projectFlock
|
||||
).
|
||||
Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase).
|
||||
Joins("LEFT JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", stockableAdjustment).
|
||||
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
|
||||
Where("rs.project_flock_kandang_id IN (?)", projectFlockKandangIDs).
|
||||
Where("r.record_datetime <= ?", *date).
|
||||
Where("f.name = ?", utils.FlagPakan).
|
||||
Scan(&total).Error
|
||||
|
||||
@@ -2,6 +2,7 @@ package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -96,6 +97,116 @@ func TestHppV2RepositoryGetEggTerjualProratesHistoricalFarmSalesFromAdjustments(
|
||||
assertFloatEquals(t, totalWeightKg, 1.4)
|
||||
}
|
||||
|
||||
func TestHppV2RepositoryGetRecordingStockRoutingAdjustmentCostByProjectFlockID(t *testing.T) {
|
||||
db := setupHppV2RepositoryTestDB(t)
|
||||
approvalType := utils.ApprovalWorkflowTransferToLaying.String()
|
||||
|
||||
mustExecHppV2(t, db,
|
||||
`INSERT INTO project_flock_kandangs (id, kandang_id, project_flock_id) VALUES
|
||||
(101, 1, 1),
|
||||
(102, 2, 1),
|
||||
(103, 3, 1),
|
||||
(104, 4, 1),
|
||||
(105, 5, 1),
|
||||
(201, 6, 2)`,
|
||||
`INSERT INTO recordings (id, project_flock_kandangs_id, record_datetime, deleted_at) VALUES
|
||||
(1, 101, '2026-04-10 08:00:00', NULL),
|
||||
(2, 101, '2026-04-10 08:05:00', NULL),
|
||||
(3, 101, '2026-04-10 08:10:00', NULL),
|
||||
(4, 102, '2026-04-10 08:15:00', NULL),
|
||||
(5, 102, '2026-04-10 08:20:00', NULL),
|
||||
(6, 103, '2026-04-12 08:00:00', NULL),
|
||||
(7, 103, '2026-04-12 08:05:00', NULL),
|
||||
(8, 104, '2026-04-12 08:10:00', NULL),
|
||||
(9, 104, '2026-04-12 08:15:00', NULL),
|
||||
(10, 105, '2026-04-12 08:20:00', NULL),
|
||||
(11, 105, '2026-04-12 08:25:00', NULL)`,
|
||||
`INSERT INTO product_warehouses (id, warehouse_id, product_id, project_flock_kandang_id) VALUES
|
||||
(501, 201, 10, NULL),
|
||||
(502, 201, 10, NULL),
|
||||
(503, 201, 10, NULL),
|
||||
(504, 201, 10, NULL),
|
||||
(505, 201, 10, NULL),
|
||||
(506, 201, 10, NULL),
|
||||
(507, 201, 10, NULL),
|
||||
(508, 201, 10, NULL),
|
||||
(509, 201, 10, NULL),
|
||||
(510, 201, 10, NULL),
|
||||
(511, 201, 10, NULL)`,
|
||||
`INSERT INTO flags (id, flagable_type, flagable_id, name) VALUES
|
||||
(10, 'products', 10, 'PAKAN')`,
|
||||
`INSERT INTO recording_stocks (id, recording_id, product_warehouse_id, project_flock_kandang_id) VALUES
|
||||
(101, 1, 501, NULL),
|
||||
(102, 2, 502, 201),
|
||||
(103, 3, 503, 101),
|
||||
(104, 4, 504, NULL),
|
||||
(105, 5, 505, 201),
|
||||
(106, 6, 506, NULL),
|
||||
(107, 7, 507, 201),
|
||||
(108, 8, 508, NULL),
|
||||
(109, 9, 509, 201),
|
||||
(110, 10, 510, NULL),
|
||||
(111, 11, 511, 201)`,
|
||||
`INSERT INTO purchase_items (id, product_id, price) VALUES
|
||||
(601, 10, 100),
|
||||
(602, 10, 110),
|
||||
(603, 10, 120),
|
||||
(604, 10, 130),
|
||||
(605, 10, 140),
|
||||
(606, 10, 150),
|
||||
(607, 10, 160),
|
||||
(608, 10, 170),
|
||||
(609, 10, 180),
|
||||
(610, 10, 190),
|
||||
(611, 10, 200)`,
|
||||
`INSERT INTO stock_allocations (id, usable_type, usable_id, stockable_type, stockable_id, status, allocation_purpose, qty) VALUES
|
||||
(9001, 'RECORDING_STOCK', 101, 'PURCHASE_ITEMS', 601, 'ACTIVE', 'CONSUME', 2),
|
||||
(9002, 'RECORDING_STOCK', 102, 'PURCHASE_ITEMS', 602, 'ACTIVE', 'CONSUME', 1),
|
||||
(9003, 'RECORDING_STOCK', 103, 'PURCHASE_ITEMS', 603, 'ACTIVE', 'CONSUME', 1),
|
||||
(9004, 'RECORDING_STOCK', 104, 'PURCHASE_ITEMS', 604, 'ACTIVE', 'CONSUME', 1),
|
||||
(9005, 'RECORDING_STOCK', 105, 'PURCHASE_ITEMS', 605, 'ACTIVE', 'CONSUME', 1),
|
||||
(9006, 'RECORDING_STOCK', 106, 'PURCHASE_ITEMS', 606, 'ACTIVE', 'CONSUME', 1),
|
||||
(9007, 'RECORDING_STOCK', 107, 'PURCHASE_ITEMS', 607, 'ACTIVE', 'CONSUME', 1),
|
||||
(9008, 'RECORDING_STOCK', 108, 'PURCHASE_ITEMS', 608, 'ACTIVE', 'CONSUME', 1),
|
||||
(9009, 'RECORDING_STOCK', 109, 'PURCHASE_ITEMS', 609, 'ACTIVE', 'CONSUME', 1),
|
||||
(9010, 'RECORDING_STOCK', 110, 'PURCHASE_ITEMS', 610, 'ACTIVE', 'CONSUME', 1),
|
||||
(9011, 'RECORDING_STOCK', 111, 'PURCHASE_ITEMS', 611, 'ACTIVE', 'CONSUME', 1)`,
|
||||
`INSERT INTO laying_transfers (id, transfer_date, effective_move_date, economic_cutoff_date, executed_at, deleted_at) VALUES
|
||||
(1001, '2026-04-04', '2026-04-05', NULL, '2026-04-05 00:00:00', NULL),
|
||||
(1002, '2026-05-01', '2026-05-01', NULL, '2026-05-01 00:00:00', NULL),
|
||||
(1003, '2026-04-03', '2026-04-05', NULL, '2026-04-05 00:00:00', NULL),
|
||||
(1004, '2026-04-03', '2026-04-05', NULL, NULL, NULL)`,
|
||||
`INSERT INTO laying_transfer_targets (id, laying_transfer_id, target_project_flock_kandang_id, deleted_at) VALUES
|
||||
(2001, 1001, 101, NULL),
|
||||
(2002, 1002, 103, NULL),
|
||||
(2003, 1003, 104, NULL),
|
||||
(2004, 1004, 105, NULL)`,
|
||||
fmt.Sprintf(`INSERT INTO approvals (id, approvable_type, approvable_id, action) VALUES
|
||||
(3001, '%s', 1001, 'APPROVED'),
|
||||
(3002, '%s', 1002, 'APPROVED'),
|
||||
(3003, '%s', 1003, 'APPROVED'),
|
||||
(3004, '%s', 1003, 'REJECTED'),
|
||||
(3005, '%s', 1004, 'APPROVED')`,
|
||||
approvalType, approvalType, approvalType, approvalType, approvalType),
|
||||
)
|
||||
|
||||
repo := &HppV2RepositoryImpl{db: db}
|
||||
|
||||
periodDate := mustJakartaTime(t, "2026-04-30 00:00:00")
|
||||
total, err := repo.GetRecordingStockRoutingAdjustmentCostByProjectFlockID(context.Background(), 1, periodDate)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
assertFloatEquals(t, total, 750)
|
||||
|
||||
earlyPeriod := mustJakartaTime(t, "2026-04-10 23:59:59")
|
||||
earlyTotal, err := repo.GetRecordingStockRoutingAdjustmentCostByProjectFlockID(context.Background(), 1, earlyPeriod)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
assertFloatEquals(t, earlyTotal, 240)
|
||||
}
|
||||
|
||||
func setupHppV2RepositoryTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
|
||||
@@ -111,6 +222,12 @@ func setupHppV2RepositoryTestDB(t *testing.T) *gorm.DB {
|
||||
record_datetime DATETIME NULL,
|
||||
deleted_at DATETIME NULL
|
||||
)`,
|
||||
`CREATE TABLE recording_stocks (
|
||||
id INTEGER PRIMARY KEY,
|
||||
recording_id INTEGER NULL,
|
||||
product_warehouse_id INTEGER NULL,
|
||||
project_flock_kandang_id INTEGER NULL
|
||||
)`,
|
||||
`CREATE TABLE recording_eggs (
|
||||
id INTEGER PRIMARY KEY,
|
||||
recording_id INTEGER NULL,
|
||||
@@ -174,6 +291,11 @@ func setupHppV2RepositoryTestDB(t *testing.T) *gorm.DB {
|
||||
id INTEGER PRIMARY KEY,
|
||||
product_warehouse_id INTEGER NULL
|
||||
)`,
|
||||
`CREATE TABLE purchase_items (
|
||||
id INTEGER PRIMARY KEY,
|
||||
product_id INTEGER NULL,
|
||||
price NUMERIC(15,3) NULL
|
||||
)`,
|
||||
`CREATE TABLE marketing_delivery_products (
|
||||
id INTEGER PRIMARY KEY,
|
||||
marketing_product_id INTEGER NULL,
|
||||
@@ -187,6 +309,26 @@ func setupHppV2RepositoryTestDB(t *testing.T) *gorm.DB {
|
||||
flagable_id INTEGER NULL,
|
||||
name TEXT NULL
|
||||
)`,
|
||||
`CREATE TABLE laying_transfers (
|
||||
id INTEGER PRIMARY KEY,
|
||||
transfer_date DATETIME NULL,
|
||||
effective_move_date DATETIME NULL,
|
||||
economic_cutoff_date DATETIME NULL,
|
||||
executed_at DATETIME NULL,
|
||||
deleted_at DATETIME NULL
|
||||
)`,
|
||||
`CREATE TABLE laying_transfer_targets (
|
||||
id INTEGER PRIMARY KEY,
|
||||
laying_transfer_id INTEGER NULL,
|
||||
target_project_flock_kandang_id INTEGER NULL,
|
||||
deleted_at DATETIME NULL
|
||||
)`,
|
||||
`CREATE TABLE approvals (
|
||||
id INTEGER PRIMARY KEY,
|
||||
approvable_type TEXT NULL,
|
||||
approvable_id INTEGER NULL,
|
||||
action TEXT NULL
|
||||
)`,
|
||||
)
|
||||
|
||||
return db
|
||||
|
||||
@@ -16,6 +16,7 @@ const (
|
||||
hppV2ComponentBopRegular = "BOP_REGULAR"
|
||||
hppV2ComponentBopEksp = "BOP_EKSPEDISI"
|
||||
hppV2ComponentManualPulletCost = "MANUAL_PULLET_COST"
|
||||
hppV2ComponentRecordingStockRoute = "RECORDING_STOCK_ROUTE"
|
||||
hppV2ComponentDepreciation = "DEPRECIATION"
|
||||
hppV2PartGrowingNormal = "growing_normal"
|
||||
hppV2PartGrowingCutover = "growing_cutover"
|
||||
@@ -26,6 +27,7 @@ const (
|
||||
hppV2PartLayingDirect = "laying_direct"
|
||||
hppV2PartLayingFarm = "laying_farm"
|
||||
hppV2PartManualCutover = "manual_cutover"
|
||||
hppV2PartRecordingStockRoute = "recording_stock_route"
|
||||
hppV2PartDepreciationNormal = "normal_transfer"
|
||||
hppV2PartDepreciationCutover = "manual_cutover"
|
||||
hppV2PartDepreciationFarmSnapshot = "farm_snapshot"
|
||||
@@ -190,6 +192,12 @@ func (s *hppV2Service) CalculateHppBreakdown(projectFlockKandangId uint, date *t
|
||||
}
|
||||
appendComponent(hppV2ComponentManualPulletCost, manualPulletComponent)
|
||||
|
||||
recordingStockRouteComponent, err := s.getRecordingStockRouteComponent(projectFlockKandangId, contextRow, startOfDay)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
appendComponent(hppV2ComponentRecordingStockRoute, recordingStockRouteComponent)
|
||||
|
||||
depreciationComponent, err := s.getDepreciationComponent(projectFlockKandangId, contextRow, startOfDay, endOfDay, totalPulletCost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1064,6 +1072,100 @@ func (s *hppV2Service) getManualPulletCostComponent(
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *hppV2Service) getRecordingStockRouteComponent(
|
||||
projectFlockKandangId uint,
|
||||
contextRow *commonRepo.HppV2ProjectFlockKandangContext,
|
||||
periodDate time.Time,
|
||||
) (*HppV2Component, error) {
|
||||
if s.hppRepo == nil || contextRow == nil || periodDate.IsZero() {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
farmTotalCost, err := s.hppRepo.GetRecordingStockRoutingAdjustmentCostByProjectFlockID(
|
||||
context.Background(),
|
||||
contextRow.ProjectFlockID,
|
||||
periodDate,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if farmTotalCost <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
farmPFKIDs, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), contextRow.ProjectFlockID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(farmPFKIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
totalPopulation, err := s.hppRepo.GetTotalPopulation(context.Background(), farmPFKIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if totalPopulation <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
targetPopulation, err := s.hppRepo.GetTotalPopulation(context.Background(), []uint{projectFlockKandangId})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if targetPopulation <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
ratio := targetPopulation / totalPopulation
|
||||
if ratio <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
appliedTotal := farmTotalCost * ratio
|
||||
if appliedTotal <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
part := HppV2ComponentPart{
|
||||
Code: hppV2PartRecordingStockRoute,
|
||||
Title: "Recording Stock Route",
|
||||
Scopes: []string{hppV2ScopePulletCost},
|
||||
Total: appliedTotal,
|
||||
Proration: &HppV2Proration{
|
||||
Basis: hppV2ProrationPopulation,
|
||||
Numerator: targetPopulation,
|
||||
Denominator: totalPopulation,
|
||||
Ratio: ratio,
|
||||
},
|
||||
Details: map[string]any{
|
||||
"period_date": formatDateOnly(periodDate),
|
||||
"farm_total_cost": farmTotalCost,
|
||||
"target_population": targetPopulation,
|
||||
"farm_population": totalPopulation,
|
||||
"project_flock_id": contextRow.ProjectFlockID,
|
||||
"project_flock_kandang_id": projectFlockKandangId,
|
||||
},
|
||||
References: []HppV2Reference{
|
||||
{
|
||||
Type: "recording_stock_route",
|
||||
Date: formatDateOnly(periodDate),
|
||||
Qty: 1,
|
||||
Total: farmTotalCost,
|
||||
AppliedTotal: appliedTotal,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return &HppV2Component{
|
||||
Code: hppV2ComponentRecordingStockRoute,
|
||||
Title: "Recording Stock Route",
|
||||
Scopes: []string{hppV2ScopePulletCost},
|
||||
Total: appliedTotal,
|
||||
Parts: []HppV2ComponentPart{part},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *hppV2Service) getDepreciationComponent(
|
||||
projectFlockKandangId uint,
|
||||
contextRow *commonRepo.HppV2ProjectFlockKandangContext,
|
||||
|
||||
@@ -25,6 +25,7 @@ type hppV2RepoStub struct {
|
||||
chickinRowsByKey map[string][]commonRepo.HppV2ChickinCostRow
|
||||
expenseRowsByPFKKey map[string][]commonRepo.HppV2ExpenseCostRow
|
||||
expenseRowsByFarmKey map[string][]commonRepo.HppV2ExpenseCostRow
|
||||
routeCostByProject map[uint]float64
|
||||
totalPopulationByKey map[string]float64
|
||||
transferSummaryByPFK map[uint]struct {
|
||||
projectFlockID uint
|
||||
@@ -60,6 +61,10 @@ func (s *hppV2RepoStub) GetManualDepreciationInputByProjectFlockID(_ context.Con
|
||||
return s.manualInputByProject[projectFlockID], nil
|
||||
}
|
||||
|
||||
func (s *hppV2RepoStub) GetRecordingStockRoutingAdjustmentCostByProjectFlockID(_ context.Context, projectFlockID uint, _ time.Time) (float64, error) {
|
||||
return s.routeCostByProject[projectFlockID], nil
|
||||
}
|
||||
|
||||
func (s *hppV2RepoStub) GetFarmDepreciationSnapshotByProjectFlockIDAndPeriod(_ context.Context, projectFlockID uint, periodDate time.Time) (*commonRepo.HppV2FarmDepreciationSnapshotRow, error) {
|
||||
if s.snapshotByProjectKey == nil {
|
||||
return nil, nil
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
BEGIN;
|
||||
|
||||
DROP INDEX IF EXISTS idx_recording_stocks_project_flock_kandang_id;
|
||||
|
||||
ALTER TABLE recording_stocks
|
||||
DROP CONSTRAINT IF EXISTS fk_recording_stocks_project_flock_kandang_id;
|
||||
|
||||
ALTER TABLE recording_stocks
|
||||
DROP COLUMN IF EXISTS project_flock_kandang_id;
|
||||
|
||||
ALTER TABLE house_depreciation_standards
|
||||
DROP CONSTRAINT IF EXISTS chk_house_depreciation_standards_standard_week_positive;
|
||||
|
||||
ALTER TABLE house_depreciation_standards
|
||||
DROP COLUMN IF EXISTS standard_week;
|
||||
|
||||
COMMIT;
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE recording_stocks
|
||||
ADD COLUMN IF NOT EXISTS project_flock_kandang_id BIGINT NULL;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
WHERE conname = 'fk_recording_stocks_project_flock_kandang_id'
|
||||
) THEN
|
||||
ALTER TABLE recording_stocks
|
||||
ADD CONSTRAINT fk_recording_stocks_project_flock_kandang_id
|
||||
FOREIGN KEY (project_flock_kandang_id)
|
||||
REFERENCES project_flock_kandangs(id)
|
||||
ON DELETE SET NULL
|
||||
ON UPDATE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_recording_stocks_project_flock_kandang_id
|
||||
ON recording_stocks(project_flock_kandang_id);
|
||||
|
||||
ALTER TABLE house_depreciation_standards
|
||||
ADD COLUMN IF NOT EXISTS standard_week INT;
|
||||
|
||||
UPDATE house_depreciation_standards
|
||||
SET standard_week = CASE house_type::text
|
||||
WHEN 'close_house' THEN 22
|
||||
WHEN 'open_house' THEN 25
|
||||
ELSE standard_week
|
||||
END
|
||||
WHERE standard_week IS NULL OR standard_week <= 0;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
WHERE conname = 'chk_house_depreciation_standards_standard_week_positive'
|
||||
) THEN
|
||||
ALTER TABLE house_depreciation_standards
|
||||
ADD CONSTRAINT chk_house_depreciation_standards_standard_week_positive
|
||||
CHECK (standard_week > 0);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE house_depreciation_standards
|
||||
ALTER COLUMN standard_week SET NOT NULL;
|
||||
|
||||
COMMIT;
|
||||
@@ -6,6 +6,7 @@ type HouseDepreciationStandard struct {
|
||||
Id uint `gorm:"primaryKey"`
|
||||
HouseType string `gorm:"type:house_type_enum;not null;uniqueIndex:house_depreciation_standards_house_type_day_unique,priority:1"`
|
||||
DayNumber int `gorm:"column:day;not null;uniqueIndex:house_depreciation_standards_house_type_day_unique,priority:2"`
|
||||
StandardWeek int `gorm:"column:standard_week;not null"`
|
||||
DepreciationPercent float64 `gorm:"type:numeric(15,6);not null"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime"`
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
package entities
|
||||
|
||||
type RecordingStock struct {
|
||||
Id uint `gorm:"primaryKey"`
|
||||
RecordingId uint `gorm:"column:recording_id;not null;index"`
|
||||
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
|
||||
UsageQty *float64 `gorm:"column:usage_qty"`
|
||||
PendingQty *float64 `gorm:"column:pending_qty"`
|
||||
Id uint `gorm:"primaryKey"`
|
||||
RecordingId uint `gorm:"column:recording_id;not null;index"`
|
||||
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
|
||||
ProjectFlockKandangId *uint `gorm:"column:project_flock_kandang_id;index"`
|
||||
UsageQty *float64 `gorm:"column:usage_qty"`
|
||||
PendingQty *float64 `gorm:"column:pending_qty"`
|
||||
|
||||
Recording Recording `gorm:"foreignKey:RecordingId;references:Id"`
|
||||
ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"`
|
||||
|
||||
@@ -98,6 +98,9 @@ func sapronakIncomingPurchaseQueryParts(params SapronakQueryParams) (string, []a
|
||||
fifo.StockableKeyPurchaseItems.String(),
|
||||
entity.StockAllocationStatusActive,
|
||||
entity.StockAllocationPurposeConsume,
|
||||
fifo.UsableKeyRecordingStock.String(),
|
||||
params.ProjectFlockKandangIDs,
|
||||
fifo.UsableKeyProjectChickin.String(),
|
||||
params.ProjectFlockKandangIDs,
|
||||
params.ProjectFlockKandangIDs,
|
||||
params.WarehouseIDs,
|
||||
@@ -323,7 +326,7 @@ func (r *ClosingRepositoryImpl) SumFeedPurchaseAndUsedByProjectFlockKandangIDs(c
|
||||
Joins("JOIN product_warehouses pw ON pw.id = rs.product_warehouse_id").
|
||||
Joins("JOIN products prod ON prod.id = pw.product_id").
|
||||
Joins("JOIN flags f ON f.flagable_id = prod.id AND f.flagable_type = ?", "products").
|
||||
Where("rec.project_flock_kandangs_id IN ?", projectFlockKandangIDs).
|
||||
Where("rs.project_flock_kandang_id IN ?", projectFlockKandangIDs).
|
||||
Where("f.name = ?", "PAKAN").
|
||||
Select("COALESCE(SUM(COALESCE(rs.usage_qty, 0) + COALESCE(rs.pending_qty, 0)), 0) AS total_used").
|
||||
Scan(&usageAgg).Error
|
||||
@@ -905,7 +908,11 @@ WITH scoped_farm_allocations AS (
|
||||
WHERE sa.stockable_type = ?
|
||||
AND sa.status = ?
|
||||
AND sa.allocation_purpose = ?
|
||||
AND COALESCE(rec.project_flock_kandangs_id, pc.project_flock_kandang_id) IN ?
|
||||
AND (
|
||||
(sa.usable_type = ? AND rs.project_flock_kandang_id IN ?)
|
||||
OR
|
||||
(sa.usable_type = ? AND pc.project_flock_kandang_id IN ?)
|
||||
)
|
||||
GROUP BY sa.stockable_id
|
||||
)
|
||||
SELECT
|
||||
@@ -1167,7 +1174,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsage(ctx context.Context, pfkID ui
|
||||
"recording_stocks rs",
|
||||
"pw.id = rs.product_warehouse_id",
|
||||
[]string{"JOIN recordings r ON r.id = rs.recording_id AND r.deleted_at IS NULL"},
|
||||
"r.project_flock_kandangs_id = ? AND f.name IN ?",
|
||||
"rs.project_flock_kandang_id = ? AND f.name IN ?",
|
||||
pfkID,
|
||||
sapronakFlagsUsage,
|
||||
)
|
||||
@@ -1208,7 +1215,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsageDetails(ctx context.Context, p
|
||||
COALESCE(rs.usage_qty,0) AS qty_out,
|
||||
COALESCE(p.product_price,0) AS price
|
||||
`,
|
||||
"r.project_flock_kandangs_id = ? AND f.name IN ?",
|
||||
"rs.project_flock_kandang_id = ? AND f.name IN ?",
|
||||
pfkID,
|
||||
sapronakFlagsUsage,
|
||||
)
|
||||
@@ -1294,7 +1301,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsageAllocatedDetails(ctx context.C
|
||||
Where("sa.stockable_type <> ?", fifo.StockableKeyProjectFlockPopulation.String()).
|
||||
Where("f.name IN ?", sapronakFlagsAll).
|
||||
Where(`
|
||||
(sa.usable_type = ? AND r.project_flock_kandangs_id = ? AND f.name IN ?)
|
||||
(sa.usable_type = ? AND rs.project_flock_kandang_id = ? AND f.name IN ?)
|
||||
OR
|
||||
(sa.usable_type = ? AND pc_used.project_flock_kandang_id = ? AND f.name IN ?)
|
||||
`,
|
||||
@@ -1347,7 +1354,12 @@ func (r *ClosingRepositoryImpl) incomingFarmPurchaseAllocationBase(ctx context.C
|
||||
Where("sa.status = ?", entity.StockAllocationStatusActive).
|
||||
Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume).
|
||||
Where("w.kandang_id IS NULL").
|
||||
Where("COALESCE(rec.project_flock_kandangs_id, pc.project_flock_kandang_id) = ?", projectFlockKandangID).
|
||||
Where("(sa.usable_type = ? AND rs.project_flock_kandang_id = ?) OR (sa.usable_type = ? AND pc.project_flock_kandang_id = ?)",
|
||||
fifo.UsableKeyRecordingStock.String(),
|
||||
projectFlockKandangID,
|
||||
fifo.UsableKeyProjectChickin.String(),
|
||||
projectFlockKandangID,
|
||||
).
|
||||
Where("f.name IN ?", sapronakFlagsAll).
|
||||
Where("pi.received_date IS NOT NULL")
|
||||
db = applyDateRange(db, "pi.received_date", start, end)
|
||||
|
||||
@@ -20,8 +20,8 @@ func TestSapronakIncomingPurchaseQueryPartsUsesAttributedPurchasesWhenProjectFlo
|
||||
if sql != sapronakIncomingPurchasesScopedSQL() {
|
||||
t.Fatalf("expected scoped purchase SQL, got %q", sql)
|
||||
}
|
||||
if len(args) != 8 {
|
||||
t.Fatalf("expected 8 argument groups, got %d", len(args))
|
||||
if len(args) != 11 {
|
||||
t.Fatalf("expected 11 argument groups, got %d", len(args))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ func TestFetchSapronakIncomingIncludesAttributedFarmPurchasesAndHistoricalWareho
|
||||
(2, 10, 'products', 'OBAT')`,
|
||||
`INSERT INTO purchases (id, po_number, deleted_at) VALUES (1, 'PO-LTI-0005', NULL)`,
|
||||
`INSERT INTO recordings (id, project_flock_kandangs_id, deleted_at) VALUES (11, 101, NULL), (12, 999, NULL)`,
|
||||
`INSERT INTO recording_stocks (id, recording_id, product_warehouse_id, usage_qty) VALUES (21, 11, 501, 150), (22, 12, 502, 10)`,
|
||||
`INSERT INTO recording_stocks (id, recording_id, product_warehouse_id, usage_qty, project_flock_kandang_id) VALUES (21, 11, 501, 150, 101), (22, 12, 502, 10, 999)`,
|
||||
`INSERT INTO purchase_items (id, purchase_id, product_id, warehouse_id, project_flock_kandang_id, total_qty, price, received_date) VALUES
|
||||
(1, 1, 10, 1, NULL, 100, 261700, '` + receivedAt.Format(time.RFC3339) + `'),
|
||||
(2, 1, 20, 1, NULL, 50, 15000, '` + receivedAt.Format(time.RFC3339) + `'),
|
||||
@@ -145,11 +145,12 @@ func setupClosingRepositoryTestDB(t *testing.T) *gorm.DB {
|
||||
deleted_at TIMESTAMP NULL
|
||||
)`,
|
||||
`CREATE TABLE recording_stocks (
|
||||
id INTEGER PRIMARY KEY,
|
||||
recording_id INTEGER NOT NULL,
|
||||
product_warehouse_id INTEGER NOT NULL,
|
||||
usage_qty NUMERIC(15,3) NOT NULL DEFAULT 0
|
||||
)`,
|
||||
id INTEGER PRIMARY KEY,
|
||||
recording_id INTEGER NOT NULL,
|
||||
product_warehouse_id INTEGER NOT NULL,
|
||||
usage_qty NUMERIC(15,3) NOT NULL DEFAULT 0,
|
||||
project_flock_kandang_id INTEGER NULL
|
||||
)`,
|
||||
`CREATE TABLE project_chickins (
|
||||
id INTEGER PRIMARY KEY,
|
||||
project_flock_kandang_id INTEGER NOT NULL
|
||||
|
||||
@@ -351,6 +351,31 @@ func (u *DailyChecklistController) CreateOne(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
func (u *DailyChecklistController) BulkUpdate(c *fiber.Ctx) error {
|
||||
req := new(validation.BulkStatusUpdate)
|
||||
if err := c.BodyParser(req); err != nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
|
||||
}
|
||||
|
||||
results, err := u.DailyChecklistService.BulkUpdate(c, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
responseData := make([]dto.DailyChecklistListDTO, len(results))
|
||||
for i, item := range results {
|
||||
responseData[i] = dto.ToDailyChecklistListDTO(item)
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusOK).
|
||||
JSON(response.Success{
|
||||
Code: fiber.StatusOK,
|
||||
Status: "success",
|
||||
Message: "Bulk update dailyChecklist successfully",
|
||||
Data: responseData,
|
||||
})
|
||||
}
|
||||
|
||||
func (u *DailyChecklistController) UpdateOne(c *fiber.Ctx) error {
|
||||
req := new(validation.Update)
|
||||
param := c.Params("idDailyChecklist")
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||
"context"
|
||||
"time"
|
||||
|
||||
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/common/repository"
|
||||
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type DailyChecklistRepository interface {
|
||||
repository.BaseRepository[entity.DailyChecklist]
|
||||
ListScopedChecklistIDs(c *fiber.Ctx, ids []uint) ([]uint, error)
|
||||
BulkUpdateStatus(ctx context.Context, ids []uint, status string, rejectReason *string) error
|
||||
ListByIDsWithKandang(ctx context.Context, ids []uint) ([]entity.DailyChecklist, error)
|
||||
}
|
||||
|
||||
type DailyChecklistRepositoryImpl struct {
|
||||
@@ -19,3 +28,70 @@ func NewDailyChecklistRepository(db *gorm.DB) DailyChecklistRepository {
|
||||
BaseRepositoryImpl: repository.NewBaseRepository[entity.DailyChecklist](db),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *DailyChecklistRepositoryImpl) ListScopedChecklistIDs(c *fiber.Ctx, ids []uint) ([]uint, error) {
|
||||
if len(ids) == 0 {
|
||||
return []uint{}, nil
|
||||
}
|
||||
|
||||
db := r.DB().WithContext(c.Context()).
|
||||
Table("daily_checklists dc").
|
||||
Select("dc.id").
|
||||
Joins("JOIN kandang_groups k ON k.id = dc.kandang_id").
|
||||
Joins("JOIN locations loc ON loc.id = k.location_id").
|
||||
Joins("JOIN areas a ON a.id = loc.area_id").
|
||||
Where("dc.id IN ?", ids)
|
||||
|
||||
db, err := m.ApplyLocationAreaScope(c, db, "loc.id", "a.id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var scopedIDs []uint
|
||||
if err := db.Pluck("dc.id", &scopedIDs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return scopedIDs, nil
|
||||
}
|
||||
|
||||
func (r *DailyChecklistRepositoryImpl) BulkUpdateStatus(ctx context.Context, ids []uint, status string, rejectReason *string) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
updateBody := map[string]any{
|
||||
"status": status,
|
||||
"reject_reason": rejectReason,
|
||||
"updated_at": time.Now(),
|
||||
}
|
||||
|
||||
return r.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
result := tx.Model(&entity.DailyChecklist{}).
|
||||
Where("id IN ?", ids).
|
||||
Updates(updateBody)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected != int64(len(ids)) {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (r *DailyChecklistRepositoryImpl) ListByIDsWithKandang(ctx context.Context, ids []uint) ([]entity.DailyChecklist, error) {
|
||||
if len(ids) == 0 {
|
||||
return []entity.DailyChecklist{}, nil
|
||||
}
|
||||
|
||||
var items []entity.DailyChecklist
|
||||
if err := r.DB().WithContext(ctx).
|
||||
Where("id IN ?", ids).
|
||||
Preload("Kandang").
|
||||
Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ func DailyChecklistRoutes(v1 fiber.Router, u user.UserService, s dailyChecklist.
|
||||
*/
|
||||
route.Post("/assignment", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.UpdateAssignment)
|
||||
|
||||
route.Patch("/bulk-update", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.BulkUpdate)
|
||||
route.Patch("/:idDailyChecklist", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.UpdateOne)
|
||||
route.Delete("/:idDailyChecklist", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.DeleteOne)
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ type DailyChecklistService interface {
|
||||
GetOne(ctx *fiber.Ctx, id uint) (*entity.DailyChecklist, error)
|
||||
CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.DailyChecklist, error)
|
||||
UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.DailyChecklist, error)
|
||||
BulkUpdate(ctx *fiber.Ctx, req *validation.BulkStatusUpdate) ([]entity.DailyChecklist, error)
|
||||
DeleteOne(ctx *fiber.Ctx, id uint) error
|
||||
AssignPhases(ctx *fiber.Ctx, id uint, req *validation.AssignPhases) error
|
||||
AssignTasks(ctx *fiber.Ctx, id uint, req *validation.AssignTask) error
|
||||
@@ -646,6 +647,67 @@ func (s dailyChecklistService) UpdateOne(c *fiber.Ctx, req *validation.Update, i
|
||||
return s.GetOne(c, id)
|
||||
}
|
||||
|
||||
func (s dailyChecklistService) BulkUpdate(c *fiber.Ctx, req *validation.BulkStatusUpdate) ([]entity.DailyChecklist, error) {
|
||||
if err := s.Validate.Struct(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
status := strings.ToUpper(strings.TrimSpace(req.Status))
|
||||
if status != "APPROVED" && status != "REJECTED" {
|
||||
return nil, fiber.NewError(fiber.StatusBadRequest, "status must be APPROVED or REJECTED")
|
||||
}
|
||||
|
||||
ids, err := parseChecklistIDs(req.IDs)
|
||||
if err != nil {
|
||||
return nil, fiber.NewError(fiber.StatusBadRequest, err.Error())
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil, fiber.NewError(fiber.StatusBadRequest, "ids cannot be empty")
|
||||
}
|
||||
|
||||
scopedIDs, err := s.Repository.ListScopedChecklistIDs(c, ids)
|
||||
if err != nil {
|
||||
s.Log.Errorf("Failed to validate daily checklist scope for bulk update: %+v", err)
|
||||
return nil, err
|
||||
}
|
||||
if len(scopedIDs) != len(ids) {
|
||||
return nil, fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found")
|
||||
}
|
||||
|
||||
var rejectReason *string
|
||||
if status == "REJECTED" {
|
||||
rejectReason = req.RejectReason
|
||||
}
|
||||
|
||||
if err := s.Repository.BulkUpdateStatus(c.Context(), ids, status, rejectReason); err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found")
|
||||
}
|
||||
s.Log.Errorf("Failed to bulk update daily checklist status: %+v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
updated, err := s.Repository.ListByIDsWithKandang(c.Context(), ids)
|
||||
if err != nil {
|
||||
s.Log.Errorf("Failed to fetch updated daily checklists: %+v", err)
|
||||
return nil, err
|
||||
}
|
||||
if len(updated) != len(ids) {
|
||||
return nil, fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found")
|
||||
}
|
||||
|
||||
orderByID := make(map[uint]int, len(ids))
|
||||
for idx, id := range ids {
|
||||
orderByID[id] = idx
|
||||
}
|
||||
|
||||
sort.Slice(updated, func(i, j int) bool {
|
||||
return orderByID[updated[i].Id] < orderByID[updated[j].Id]
|
||||
})
|
||||
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
func (s dailyChecklistService) DeleteOne(c *fiber.Ctx, id uint) error {
|
||||
if err := s.ensureChecklistAccess(c, id); err != nil {
|
||||
return err
|
||||
@@ -908,6 +970,33 @@ func parsePhaseIDs(raw string) ([]uint, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func parseChecklistIDs(raw string) ([]uint, error) {
|
||||
parts := strings.Split(raw, ",")
|
||||
result := make([]uint, 0, len(parts))
|
||||
seen := make(map[uint]struct{})
|
||||
|
||||
for _, part := range parts {
|
||||
value := strings.TrimSpace(part)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
num, err := strconv.ParseUint(value, 10, 64)
|
||||
if err != nil || num == 0 {
|
||||
return nil, errors.New("invalid daily checklist id: " + value)
|
||||
}
|
||||
|
||||
u := uint(num)
|
||||
if _, ok := seen[u]; ok {
|
||||
continue
|
||||
}
|
||||
seen[u] = struct{}{}
|
||||
result = append(result, u)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func parseIDs(raw string) ([]uint, error) {
|
||||
parts := strings.Split(raw, ",")
|
||||
result := make([]uint, 0, len(parts))
|
||||
|
||||
@@ -18,6 +18,12 @@ type Update struct {
|
||||
DeletedDocumentIDs *string `form:"deleted_document_ids" json:"deleted_document_ids"`
|
||||
}
|
||||
|
||||
type BulkStatusUpdate struct {
|
||||
IDs string `form:"ids" json:"ids" validate:"required_strict"`
|
||||
Status string `form:"status" json:"status" validate:"required,oneof=APPROVED REJECTED"`
|
||||
RejectReason *string `form:"reject_reason" json:"reject_reason"`
|
||||
}
|
||||
|
||||
type Query struct {
|
||||
Page int `query:"page" validate:"omitempty,number,min=1,gt=0"`
|
||||
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"`
|
||||
|
||||
@@ -6,11 +6,16 @@ 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"
|
||||
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/validations"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/response"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
||||
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
@@ -26,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),
|
||||
@@ -264,6 +288,51 @@ func (u *ExpenseController) Approval(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
func (u *ExpenseController) BulkApproveToStatus(c *fiber.Ctx) error {
|
||||
req := new(validation.BulkApprovalRequest)
|
||||
|
||||
if err := c.BodyParser(req); err != nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
|
||||
}
|
||||
|
||||
targetStep, err := req.ResolveTarget()
|
||||
if err != nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, err.Error())
|
||||
}
|
||||
|
||||
if req.RequiresDate(targetStep) && strings.TrimSpace(req.Date) == "" {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "date is required for REALISASI bulk approval")
|
||||
}
|
||||
|
||||
if err := ensureExpenseBulkApprovalPermission(c, targetStep); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
results, err := u.ExpenseService.BulkApproveToStatus(c, req, targetStep)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var (
|
||||
data interface{}
|
||||
message = "Bulk approve expense successfully"
|
||||
)
|
||||
if len(results) == 1 {
|
||||
data = results[0]
|
||||
} else {
|
||||
message = "Bulk approve expenses successfully"
|
||||
data = results
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusOK).
|
||||
JSON(response.Success{
|
||||
Code: fiber.StatusOK,
|
||||
Status: "success",
|
||||
Message: message,
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
func (u *ExpenseController) CreateRealization(c *fiber.Ctx) error {
|
||||
expenseID := c.Params("id")
|
||||
id, err := strconv.Atoi(expenseID)
|
||||
@@ -366,6 +435,31 @@ func (u *ExpenseController) CompleteExpense(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
func ensureExpenseBulkApprovalPermission(c *fiber.Ctx, targetStep approvalutils.ApprovalStep) error {
|
||||
requiredPerms := []string{}
|
||||
|
||||
switch targetStep {
|
||||
case utils.ExpenseStepHeadArea:
|
||||
requiredPerms = []string{m.P_ExpenseApprovalHeadArea}
|
||||
case utils.ExpenseStepUnitVicePresident:
|
||||
requiredPerms = []string{m.P_ExpenseApprovalUnitVicePresident}
|
||||
case utils.ExpenseStepFinance:
|
||||
requiredPerms = []string{m.P_ExpenseApprovalFinance}
|
||||
case utils.ExpenseStepRealisasi:
|
||||
requiredPerms = []string{m.P_ExpenseApprovalFinance, m.P_ExpenseCreateRealizations}
|
||||
default:
|
||||
return fiber.NewError(fiber.StatusBadRequest, "Invalid approval target")
|
||||
}
|
||||
|
||||
for _, perm := range requiredPerms {
|
||||
if !m.HasPermission(c, perm) {
|
||||
return fiber.NewError(fiber.StatusForbidden, "Insufficient permission")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *ExpenseController) DeleteDocument(c *fiber.Ctx) error {
|
||||
expenseID, err := strconv.Atoi(c.Params("id"))
|
||||
if err != nil {
|
||||
|
||||
@@ -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,
|
||||
CAST(DATE(e.transaction_date) AS TEXT) 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 := exportprogress.ParseActivityDate(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)
|
||||
}
|
||||
@@ -31,6 +31,7 @@ func ExpenseRoutes(v1 fiber.Router, u user.UserService, s expense.ExpenseService
|
||||
route.Post("/approvals/head-area", m.RequirePermissions(m.P_ExpenseApprovalHeadArea), ctrl.Approval)
|
||||
route.Post("/approvals/finance", m.RequirePermissions(m.P_ExpenseApprovalFinance), ctrl.Approval)
|
||||
route.Post("/approvals/unit-vice-president", m.RequirePermissions(m.P_ExpenseApprovalUnitVicePresident), ctrl.Approval)
|
||||
route.Post("/approvals/bulk", ctrl.BulkApproveToStatus)
|
||||
|
||||
route.Post("/:id/realizations", m.RequirePermissions(m.P_ExpenseCreateRealizations), ctrl.CreateRealization)
|
||||
route.Patch("/:id/realizations", m.RequirePermissions(m.P_ExpenseUpdateRealizations), ctrl.UpdateRealization)
|
||||
|
||||
@@ -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"
|
||||
@@ -37,6 +38,8 @@ type ExpenseService interface {
|
||||
UpdateRealization(ctx *fiber.Ctx, expenseID uint, req *validation.UpdateRealization) (*expenseDto.ExpenseDetailDTO, error)
|
||||
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 {
|
||||
@@ -155,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
|
||||
@@ -742,8 +753,12 @@ func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *va
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
|
||||
actorID, err := middleware.ActorIDFromContext(c)
|
||||
if err != nil {
|
||||
return nil, fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context")
|
||||
}
|
||||
|
||||
if err := s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
|
||||
approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx))
|
||||
realizationRepoTx := repository.NewExpenseRealizationRepository(tx)
|
||||
expenseNonstockRepoTx := repository.NewExpenseNonstockRepository(tx)
|
||||
@@ -780,12 +795,6 @@ func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *va
|
||||
}
|
||||
}
|
||||
|
||||
if err := expenseRepoTx.PatchOne(c.Context(), expenseID, map[string]interface{}{
|
||||
"realization_date": realizationDate,
|
||||
}, nil); err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update realization date")
|
||||
}
|
||||
|
||||
if s.DocumentSvc != nil && len(req.Documents) > 0 {
|
||||
documentFiles := make([]commonSvc.DocumentFile, 0, len(req.Documents))
|
||||
for idx, file := range req.Documents {
|
||||
@@ -795,7 +804,6 @@ func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *va
|
||||
Index: &idx,
|
||||
})
|
||||
}
|
||||
actorID := uint(1) // TODO: replace with authenticated user id
|
||||
_, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{
|
||||
DocumentableType: string(utils.DocumentableTypeExpenseRealization),
|
||||
DocumentableID: uint64(expenseID),
|
||||
@@ -807,6 +815,12 @@ func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *va
|
||||
}
|
||||
}
|
||||
|
||||
if err := expenseRepoTx.PatchOne(c.Context(), expenseID, map[string]interface{}{
|
||||
"realization_date": realizationDate,
|
||||
}, nil); err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update realization date")
|
||||
}
|
||||
|
||||
approvalAction := entity.ApprovalActionCreated
|
||||
if _, err := approvalSvc.CreateApproval(
|
||||
c.Context(),
|
||||
@@ -814,9 +828,9 @@ func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *va
|
||||
expenseID,
|
||||
utils.ExpenseStepRealisasi,
|
||||
&approvalAction,
|
||||
uint(1), // TODO: replace with authenticated user id
|
||||
nil); err != nil {
|
||||
|
||||
actorID,
|
||||
nil,
|
||||
); err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create realization approval")
|
||||
}
|
||||
|
||||
@@ -834,6 +848,205 @@ func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *va
|
||||
return responseDTO, nil
|
||||
}
|
||||
|
||||
func (s *expenseService) BulkApproveToStatus(c *fiber.Ctx, req *validation.BulkApprovalRequest, target approvalutils.ApprovalStep) ([]expenseDto.ExpenseDetailDTO, error) {
|
||||
if err := s.Validate.Struct(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
approvableIDs := utils.UniqueUintSlice(req.ApprovableIds)
|
||||
if len(approvableIDs) == 0 {
|
||||
return nil, fiber.NewError(fiber.StatusBadRequest, "approvable_ids must contain at least one id")
|
||||
}
|
||||
|
||||
actorID, err := middleware.ActorIDFromContext(c)
|
||||
if err != nil {
|
||||
return nil, fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context")
|
||||
}
|
||||
|
||||
var realizationDate time.Time
|
||||
if req.RequiresDate(target) {
|
||||
realizationDate, err = utils.ParseDateString(req.Date)
|
||||
if err != nil {
|
||||
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid date format")
|
||||
}
|
||||
}
|
||||
|
||||
invalidateFromDateByExpenseID := make(map[uint]time.Time, len(approvableIDs))
|
||||
|
||||
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
|
||||
approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx))
|
||||
expenseRepoTx := repository.NewExpenseRepository(tx)
|
||||
|
||||
for _, id := range approvableIDs {
|
||||
if err := commonSvc.EnsureRelations(c.Context(),
|
||||
commonSvc.RelationCheck{Name: "Expense", ID: &id, Exists: expenseRepoTx.IdExists},
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
expense, err := expenseRepoTx.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB {
|
||||
return db.Preload("Nonstocks")
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return fiber.NewError(fiber.StatusNotFound, "Expense not found")
|
||||
}
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to load expense")
|
||||
}
|
||||
|
||||
latestApproval, err := approvalSvcTx.LatestByTarget(c.Context(), utils.ApprovalWorkflowExpense, id, nil)
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate workflow")
|
||||
}
|
||||
if latestApproval == nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("No approval found for Expense %d", id))
|
||||
}
|
||||
if latestApproval.Action != nil && *latestApproval.Action == entity.ApprovalActionRejected {
|
||||
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Expense %d is rejected and cannot be bulk approved", id))
|
||||
}
|
||||
|
||||
currentStep := approvalutils.ApprovalStep(latestApproval.StepNumber)
|
||||
if currentStep >= target {
|
||||
currentStepName := utils.ExpenseApprovalSteps[currentStep]
|
||||
targetStepName := utils.ExpenseApprovalSteps[target]
|
||||
if currentStep == target {
|
||||
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Expense %d is already at %s step", id, targetStepName))
|
||||
}
|
||||
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Expense %d is already beyond %s step (current step: %s)", id, targetStepName, currentStepName))
|
||||
}
|
||||
|
||||
invalidateFromDate := commonSvc.MinNonZeroDateOnlyUTC(expense.TransactionDate, expense.RealizationDate)
|
||||
|
||||
for step := currentStep + 1; step <= target; step++ {
|
||||
if step == utils.ExpenseStepRealisasi {
|
||||
if err := s.createRealizationFromExpenseLines(c.Context(), tx, expense, realizationDate, actorID, req.Notes); err != nil {
|
||||
return err
|
||||
}
|
||||
invalidateFromDate = commonSvc.MinNonZeroDateOnlyUTC(expense.TransactionDate, expense.RealizationDate, realizationDate)
|
||||
break
|
||||
}
|
||||
|
||||
approvalAction := entity.ApprovalActionApproved
|
||||
if _, err := approvalSvcTx.CreateApproval(
|
||||
c.Context(),
|
||||
utils.ApprovalWorkflowExpense,
|
||||
id,
|
||||
step,
|
||||
&approvalAction,
|
||||
actorID,
|
||||
req.Notes,
|
||||
); err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create approval")
|
||||
}
|
||||
|
||||
if step == utils.ExpenseStepFinance && expense.PoNumber == "" {
|
||||
poNumber, err := s.generatePoNumber(tx, id)
|
||||
if err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to generate PO number")
|
||||
}
|
||||
if err := expenseRepoTx.PatchOne(c.Context(), id, map[string]interface{}{"po_number": poNumber}, nil); err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update PO number")
|
||||
}
|
||||
expense.PoNumber = poNumber
|
||||
}
|
||||
}
|
||||
|
||||
invalidateFromDateByExpenseID[id] = invalidateFromDate
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
if fiberErr, ok := err.(*fiber.Error); ok {
|
||||
return nil, fiberErr
|
||||
}
|
||||
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to bulk approve expenses")
|
||||
}
|
||||
|
||||
results := make([]expenseDto.ExpenseDetailDTO, 0, len(approvableIDs))
|
||||
for _, id := range approvableIDs {
|
||||
responseDTO, err := s.GetOne(c, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results = append(results, *responseDTO)
|
||||
}
|
||||
|
||||
for expenseID, invalidateFromDate := range invalidateFromDateByExpenseID {
|
||||
s.invalidateDepreciationSnapshotsByExpense(c.Context(), nil, expenseID, invalidateFromDate, nil)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (s *expenseService) createRealizationFromExpenseLines(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
expense *entity.Expense,
|
||||
realizationDate time.Time,
|
||||
actorID uint,
|
||||
notes *string,
|
||||
) error {
|
||||
if expense == nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "Expense not found")
|
||||
}
|
||||
if tx == nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Transaction is required")
|
||||
}
|
||||
if err := s.ensureProjectFlockNotClosedForExpense(ctx, expense); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
realizationRepoTx := repository.NewExpenseRealizationRepository(tx)
|
||||
expenseRepoTx := repository.NewExpenseRepository(tx)
|
||||
approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx))
|
||||
|
||||
for _, expenseNonstock := range expense.Nonstocks {
|
||||
expenseNonstockID := expenseNonstock.Id
|
||||
|
||||
_, err := realizationRepoTx.GetByExpenseNonstockID(ctx, expenseNonstockID)
|
||||
if err == nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Realization already exists for expense nonstock %d", expenseNonstockID))
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to check existing realization")
|
||||
}
|
||||
|
||||
realization := &entity.ExpenseRealization{
|
||||
ExpenseNonstockId: &expenseNonstockID,
|
||||
Qty: expenseNonstock.Qty,
|
||||
Price: expenseNonstock.Price,
|
||||
Notes: expenseNonstock.Notes,
|
||||
}
|
||||
|
||||
if err := realizationRepoTx.CreateOne(ctx, realization, nil); err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create realization")
|
||||
}
|
||||
}
|
||||
|
||||
if err := expenseRepoTx.PatchOne(ctx, uint(expense.Id), map[string]interface{}{
|
||||
"realization_date": realizationDate,
|
||||
}, nil); err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update realization date")
|
||||
}
|
||||
|
||||
approvalAction := entity.ApprovalActionCreated
|
||||
if _, err := approvalSvc.CreateApproval(
|
||||
ctx,
|
||||
utils.ApprovalWorkflowExpense,
|
||||
uint(expense.Id),
|
||||
utils.ExpenseStepRealisasi,
|
||||
&approvalAction,
|
||||
actorID,
|
||||
notes,
|
||||
); err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create realization approval")
|
||||
}
|
||||
|
||||
expense.RealizationDate = realizationDate
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *expenseService) CompleteExpense(c *fiber.Ctx, id uint, notes *string) (*expenseDto.ExpenseDetailDTO, error) {
|
||||
|
||||
if err := commonSvc.EnsureRelations(c.Context(),
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
package validation
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"mime/multipart"
|
||||
"strings"
|
||||
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
||||
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
|
||||
)
|
||||
|
||||
type Create struct {
|
||||
@@ -66,3 +71,31 @@ type ApprovalRequest struct {
|
||||
ApprovableIds []uint `json:"approvable_ids" validate:"required,min=1,dive,gt=0"`
|
||||
Notes *string `json:"notes" form:"notes"`
|
||||
}
|
||||
|
||||
type BulkApprovalRequest struct {
|
||||
ApprovableIds []uint `json:"approvable_ids" validate:"required,min=1,dive,gt=0"`
|
||||
Status string `json:"status" validate:"required,max=100"`
|
||||
Date string `json:"date,omitempty" validate:"omitempty,datetime=2006-01-02"`
|
||||
Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"`
|
||||
}
|
||||
|
||||
func (r *BulkApprovalRequest) ResolveTarget() (approvalutils.ApprovalStep, error) {
|
||||
status := strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(r.Status), " ", "_"))
|
||||
|
||||
switch status {
|
||||
case "HEAD_AREA", "APPROVAL_HEAD_AREA":
|
||||
return utils.ExpenseStepHeadArea, nil
|
||||
case "UNIT_VICE_PRESIDENT", "APPROVAL_UNIT_VICE_PRESIDENT", "BUSINESS_UNIT_VICE_PRESIDENT", "APPROVAL_BUSINESS_UNIT_VICE_PRESIDENT":
|
||||
return utils.ExpenseStepUnitVicePresident, nil
|
||||
case "FINANCE", "APPROVAL_FINANCE":
|
||||
return utils.ExpenseStepFinance, nil
|
||||
case "REALISASI":
|
||||
return utils.ExpenseStepRealisasi, nil
|
||||
default:
|
||||
return 0, errors.New("status must be one of HEAD_AREA, UNIT_VICE_PRESIDENT, FINANCE, or REALISASI")
|
||||
}
|
||||
}
|
||||
|
||||
func (r *BulkApprovalRequest) RequiresDate(target approvalutils.ApprovalStep) bool {
|
||||
return target == utils.ExpenseStepRealisasi
|
||||
}
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
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"
|
||||
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/validations"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/response"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
||||
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
@@ -24,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
|
||||
@@ -152,3 +177,64 @@ func (u *DeliveryOrdersController) UpdateOne(c *fiber.Ctx) error {
|
||||
Data: result,
|
||||
})
|
||||
}
|
||||
|
||||
func (u *DeliveryOrdersController) BulkApproveToStatus(c *fiber.Ctx) error {
|
||||
req := new(validation.BulkApprovalRequest)
|
||||
|
||||
if err := c.BodyParser(req); err != nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
|
||||
}
|
||||
|
||||
targetStep, err := req.ResolveTarget()
|
||||
if err != nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, err.Error())
|
||||
}
|
||||
|
||||
if req.RequiresDate(targetStep) && strings.TrimSpace(req.Date) == "" {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "date is required for DELIVERY bulk approval")
|
||||
}
|
||||
|
||||
if err := ensureMarketingBulkApprovalPermission(c, targetStep); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
results, err := u.DeliveryOrdersService.BulkApproveToStatus(c, req, targetStep)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var (
|
||||
data interface{}
|
||||
message = "Bulk approve marketing successfully"
|
||||
)
|
||||
if len(results) == 1 {
|
||||
data = results[0]
|
||||
} else {
|
||||
message = "Bulk approve marketings successfully"
|
||||
data = results
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusOK).
|
||||
JSON(response.Success{
|
||||
Code: fiber.StatusOK,
|
||||
Status: "success",
|
||||
Message: message,
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
func ensureMarketingBulkApprovalPermission(c *fiber.Ctx, targetStep approvalutils.ApprovalStep) error {
|
||||
requiredPerms := []string{m.P_SalesOrderApproval}
|
||||
|
||||
if targetStep == utils.MarketingDeliveryOrder {
|
||||
requiredPerms = append(requiredPerms, m.P_DeliveryUpdateOne)
|
||||
}
|
||||
|
||||
for _, perm := range requiredPerms {
|
||||
if !m.HasPermission(c, perm) {
|
||||
return fiber.NewError(fiber.StatusForbidden, "Insufficient permission")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ type MarketingDeliveryProductDTO struct {
|
||||
}
|
||||
|
||||
type DeliveryItemDTO struct {
|
||||
MarketingProductId uint `json:"marketing_product_id"`
|
||||
ProductWarehouse *productwarehouseDTO.ProductWarehousNestedDTO `json:"product_warehouse"`
|
||||
Qty float64 `json:"qty"`
|
||||
UnitPrice float64 `json:"unit_price"`
|
||||
@@ -153,7 +154,7 @@ func ToMarketingDeliveryProductDTO(e entity.MarketingDeliveryProduct) MarketingD
|
||||
return MarketingDeliveryProductDTO{
|
||||
Id: e.Id,
|
||||
MarketingProductId: e.MarketingProductId,
|
||||
Qty: e.UsageQty,
|
||||
Qty: e.UsageQty + e.PendingQty,
|
||||
UnitPrice: e.UnitPrice,
|
||||
TotalWeight: e.TotalWeight,
|
||||
AvgWeight: e.AvgWeight,
|
||||
@@ -328,6 +329,7 @@ func groupDeliveryProducts(products []MarketingDeliveryProductDTO, soNumber stri
|
||||
}
|
||||
|
||||
deliveryItem := DeliveryItemDTO{
|
||||
MarketingProductId: product.MarketingProductId,
|
||||
ProductWarehouse: product.ProductWarehouse,
|
||||
Qty: product.Qty,
|
||||
UnitPrice: product.UnitPrice,
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||
)
|
||||
|
||||
func TestToMarketingDeliveryProductDTOIncludesPendingQty(t *testing.T) {
|
||||
input := entity.MarketingDeliveryProduct{
|
||||
Id: 1,
|
||||
MarketingProductId: 42,
|
||||
UsageQty: 15,
|
||||
PendingQty: 5,
|
||||
}
|
||||
|
||||
got := ToMarketingDeliveryProductDTO(input)
|
||||
|
||||
if got.Qty != 20 {
|
||||
t.Fatalf("expected qty to include pending quantity, got %.2f", got.Qty)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
CAST(DATE(m.so_date) AS TEXT) 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 := exportprogress.ParseActivityDate(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
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ func RegisterRoutes(router fiber.Router, userService user.UserService, salesOrde
|
||||
route.Post("/sales-orders", m.RequirePermissions(m.P_SalesOrderCreateOne), salesOrdersCtrl.CreateOne)
|
||||
route.Patch("/sales-orders/:id", m.RequirePermissions(m.P_SalesOrderUpdateOne), salesOrdersCtrl.UpdateOne)
|
||||
route.Post("/sales-orders/approvals", m.RequirePermissions(m.P_SalesOrderApproval), salesOrdersCtrl.Approval)
|
||||
route.Post("/approvals/bulk", deliveryOrdersCtrl.BulkApproveToStatus)
|
||||
|
||||
route.Post("/delivery-orders", m.RequirePermissions(m.P_DeliveryCreateOne), deliveryOrdersCtrl.CreateOne)
|
||||
route.Patch("/delivery-orders/:id", m.RequirePermissions(m.P_DeliveryUpdateOne), deliveryOrdersCtrl.UpdateOne)
|
||||
|
||||
@@ -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"
|
||||
@@ -20,6 +21,7 @@ import (
|
||||
rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
|
||||
rShared "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
||||
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
@@ -32,6 +34,8 @@ type DeliveryOrdersService interface {
|
||||
GetOne(ctx *fiber.Ctx, id uint) (*dto.MarketingDetailDTO, error)
|
||||
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 {
|
||||
@@ -247,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
|
||||
@@ -544,6 +556,192 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO
|
||||
return s.getMarketingWithDeliveries(c, id)
|
||||
}
|
||||
|
||||
func (s deliveryOrdersService) BulkApproveToStatus(c *fiber.Ctx, req *validation.BulkApprovalRequest, target approvalutils.ApprovalStep) ([]dto.MarketingDetailDTO, error) {
|
||||
if err := s.Validate.Struct(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
approvableIDs := utils.UniqueUintSlice(req.ApprovableIds)
|
||||
if len(approvableIDs) == 0 {
|
||||
return nil, fiber.NewError(fiber.StatusBadRequest, "approvable_ids must contain at least one id")
|
||||
}
|
||||
|
||||
for _, id := range approvableIDs {
|
||||
if err := m.EnsureMarketingAccess(c, s.MarketingRepo.DB(), id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
actorID, err := m.ActorIDFromContext(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var deliveryDate time.Time
|
||||
if req.RequiresDate(target) {
|
||||
deliveryDate, err = utils.ParseDateString(req.Date)
|
||||
if err != nil {
|
||||
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid date format")
|
||||
}
|
||||
}
|
||||
|
||||
err = s.MarketingRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
|
||||
approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx))
|
||||
marketingRepoTx := marketingRepo.NewMarketingRepository(tx)
|
||||
|
||||
for _, id := range approvableIDs {
|
||||
if err := commonSvc.EnsureRelations(c.Context(),
|
||||
commonSvc.RelationCheck{Name: "Marketing", ID: &id, Exists: marketingRepoTx.IdExists},
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
marketing, err := marketingRepoTx.GetByID(c.Context(), id, s.withRelations)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Marketing %d not found", id))
|
||||
}
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch marketing")
|
||||
}
|
||||
|
||||
latestApproval, err := approvalSvcTx.LatestByTarget(c.Context(), utils.ApprovalWorkflowMarketing, id, nil)
|
||||
if err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to check approval status")
|
||||
}
|
||||
if latestApproval == nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("No approval found for Marketing %d", id))
|
||||
}
|
||||
if latestApproval.Action != nil && *latestApproval.Action == entity.ApprovalActionRejected {
|
||||
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Marketing %d is rejected and cannot be bulk approved", id))
|
||||
}
|
||||
|
||||
currentStep := approvalutils.ApprovalStep(latestApproval.StepNumber)
|
||||
if currentStep >= target {
|
||||
currentStepName := utils.MarketingApprovalSteps[currentStep]
|
||||
targetStepName := utils.MarketingApprovalSteps[target]
|
||||
if currentStep == target {
|
||||
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Marketing %d is already at %s step", id, targetStepName))
|
||||
}
|
||||
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Marketing %d is already beyond %s step (current step: %s)", id, targetStepName, currentStepName))
|
||||
}
|
||||
|
||||
if len(marketing.Products) > 0 {
|
||||
pwIDs := make([]uint, 0, len(marketing.Products))
|
||||
for _, product := range marketing.Products {
|
||||
if product.ProductWarehouseId != 0 {
|
||||
pwIDs = append(pwIDs, product.ProductWarehouseId)
|
||||
}
|
||||
}
|
||||
if err := commonSvc.EnsureProjectFlockNotClosedForProductWarehouses(c.Context(), tx, pwIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for step := currentStep + 1; step <= target; step++ {
|
||||
if step == utils.MarketingDeliveryOrder {
|
||||
if err := s.createDeliveryFromMarketingProducts(c.Context(), tx, marketing, deliveryDate, actorID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
approvalAction := entity.ApprovalActionApproved
|
||||
if _, err := approvalSvcTx.CreateApproval(
|
||||
c.Context(),
|
||||
utils.ApprovalWorkflowMarketing,
|
||||
id,
|
||||
step,
|
||||
&approvalAction,
|
||||
actorID,
|
||||
req.Notes,
|
||||
); err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create approval")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
if fiberErr, ok := err.(*fiber.Error); ok {
|
||||
return nil, fiberErr
|
||||
}
|
||||
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to bulk approve marketings")
|
||||
}
|
||||
|
||||
results := make([]dto.MarketingDetailDTO, 0, len(approvableIDs))
|
||||
for _, id := range approvableIDs {
|
||||
result, err := s.getMarketingWithDeliveries(c, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results = append(results, *result)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (s deliveryOrdersService) createDeliveryFromMarketingProducts(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
marketing *entity.Marketing,
|
||||
deliveryDate time.Time,
|
||||
actorID uint,
|
||||
) error {
|
||||
if marketing == nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "Marketing not found")
|
||||
}
|
||||
if tx == nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Transaction is required")
|
||||
}
|
||||
|
||||
marketingDeliveryProductRepositoryTx := marketingRepo.NewMarketingDeliveryProductRepository(tx)
|
||||
|
||||
for _, marketingProduct := range marketing.Products {
|
||||
deliveryProduct := marketingProduct.DeliveryProduct
|
||||
if deliveryProduct == nil {
|
||||
record, err := marketingDeliveryProductRepositoryTx.GetByMarketingProductID(ctx, marketingProduct.Id)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Delivery product for marketing product %d not found", marketingProduct.Id))
|
||||
}
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch delivery product")
|
||||
}
|
||||
deliveryProduct = record
|
||||
}
|
||||
|
||||
oldRequestedQty := deliveryProduct.UsageQty + deliveryProduct.PendingQty
|
||||
deliveryDateCopy := deliveryDate
|
||||
|
||||
deliveryProduct.ProductWarehouseId = marketingProduct.ProductWarehouseId
|
||||
deliveryProduct.UnitPrice = marketingProduct.UnitPrice
|
||||
deliveryProduct.AvgWeight = marketingProduct.AvgWeight
|
||||
deliveryProduct.WeightPerConvertion = marketingProduct.WeightPerConvertion
|
||||
deliveryProduct.TotalWeight = marketingProduct.TotalWeight
|
||||
deliveryProduct.TotalPrice = marketingProduct.TotalPrice
|
||||
deliveryProduct.DeliveryDate = &deliveryDateCopy
|
||||
|
||||
requestedQty := marketingProduct.Qty
|
||||
if requestedQty != oldRequestedQty {
|
||||
if oldRequestedQty > 0 {
|
||||
if err := s.releaseDeliveryStock(ctx, tx, deliveryProduct, &marketingProduct, actorID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if requestedQty > 0 {
|
||||
if err := s.consumeDeliveryStock(ctx, tx, deliveryProduct, &marketingProduct, requestedQty, actorID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := marketingDeliveryProductRepositoryTx.UpdateOne(ctx, deliveryProduct.Id, deliveryProduct, nil); err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update delivery product")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *deliveryOrdersService) calculatePriceByMarketingType(marketingType string, qty, avgWeight, unitPrice float64, week *int, convertionUnit *string, _ *float64) (totalWeight, totalPrice float64) {
|
||||
if marketingType == string(utils.MarketingTypeTrading) {
|
||||
totalWeight = 0
|
||||
@@ -1105,7 +1303,7 @@ func (s deliveryOrdersService) resolveMarketingRequestedUsageQty(ctx context.Con
|
||||
var usageQty float64
|
||||
if err := tx.WithContext(ctx).
|
||||
Table("marketing_delivery_products").
|
||||
Select("usage_qty").
|
||||
Select("usage_qty + pending_qty").
|
||||
Where("id = ?", deliveryProductID).
|
||||
Scan(&usageQty).Error; err != nil {
|
||||
return 0
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
package validation
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
||||
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
|
||||
)
|
||||
|
||||
type Create struct {
|
||||
CustomerId uint `json:"customer_id" validate:"required,gt=0"`
|
||||
SalesPersonId uint `json:"sales_person_id" validate:"required,gt=0"`
|
||||
@@ -33,3 +41,27 @@ type Approve struct {
|
||||
ApprovableIds []uint `json:"approvable_ids" validate:"required_strict,min=1,dive,gt=0"`
|
||||
Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"`
|
||||
}
|
||||
|
||||
type BulkApprovalRequest struct {
|
||||
ApprovableIds []uint `json:"approvable_ids" validate:"required,min=1,dive,gt=0"`
|
||||
Status string `json:"status" validate:"required,max=100"`
|
||||
Date string `json:"date,omitempty" validate:"omitempty,datetime=2006-01-02"`
|
||||
Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"`
|
||||
}
|
||||
|
||||
func (r *BulkApprovalRequest) ResolveTarget() (approvalutils.ApprovalStep, error) {
|
||||
status := strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(r.Status), " ", "_"))
|
||||
|
||||
switch status {
|
||||
case "SALES_ORDER":
|
||||
return utils.MarketingStepSalesOrder, nil
|
||||
case "DELIVERY", "DELIVERY_ORDER":
|
||||
return utils.MarketingDeliveryOrder, nil
|
||||
default:
|
||||
return 0, errors.New("status must be one of SALES_ORDER or DELIVERY")
|
||||
}
|
||||
}
|
||||
|
||||
func (r *BulkApprovalRequest) RequiresDate(target approvalutils.ApprovalStep) bool {
|
||||
return target == utils.MarketingDeliveryOrder
|
||||
}
|
||||
|
||||
@@ -312,10 +312,10 @@ func (u *ProjectflockController) LookupProjectFlockKandang(c *fiber.Ctx) error {
|
||||
mapped := warehouseDTO.ToWarehouseRelationDTO(*warehouse)
|
||||
dtoResult.Warehouse = &mapped
|
||||
}
|
||||
if isTransition, isLaying, serr := u.ProjectflockService.GetProjectFlockKandangTransferStateAtDate(c, result.Id, recordDate); serr != nil {
|
||||
if _, isLaying, serr := u.ProjectflockService.GetProjectFlockKandangTransferStateAtDate(c, result.Id, recordDate); serr != nil {
|
||||
return serr
|
||||
} else {
|
||||
dtoResult.IsTransition = isTransition
|
||||
dtoResult.IsTransition = false
|
||||
dtoResult.IsLaying = isLaying
|
||||
}
|
||||
applyCutOverLayingLookupOverride(&dtoResult)
|
||||
|
||||
@@ -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,
|
||||
CAST(DATE(r.record_datetime) AS TEXT) 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 := exportprogress.ParseActivityDate(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"
|
||||
@@ -32,6 +33,7 @@ import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/sirupsen/logrus"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type RecordingService interface {
|
||||
@@ -42,6 +44,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 +205,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
|
||||
@@ -355,6 +366,14 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
|
||||
}
|
||||
}
|
||||
|
||||
var stockOwnerProjectFlockKandangID *uint
|
||||
if len(req.Stocks) > 0 {
|
||||
stockOwnerProjectFlockKandangID, err = s.resolveRecordingStockOwnerProjectFlockKandangID(ctx, pfk, recordTime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
day, err := s.computeRecordingDay(ctx, pfk.Id, recordTime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -431,12 +450,12 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
|
||||
return err
|
||||
}
|
||||
|
||||
mappedStocks := recordingutil.MapStocks(createdRecording.Id, req.Stocks)
|
||||
stockDesired := resetStockQuantitiesForFIFO(mappedStocks)
|
||||
if err := s.Repository.CreateStocks(tx, mappedStocks); err != nil {
|
||||
s.Log.Errorf("Failed to persist stocks: %+v", err)
|
||||
return err
|
||||
}
|
||||
mappedStocks := recordingutil.MapStocks(createdRecording.Id, stockOwnerProjectFlockKandangID, req.Stocks)
|
||||
stockDesired := resetStockQuantitiesForFIFO(mappedStocks)
|
||||
if err := s.Repository.CreateStocks(tx, mappedStocks); err != nil {
|
||||
s.Log.Errorf("Failed to persist stocks: %+v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
for i := range mappedStocks {
|
||||
if i >= len(stockDesired) {
|
||||
@@ -500,10 +519,14 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
|
||||
s.Log.Errorf("Failed to compute recording metrics: %+v", err)
|
||||
return err
|
||||
}
|
||||
if err := s.recalculateFrom(ctx, tx, createdRecording.ProjectFlockKandangId, createdRecording.RecordDatetime); err != nil {
|
||||
s.Log.Errorf("Failed to recalculate recordings after create: %+v", err)
|
||||
return err
|
||||
}
|
||||
if err := s.recalculateFrom(ctx, tx, createdRecording.ProjectFlockKandangId, createdRecording.RecordDatetime); err != nil {
|
||||
s.Log.Errorf("Failed to recalculate recordings after create: %+v", err)
|
||||
return err
|
||||
}
|
||||
if err := s.syncFarmDepreciationManualInputFromRecordingStocks(ctx, tx, createdRecording.ProjectFlockKandangId, createdRecording.RecordDatetime); err != nil {
|
||||
s.Log.Errorf("Failed to sync farm depreciation manual input after create: %+v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
action := entity.ApprovalActionCreated
|
||||
if err := s.createRecordingApproval(ctx, tx, createdRecording.Id, utils.RecordingStepPengajuan, action, createdRecording.CreatedBy, nil); err != nil {
|
||||
@@ -564,8 +587,8 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
|
||||
return err
|
||||
}
|
||||
|
||||
recordingEntity = recording
|
||||
pfkForRoute := recordingEntity.ProjectFlockKandang
|
||||
recordingEntity = recording
|
||||
pfkForRoute := recordingEntity.ProjectFlockKandang
|
||||
if pfkForRoute == nil || pfkForRoute.Id == 0 {
|
||||
fetchedPfk, fetchErr := s.ProjectFlockKandangRepo.GetByIDLight(ctx, recordingEntity.ProjectFlockKandangId)
|
||||
if fetchErr != nil {
|
||||
@@ -576,35 +599,43 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
|
||||
return fetchErr
|
||||
}
|
||||
pfkForRoute = fetchedPfk
|
||||
}
|
||||
routePayload := buildRecordingRoutePayloadFromUpdate(req)
|
||||
if err := s.enforceTransferRecordingRoute(ctx, pfkForRoute, recordingEntity.RecordDatetime, routePayload); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := s.tryAutoExecuteTransferForRecordingCreate(c, pfkForRoute, recordingEntity.RecordDatetime); err != nil {
|
||||
return err
|
||||
}
|
||||
routePayload := buildRecordingRoutePayloadFromUpdate(req)
|
||||
if err := s.enforceTransferRecordingRoute(ctx, pfkForRoute, recordingEntity.RecordDatetime, routePayload); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hasStockChanges := req.Stocks != nil
|
||||
hasDepletionChanges := req.Depletions != nil
|
||||
hasEggChanges := req.Eggs != nil
|
||||
|
||||
var existingStocks []entity.RecordingStock
|
||||
var existingDepletions []entity.RecordingDepletion
|
||||
var existingEggs []entity.RecordingEgg
|
||||
var mappedDepletions []entity.RecordingDepletion
|
||||
var existingStocks []entity.RecordingStock
|
||||
var existingDepletions []entity.RecordingDepletion
|
||||
var existingEggs []entity.RecordingEgg
|
||||
var mappedDepletions []entity.RecordingDepletion
|
||||
var stockOwnerProjectFlockKandangID *uint
|
||||
|
||||
note := recordingutil.RecordingNote("Edit", recordingEntity.Id)
|
||||
|
||||
if hasStockChanges {
|
||||
existingStocks, err = s.Repository.ListStocks(tx, recordingEntity.Id)
|
||||
if err != nil {
|
||||
s.Log.Errorf("Failed to list existing stocks: %+v", err)
|
||||
return err
|
||||
}
|
||||
if hasStockChanges {
|
||||
stockOwnerProjectFlockKandangID, err = s.resolveRecordingStockOwnerProjectFlockKandangID(ctx, pfkForRoute, recordingEntity.RecordDatetime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
existingStocks, err = s.Repository.ListStocks(tx, recordingEntity.Id)
|
||||
if err != nil {
|
||||
s.Log.Errorf("Failed to list existing stocks: %+v", err)
|
||||
return err
|
||||
}
|
||||
existingUsage := recordingutil.StockUsageByWarehouse(existingStocks)
|
||||
incomingUsage := recordingutil.StockUsageByWarehouseReq(req.Stocks)
|
||||
match := recordingutil.FloatMapsEqual(existingUsage, incomingUsage)
|
||||
if match {
|
||||
hasStockChanges = false
|
||||
} else {
|
||||
match := recordingutil.FloatMapsEqual(existingUsage, incomingUsage) && recordingStocksAllOwnedBy(existingStocks, stockOwnerProjectFlockKandangID)
|
||||
if match {
|
||||
hasStockChanges = false
|
||||
} else {
|
||||
if err := s.ensureProductWarehousesExist(c, req.Stocks, nil, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -612,11 +643,11 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
|
||||
if err := s.ensureProductWarehousesByFlags(ctx, feedIDs, []string{"PAKAN", "OVK"}, "feed"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.reflowSyncRecordingStocks(ctx, tx, recordingEntity.Id, existingStocks, req.Stocks, note, actorID); err != nil {
|
||||
return err
|
||||
if err := s.reflowSyncRecordingStocks(ctx, tx, recordingEntity.Id, existingStocks, req.Stocks, stockOwnerProjectFlockKandangID, note, actorID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if hasDepletionChanges {
|
||||
existingDepletions, err = s.Repository.ListDepletions(tx, recordingEntity.Id)
|
||||
@@ -778,16 +809,22 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
|
||||
return err
|
||||
}
|
||||
|
||||
if hasStockChanges || hasDepletionChanges || hasEggChanges {
|
||||
if hasStockChanges || hasDepletionChanges || hasEggChanges {
|
||||
if err := s.computeAndUpdateMetrics(ctx, tx, recordingEntity); err != nil {
|
||||
s.Log.Errorf("Failed to recompute recording metrics: %+v", err)
|
||||
return err
|
||||
}
|
||||
if err := s.recalculateFrom(ctx, tx, recordingEntity.ProjectFlockKandangId, recordingEntity.RecordDatetime); err != nil {
|
||||
s.Log.Errorf("Failed to recalculate recordings after update: %+v", err)
|
||||
return err
|
||||
if err := s.recalculateFrom(ctx, tx, recordingEntity.ProjectFlockKandangId, recordingEntity.RecordDatetime); err != nil {
|
||||
s.Log.Errorf("Failed to recalculate recordings after update: %+v", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
if hasStockChanges {
|
||||
if err := s.syncFarmDepreciationManualInputFromRecordingStocks(ctx, tx, recordingEntity.ProjectFlockKandangId, recordingEntity.RecordDatetime); err != nil {
|
||||
s.Log.Errorf("Failed to sync farm depreciation manual input after update: %+v", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
action := entity.ApprovalActionUpdated
|
||||
actorID := recordingEntity.CreatedBy
|
||||
@@ -1045,11 +1082,15 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.recalculateFrom(ctx, tx, recording.ProjectFlockKandangId, recording.RecordDatetime); err != nil {
|
||||
s.Log.Errorf("Failed to recalculate recordings after delete: %+v", err)
|
||||
return err
|
||||
}
|
||||
s.invalidateDepreciationSnapshots(ctx, tx, recording.ProjectFlockKandangId, recording.RecordDatetime)
|
||||
if err := s.recalculateFrom(ctx, tx, recording.ProjectFlockKandangId, recording.RecordDatetime); err != nil {
|
||||
s.Log.Errorf("Failed to recalculate recordings after delete: %+v", err)
|
||||
return err
|
||||
}
|
||||
if err := s.syncFarmDepreciationManualInputFromRecordingStocks(ctx, tx, recording.ProjectFlockKandangId, recording.RecordDatetime); err != nil {
|
||||
s.Log.Errorf("Failed to sync farm depreciation manual input after delete: %+v", err)
|
||||
return err
|
||||
}
|
||||
s.invalidateDepreciationSnapshots(ctx, tx, recording.ProjectFlockKandangId, recording.RecordDatetime)
|
||||
|
||||
return nil
|
||||
})
|
||||
@@ -1425,12 +1466,13 @@ func (s *recordingService) tryAutoExecuteTransferForRecordingCreate(c *fiber.Ctx
|
||||
return nil
|
||||
}
|
||||
|
||||
businessDate := recordDate
|
||||
physicalMoveDate := transferPhysicalMoveDate(transfer)
|
||||
if physicalMoveDate.IsZero() || recordDate.Before(physicalMoveDate) {
|
||||
return nil
|
||||
if !physicalMoveDate.IsZero() && businessDate.Before(physicalMoveDate) {
|
||||
businessDate = physicalMoveDate
|
||||
}
|
||||
|
||||
if _, err := s.TransferLayingSvc.ExecuteWithBusinessDate(c, transfer.Id, recordDate); err != nil {
|
||||
if _, err := s.TransferLayingSvc.ExecuteWithBusinessDate(c, transfer.Id, businessDate); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1443,102 +1485,10 @@ func (s *recordingService) enforceTransferRecordingRoute(
|
||||
recordTime time.Time,
|
||||
payload recordingRoutePayload,
|
||||
) error {
|
||||
if pfk == nil || pfk.Id == 0 || s.TransferLayingRepo == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
recordDate := normalizeDateOnlyUTC(recordTime)
|
||||
category := strings.ToUpper(strings.TrimSpace(pfk.ProjectFlock.Category))
|
||||
|
||||
switch category {
|
||||
case strings.ToUpper(string(utils.ProjectFlockCategoryLaying)):
|
||||
transfer, err := s.TransferLayingRepo.GetLatestApprovedByTargetKandang(ctx, pfk.Id)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
s.Log.Errorf("Failed to resolve approved transfer by target kandang %d: %+v", pfk.Id, err)
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi transfer laying")
|
||||
}
|
||||
|
||||
physicalMoveDate, economicCutoffDate := transferRecordingWindow(transfer)
|
||||
if physicalMoveDate.IsZero() {
|
||||
return nil
|
||||
}
|
||||
|
||||
if recordDate.Before(physicalMoveDate) {
|
||||
return fiber.NewError(
|
||||
fiber.StatusBadRequest,
|
||||
fmt.Sprintf("Recording kandang laying hanya bisa dimulai pada %s (tanggal pindah fisik). Sebelumnya gunakan kandang growing", physicalMoveDate.Format("2006-01-02")),
|
||||
)
|
||||
}
|
||||
|
||||
if transfer.ExecutedAt == nil || transfer.ExecutedAt.IsZero() {
|
||||
return fiber.NewError(
|
||||
fiber.StatusBadRequest,
|
||||
fmt.Sprintf("Transfer laying %s dengan tanggal pindah fisik %s belum dieksekusi. Eksekusi transfer terlebih dahulu", transfer.TransferNumber, physicalMoveDate.Format("2006-01-02")),
|
||||
)
|
||||
}
|
||||
|
||||
if recordDate.Before(economicCutoffDate) && payload.StockCount > 0 {
|
||||
return fiber.NewError(
|
||||
fiber.StatusBadRequest,
|
||||
fmt.Sprintf(
|
||||
"Periode transisi transfer laying %s (%s s.d. %s): input PAKAN/OVK harus dicatat di kandang growing hingga %s. Recording kandang laying pada periode ini hanya untuk deplesi (dan telur bila ada).",
|
||||
transfer.TransferNumber,
|
||||
physicalMoveDate.Format("2006-01-02"),
|
||||
economicCutoffDate.AddDate(0, 0, -1).Format("2006-01-02"),
|
||||
economicCutoffDate.AddDate(0, 0, -1).Format("2006-01-02"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
case strings.ToUpper(string(utils.ProjectFlockCategoryGrowing)):
|
||||
transfer, err := s.TransferLayingRepo.GetLatestApprovedBySourceKandang(ctx, pfk.Id)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
s.Log.Errorf("Failed to resolve approved transfer by source kandang %d: %+v", pfk.Id, err)
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi transfer laying")
|
||||
}
|
||||
|
||||
physicalMoveDate, economicCutoffDate := transferRecordingWindow(transfer)
|
||||
if physicalMoveDate.IsZero() {
|
||||
return nil
|
||||
}
|
||||
|
||||
if recordDate.Before(physicalMoveDate) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if transfer.ExecutedAt == nil || transfer.ExecutedAt.IsZero() {
|
||||
return fiber.NewError(
|
||||
fiber.StatusBadRequest,
|
||||
fmt.Sprintf("Transfer laying %s sudah memasuki tanggal pindah fisik %s namun belum dieksekusi. Eksekusi transfer lalu lakukan recording transisi sesuai aturan", transfer.TransferNumber, physicalMoveDate.Format("2006-01-02")),
|
||||
)
|
||||
}
|
||||
|
||||
if !recordDate.Before(economicCutoffDate) {
|
||||
return fiber.NewError(
|
||||
fiber.StatusBadRequest,
|
||||
fmt.Sprintf("Recording kandang growing hanya diperbolehkan sampai %s. Gunakan kandang laying mulai %s", economicCutoffDate.AddDate(0, 0, -1).Format("2006-01-02"), economicCutoffDate.Format("2006-01-02")),
|
||||
)
|
||||
}
|
||||
|
||||
if payload.DepletionCount > 0 {
|
||||
return fiber.NewError(
|
||||
fiber.StatusBadRequest,
|
||||
fmt.Sprintf(
|
||||
"Periode transisi transfer laying %s (%s s.d. %s): deplesi harus dicatat di kandang laying tujuan agar mapping tidak ambigu. Kandang growing pada periode ini hanya untuk PAKAN/OVK.",
|
||||
transfer.TransferNumber,
|
||||
physicalMoveDate.Format("2006-01-02"),
|
||||
economicCutoffDate.AddDate(0, 0, -1).Format("2006-01-02"),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
_ = ctx
|
||||
_ = pfk
|
||||
_ = recordTime
|
||||
_ = payload
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1648,6 +1598,362 @@ func boolPtr(value bool) *bool {
|
||||
return &v
|
||||
}
|
||||
|
||||
func recordingStocksAllOwnedBy(stocks []entity.RecordingStock, owner *uint) bool {
|
||||
for _, stock := range stocks {
|
||||
if !uintPtrEqual(stock.ProjectFlockKandangId, owner) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func uintPtrEqual(a *uint, b *uint) bool {
|
||||
if a == nil && b == nil {
|
||||
return true
|
||||
}
|
||||
if a == nil || b == nil {
|
||||
return false
|
||||
}
|
||||
return *a == *b
|
||||
}
|
||||
|
||||
func (s *recordingService) resolveRecordingStockOwnerProjectFlockKandangID(
|
||||
ctx context.Context,
|
||||
pfk *entity.ProjectFlockKandang,
|
||||
recordTime time.Time,
|
||||
) (*uint, error) {
|
||||
if pfk == nil || pfk.Id == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
category := strings.ToUpper(strings.TrimSpace(pfk.ProjectFlock.Category))
|
||||
if category == "" {
|
||||
loaded, err := s.ProjectFlockKandangRepo.GetByIDLight(ctx, pfk.Id)
|
||||
if err == nil && loaded != nil {
|
||||
pfk = loaded
|
||||
category = strings.ToUpper(strings.TrimSpace(loaded.ProjectFlock.Category))
|
||||
}
|
||||
}
|
||||
|
||||
if category != strings.ToUpper(string(utils.ProjectFlockCategoryLaying)) {
|
||||
owner := pfk.Id
|
||||
return &owner, nil
|
||||
}
|
||||
|
||||
if s.TransferLayingRepo == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
transfer, err := s.TransferLayingRepo.GetLatestApprovedByTargetKandang(ctx, pfk.Id)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
s.Log.Errorf("Failed to resolve transfer laying for recording stock owner (target_pfk=%d): %+v", pfk.Id, err)
|
||||
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal menentukan atribusi recording stock")
|
||||
}
|
||||
if transfer == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
sourceProjectFlockKandangID, err := s.resolveTransferSourceProjectFlockKandangID(ctx, transfer)
|
||||
if err != nil {
|
||||
s.Log.Errorf("Failed to resolve transfer source kandang for transfer %d: %+v", transfer.Id, err)
|
||||
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal menentukan sumber kandang growing")
|
||||
}
|
||||
if sourceProjectFlockKandangID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
sourceChickinDate, err := s.getEarliestChickInDateByProjectFlockKandangID(ctx, sourceProjectFlockKandangID)
|
||||
if err != nil {
|
||||
s.Log.Errorf("Failed to resolve earliest chick-in date for source kandang %d: %+v", sourceProjectFlockKandangID, err)
|
||||
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal menentukan umur ayam dari growing")
|
||||
}
|
||||
if sourceChickinDate == nil || sourceChickinDate.IsZero() {
|
||||
owner := sourceProjectFlockKandangID
|
||||
return &owner, nil
|
||||
}
|
||||
|
||||
thresholdDay, err := s.resolveLayingDepreciationThresholdDay(ctx, pfk)
|
||||
if err != nil {
|
||||
s.Log.Errorf("Failed to resolve laying threshold day for kandang %d: %+v", pfk.Id, err)
|
||||
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal menentukan standar umur laying")
|
||||
}
|
||||
if thresholdDay <= 0 {
|
||||
thresholdDay = commonSvc.DepreciationStartAgeDay(resolveHouseType(pfk))
|
||||
}
|
||||
if thresholdDay <= 0 {
|
||||
thresholdDay = commonSvc.DepreciationStartAgeDay("close_house")
|
||||
}
|
||||
|
||||
recordDate := normalizeDateOnlyUTC(recordTime)
|
||||
chickinDate := normalizeDateOnlyUTC(*sourceChickinDate)
|
||||
ageDay := commonSvc.FlockAgeDay(chickinDate, recordDate)
|
||||
if ageDay < thresholdDay {
|
||||
owner := sourceProjectFlockKandangID
|
||||
return &owner, nil
|
||||
}
|
||||
|
||||
owner := pfk.Id
|
||||
return &owner, nil
|
||||
}
|
||||
|
||||
func resolveHouseType(pfk *entity.ProjectFlockKandang) string {
|
||||
if pfk == nil || pfk.Kandang.HouseType == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(*pfk.Kandang.HouseType)
|
||||
}
|
||||
|
||||
func (s *recordingService) resolveLayingDepreciationThresholdDay(ctx context.Context, pfk *entity.ProjectFlockKandang) (int, error) {
|
||||
houseType := commonSvc.NormalizeDepreciationHouseType(resolveHouseType(pfk))
|
||||
if houseType == "" {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
var row struct {
|
||||
StandardWeek int `gorm:"column:standard_week"`
|
||||
}
|
||||
err := s.Repository.DB().WithContext(ctx).
|
||||
Table("house_depreciation_standards").
|
||||
Select("standard_week").
|
||||
Where("house_type::text = ?", houseType).
|
||||
Where("standard_week > 0").
|
||||
Order("effective_date DESC NULLS LAST").
|
||||
Order("id DESC").
|
||||
Limit(1).
|
||||
Scan(&row).Error
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if row.StandardWeek <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return (row.StandardWeek * 7) + 1, nil
|
||||
}
|
||||
|
||||
func (s *recordingService) resolveTransferSourceProjectFlockKandangID(ctx context.Context, transfer *entity.LayingTransfer) (uint, error) {
|
||||
if transfer == nil || transfer.Id == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
var row struct {
|
||||
SourceProjectFlockKandangID uint `gorm:"column:source_project_flock_kandang_id"`
|
||||
}
|
||||
err := s.Repository.DB().WithContext(ctx).
|
||||
Table("laying_transfer_sources").
|
||||
Select("source_project_flock_kandang_id").
|
||||
Where("laying_transfer_id = ?", transfer.Id).
|
||||
Where("deleted_at IS NULL").
|
||||
Where("source_project_flock_kandang_id > 0").
|
||||
Order("id ASC").
|
||||
Limit(1).
|
||||
Take(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
if transfer.SourceProjectFlockKandangId != nil && *transfer.SourceProjectFlockKandangId != 0 {
|
||||
return *transfer.SourceProjectFlockKandangId, nil
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if row.SourceProjectFlockKandangID != 0 {
|
||||
return row.SourceProjectFlockKandangID, nil
|
||||
}
|
||||
if transfer.SourceProjectFlockKandangId != nil && *transfer.SourceProjectFlockKandangId != 0 {
|
||||
return *transfer.SourceProjectFlockKandangId, nil
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (s *recordingService) getEarliestChickInDateByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (*time.Time, error) {
|
||||
if projectFlockKandangID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var row struct {
|
||||
ChickInDate *time.Time `gorm:"column:chick_in_date"`
|
||||
}
|
||||
err := s.Repository.DB().WithContext(ctx).
|
||||
Table("project_chickins").
|
||||
Select("MIN(chick_in_date) AS chick_in_date").
|
||||
Where("project_flock_kandang_id = ?", projectFlockKandangID).
|
||||
Where("deleted_at IS NULL").
|
||||
Scan(&row).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return row.ChickInDate, nil
|
||||
}
|
||||
|
||||
func (s *recordingService) syncFarmDepreciationManualInputFromRecordingStocks(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
projectFlockKandangID uint,
|
||||
fallbackCutoverDate time.Time,
|
||||
) error {
|
||||
if projectFlockKandangID == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
targetDB := s.Repository.DB()
|
||||
if tx != nil {
|
||||
targetDB = tx
|
||||
}
|
||||
|
||||
projectFlockID, err := s.resolveProjectFlockIDByProjectFlockKandangID(ctx, targetDB, projectFlockKandangID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if projectFlockID == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
totalCost, err := s.sumNoTransferRecordingStockCostByProjectFlockID(ctx, targetDB, projectFlockID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
existing, err := s.getFarmDepreciationManualInputByProjectFlockID(ctx, targetDB, projectFlockID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cutoverDate := normalizeDateOnlyUTC(fallbackCutoverDate)
|
||||
if existing != nil && !existing.CutoverDate.IsZero() {
|
||||
cutoverDate = normalizeDateOnlyUTC(existing.CutoverDate)
|
||||
}
|
||||
if cutoverDate.IsZero() {
|
||||
earliestDate, dateErr := s.getEarliestNoTransferRecordingDateByProjectFlockID(ctx, targetDB, projectFlockID)
|
||||
if dateErr != nil {
|
||||
return dateErr
|
||||
}
|
||||
if earliestDate != nil && !earliestDate.IsZero() {
|
||||
cutoverDate = normalizeDateOnlyUTC(*earliestDate)
|
||||
}
|
||||
}
|
||||
if cutoverDate.IsZero() {
|
||||
cutoverDate = normalizeDateOnlyUTC(time.Now().UTC())
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
row := entity.FarmDepreciationManualInput{
|
||||
ProjectFlockId: projectFlockID,
|
||||
TotalCost: totalCost,
|
||||
CutoverDate: cutoverDate,
|
||||
}
|
||||
if existing != nil {
|
||||
row.Note = existing.Note
|
||||
}
|
||||
|
||||
return targetDB.WithContext(ctx).
|
||||
Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "project_flock_id"}},
|
||||
DoUpdates: clause.Assignments(map[string]any{
|
||||
"total_cost": row.TotalCost,
|
||||
"cutover_date": row.CutoverDate,
|
||||
"updated_at": now,
|
||||
}),
|
||||
}).
|
||||
Create(&row).Error
|
||||
}
|
||||
|
||||
func (s *recordingService) resolveProjectFlockIDByProjectFlockKandangID(ctx context.Context, db *gorm.DB, projectFlockKandangID uint) (uint, error) {
|
||||
var row struct {
|
||||
ProjectFlockID uint `gorm:"column:project_flock_id"`
|
||||
}
|
||||
err := db.WithContext(ctx).
|
||||
Table("project_flock_kandangs").
|
||||
Select("project_flock_id").
|
||||
Where("id = ?", projectFlockKandangID).
|
||||
Take(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return 0, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return row.ProjectFlockID, nil
|
||||
}
|
||||
|
||||
func (s *recordingService) sumNoTransferRecordingStockCostByProjectFlockID(ctx context.Context, db *gorm.DB, projectFlockID uint) (float64, error) {
|
||||
if projectFlockID == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
var total float64
|
||||
err := db.WithContext(ctx).
|
||||
Table("recording_stocks AS rs").
|
||||
Select("COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0)").
|
||||
Joins("JOIN recordings AS r ON r.id = rs.recording_id AND r.deleted_at IS NULL").
|
||||
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id").
|
||||
Joins(
|
||||
"JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?",
|
||||
fifo.UsableKeyRecordingStock.String(),
|
||||
fifo.StockableKeyPurchaseItems.String(),
|
||||
entity.StockAllocationStatusActive,
|
||||
entity.StockAllocationPurposeConsume,
|
||||
).
|
||||
Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id").
|
||||
Where("pfk.project_flock_id = ?", projectFlockID).
|
||||
Where("rs.project_flock_kandang_id IS NULL").
|
||||
Scan(&total).Error
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (s *recordingService) getFarmDepreciationManualInputByProjectFlockID(
|
||||
ctx context.Context,
|
||||
db *gorm.DB,
|
||||
projectFlockID uint,
|
||||
) (*entity.FarmDepreciationManualInput, error) {
|
||||
if projectFlockID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var row entity.FarmDepreciationManualInput
|
||||
err := db.WithContext(ctx).
|
||||
Where("project_flock_id = ?", projectFlockID).
|
||||
Take(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
func (s *recordingService) getEarliestNoTransferRecordingDateByProjectFlockID(
|
||||
ctx context.Context,
|
||||
db *gorm.DB,
|
||||
projectFlockID uint,
|
||||
) (*time.Time, error) {
|
||||
if projectFlockID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var row struct {
|
||||
RecordDate *time.Time `gorm:"column:record_date"`
|
||||
}
|
||||
err := db.WithContext(ctx).
|
||||
Table("recording_stocks AS rs").
|
||||
Select("MIN(r.record_datetime) AS record_date").
|
||||
Joins("JOIN recordings AS r ON r.id = rs.recording_id AND r.deleted_at IS NULL").
|
||||
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id").
|
||||
Where("pfk.project_flock_id = ?", projectFlockID).
|
||||
Where("rs.project_flock_kandang_id IS NULL").
|
||||
Scan(&row).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return row.RecordDate, nil
|
||||
}
|
||||
|
||||
func (s *recordingService) resolveEggRequestsToFarmWarehouses(
|
||||
ctx context.Context,
|
||||
pfk *entity.ProjectFlockKandang,
|
||||
@@ -2578,6 +2884,7 @@ func (s *recordingService) reflowSyncRecordingStocks(
|
||||
recordingID uint,
|
||||
existing []entity.RecordingStock,
|
||||
incoming []validation.Stock,
|
||||
ownerProjectFlockKandangID *uint,
|
||||
note string,
|
||||
actorID uint,
|
||||
) error {
|
||||
@@ -2598,21 +2905,32 @@ func (s *recordingService) reflowSyncRecordingStocks(
|
||||
if len(list) > 0 {
|
||||
stock = list[0]
|
||||
existingByWarehouse[item.ProductWarehouseId] = list[1:]
|
||||
} else {
|
||||
zero := 0.0
|
||||
stock = entity.RecordingStock{
|
||||
RecordingId: recordingID,
|
||||
ProductWarehouseId: item.ProductWarehouseId,
|
||||
UsageQty: &zero,
|
||||
PendingQty: &zero,
|
||||
} else {
|
||||
zero := 0.0
|
||||
stock = entity.RecordingStock{
|
||||
RecordingId: recordingID,
|
||||
ProductWarehouseId: item.ProductWarehouseId,
|
||||
ProjectFlockKandangId: ownerProjectFlockKandangID,
|
||||
UsageQty: &zero,
|
||||
PendingQty: &zero,
|
||||
}
|
||||
if err := s.Repository.CreateStock(tx, &stock); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := s.Repository.CreateStock(tx, &stock); err != nil {
|
||||
return err
|
||||
stock.ProjectFlockKandangId = ownerProjectFlockKandangID
|
||||
if stock.Id != 0 {
|
||||
if err := tx.Model(&entity.RecordingStock{}).
|
||||
Where("id = ?", stock.Id).
|
||||
Updates(map[string]any{
|
||||
"project_flock_kandang_id": ownerProjectFlockKandangID,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
desired := item.Qty
|
||||
stock.UsageQty = &desired
|
||||
desired := item.Qty
|
||||
stock.UsageQty = &desired
|
||||
zero := 0.0
|
||||
stock.PendingQty = &zero
|
||||
stocksToApply = append(stocksToApply, stock)
|
||||
|
||||
@@ -148,6 +148,53 @@ func TestShouldNormalizeEggRequestsOnUpdateNormalizesFarmLevelEggs(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTransferSourceProjectFlockKandangIDPrefersTransferResourceSource(t *testing.T) {
|
||||
db := setupTransferSourceResolutionTestDB(t)
|
||||
repo := repository.NewRecordingRepository(db)
|
||||
svc := &recordingService{Repository: repo}
|
||||
|
||||
transfer := &entity.LayingTransfer{
|
||||
Id: 77,
|
||||
SourceProjectFlockKandangId: uintPtrForTest(999),
|
||||
}
|
||||
|
||||
if err := db.Exec(`INSERT INTO laying_transfer_sources (id, laying_transfer_id, source_project_flock_kandang_id, deleted_at) VALUES (1, 77, 123, NULL)`).Error; err != nil {
|
||||
t.Fatalf("failed seeding laying_transfer_sources: %v", err)
|
||||
}
|
||||
|
||||
got, err := svc.resolveTransferSourceProjectFlockKandangID(context.Background(), transfer)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if got != 123 {
|
||||
t.Fatalf("expected resource source project_flock_kandang_id=123, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTransferSourceProjectFlockKandangIDFallsBackToTransferHeader(t *testing.T) {
|
||||
db := setupTransferSourceResolutionTestDB(t)
|
||||
repo := repository.NewRecordingRepository(db)
|
||||
svc := &recordingService{Repository: repo}
|
||||
|
||||
transfer := &entity.LayingTransfer{
|
||||
Id: 78,
|
||||
SourceProjectFlockKandangId: uintPtrForTest(456),
|
||||
}
|
||||
|
||||
got, err := svc.resolveTransferSourceProjectFlockKandangID(context.Background(), transfer)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if got != 456 {
|
||||
t.Fatalf("expected fallback source project_flock_kandang_id=456, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func uintPtrForTest(value uint) *uint {
|
||||
v := value
|
||||
return &v
|
||||
}
|
||||
|
||||
func setupRecordingServiceTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
|
||||
@@ -193,3 +240,23 @@ func setupRecordingServiceTestDB(t *testing.T) *gorm.DB {
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
func setupTransferSourceResolutionTestDB(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)
|
||||
}
|
||||
|
||||
if err := db.Exec(`CREATE TABLE laying_transfer_sources (
|
||||
id INTEGER PRIMARY KEY,
|
||||
laying_transfer_id INTEGER NOT NULL,
|
||||
source_project_flock_kandang_id INTEGER NOT NULL,
|
||||
deleted_at TIMESTAMP NULL
|
||||
)`).Error; err != nil {
|
||||
t.Fatalf("failed creating laying_transfer_sources schema: %v", err)
|
||||
}
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
@@ -231,6 +231,9 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create)
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.validateTargetSourceLineage(c.Context(), sourceDetail.ProjectFlockKandangId, targetKandangIDs, 0); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
transferDate, err := utils.ParseDateString(req.TransferDate)
|
||||
if err != nil {
|
||||
@@ -451,6 +454,9 @@ func (s *transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.validateTargetSourceLineage(c.Context(), sourceDetail.ProjectFlockKandangId, targetKandangIDs, id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
transferDate, err := time.Parse("2006-01-02", req.TransferDate)
|
||||
if err != nil {
|
||||
@@ -1611,6 +1617,80 @@ func (s *transferLayingService) validateKandangOwnership(
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *transferLayingService) validateTargetSourceLineage(
|
||||
ctx context.Context,
|
||||
sourceProjectFlockKandangID uint,
|
||||
targetKandangIDs []uint,
|
||||
excludeTransferID uint,
|
||||
) error {
|
||||
if sourceProjectFlockKandangID == 0 || len(targetKandangIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
seen := make(map[uint]struct{}, len(targetKandangIDs))
|
||||
for _, targetKandangID := range targetKandangIDs {
|
||||
if targetKandangID == 0 {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[targetKandangID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[targetKandangID] = struct{}{}
|
||||
|
||||
existingTransfer, err := s.Repository.GetLatestApprovedByTargetKandang(ctx, targetKandangID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
continue
|
||||
}
|
||||
s.Log.Errorf("Failed to validate transfer lineage for target kandang %d: %+v", targetKandangID, err)
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi relasi sumber transfer ke laying")
|
||||
}
|
||||
if existingTransfer == nil {
|
||||
continue
|
||||
}
|
||||
if excludeTransferID != 0 && existingTransfer.Id == excludeTransferID {
|
||||
continue
|
||||
}
|
||||
|
||||
existingSourceID := uint(0)
|
||||
if existingTransfer.SourceProjectFlockKandangId != nil && *existingTransfer.SourceProjectFlockKandangId != 0 {
|
||||
existingSourceID = *existingTransfer.SourceProjectFlockKandangId
|
||||
}
|
||||
if existingSourceID == 0 && s.LayingTransferSourceRepo != nil {
|
||||
sources, sourceErr := s.LayingTransferSourceRepo.GetByLayingTransferId(ctx, existingTransfer.Id)
|
||||
if sourceErr != nil {
|
||||
s.Log.Errorf("Failed to resolve transfer sources for lineage validation transfer=%d: %+v", existingTransfer.Id, sourceErr)
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi relasi sumber transfer ke laying")
|
||||
}
|
||||
for _, source := range sources {
|
||||
if source.SourceProjectFlockKandangId != 0 {
|
||||
existingSourceID = source.SourceProjectFlockKandangId
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if existingSourceID == 0 {
|
||||
continue
|
||||
}
|
||||
if existingSourceID == sourceProjectFlockKandangID {
|
||||
continue
|
||||
}
|
||||
|
||||
return fiber.NewError(
|
||||
fiber.StatusBadRequest,
|
||||
fmt.Sprintf(
|
||||
"Kandang tujuan %d sudah memiliki lineage sumber kandang %d dari transfer %s. Tidak boleh ganti ke sumber kandang %d.",
|
||||
targetKandangID,
|
||||
existingSourceID,
|
||||
existingTransfer.TransferNumber,
|
||||
sourceProjectFlockKandangID,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s transferLayingService) GetMaxTargetQtyPerKandang(c *fiber.Ctx, projectFlockID uint) (map[uint]float64, error) {
|
||||
|
||||
if err := commonSvc.EnsureRelations(c.Context(),
|
||||
|
||||
@@ -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,
|
||||
CAST(DATE(p.po_date) AS TEXT) 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 := exportprogress.ParseActivityDate(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 {
|
||||
|
||||
@@ -221,7 +221,7 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context,
|
||||
feedQuery := r.db.WithContext(ctx).
|
||||
Table("recordings AS r").
|
||||
Select(`
|
||||
r.project_flock_kandangs_id AS project_flock_kandang_id,
|
||||
rs.project_flock_kandang_id AS project_flock_kandang_id,
|
||||
COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0) AS feed_cost,
|
||||
s.id AS supplier_id,
|
||||
s.name AS supplier_name,
|
||||
@@ -233,10 +233,10 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context,
|
||||
Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id").
|
||||
Joins("LEFT JOIN purchases AS pur ON pur.id = pi.purchase_id").
|
||||
Joins("LEFT JOIN suppliers AS s ON s.id = pur.supplier_id").
|
||||
Where("r.project_flock_kandangs_id IN ?", projectFlockKandangIDs).
|
||||
Where("rs.project_flock_kandang_id IN ?", projectFlockKandangIDs).
|
||||
Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end).
|
||||
Where("f.name = ?", utils.FlagPakan).
|
||||
Group("r.project_flock_kandangs_id, s.id, s.name, s.alias")
|
||||
Group("rs.project_flock_kandang_id, s.id, s.name, s.alias")
|
||||
|
||||
if err := feedQuery.Scan(&feedRows).Error; err != nil {
|
||||
return nil, nil, err
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
|
||||
approvalService "gitlab.com/mbugroup/lti-api.git/internal/common/service"
|
||||
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||
expenseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories"
|
||||
dto "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto"
|
||||
repportRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/repositories"
|
||||
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/validations"
|
||||
@@ -21,12 +22,17 @@ import (
|
||||
|
||||
type expenseDepreciationRepoMock struct {
|
||||
repportRepo.ExpenseDepreciationRepository
|
||||
manualInputs []repportRepo.FarmDepreciationManualInputRow
|
||||
manualInputs []repportRepo.FarmDepreciationManualInputRow
|
||||
candidateRows []repportRepo.FarmDepreciationCandidateRow
|
||||
snapshots []entity.FarmDepreciationSnapshot
|
||||
|
||||
upsertedRow *entity.FarmDepreciationManualInput
|
||||
deleteCalled bool
|
||||
deleteDate time.Time
|
||||
deleteFarmIDs []uint
|
||||
upsertedRow *entity.FarmDepreciationManualInput
|
||||
deleteCalled bool
|
||||
deleteDate time.Time
|
||||
deleteFarmIDs []uint
|
||||
upsertSnapshotCalls int
|
||||
upsertedSnapshots []entity.FarmDepreciationSnapshot
|
||||
getSnapshotsCalls int
|
||||
}
|
||||
|
||||
func (m *expenseDepreciationRepoMock) DB() *gorm.DB {
|
||||
@@ -46,6 +52,37 @@ func (m *expenseDepreciationRepoMock) UpsertManualInput(_ context.Context, row *
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *expenseDepreciationRepoMock) GetCandidateFarms(_ context.Context, _ time.Time, _ []int64, _ []int64, _ []int64) ([]repportRepo.FarmDepreciationCandidateRow, error) {
|
||||
return append([]repportRepo.FarmDepreciationCandidateRow{}, m.candidateRows...), nil
|
||||
}
|
||||
|
||||
func (m *expenseDepreciationRepoMock) GetSnapshotsByPeriodAndFarmIDs(_ context.Context, period time.Time, farmIDs []uint) ([]entity.FarmDepreciationSnapshot, error) {
|
||||
m.getSnapshotsCalls++
|
||||
if len(farmIDs) == 0 {
|
||||
return []entity.FarmDepreciationSnapshot{}, nil
|
||||
}
|
||||
allowed := make(map[uint]struct{}, len(farmIDs))
|
||||
for _, farmID := range farmIDs {
|
||||
allowed[farmID] = struct{}{}
|
||||
}
|
||||
result := make([]entity.FarmDepreciationSnapshot, 0, len(m.snapshots))
|
||||
for _, row := range m.snapshots {
|
||||
if _, ok := allowed[row.ProjectFlockId]; !ok {
|
||||
continue
|
||||
}
|
||||
if row.PeriodDate.IsZero() || row.PeriodDate.Format("2006-01-02") == period.Format("2006-01-02") {
|
||||
result = append(result, row)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *expenseDepreciationRepoMock) UpsertSnapshots(_ context.Context, rows []entity.FarmDepreciationSnapshot) error {
|
||||
m.upsertSnapshotCalls++
|
||||
m.upsertedSnapshots = append([]entity.FarmDepreciationSnapshot{}, rows...)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *expenseDepreciationRepoMock) DeleteSnapshotsFromDate(_ context.Context, fromDate time.Time, farmIDs []uint) error {
|
||||
m.deleteCalled = true
|
||||
m.deleteDate = fromDate
|
||||
@@ -57,6 +94,15 @@ func (m *expenseDepreciationRepoMock) GetLatestManualInputsByFarms(_ context.Con
|
||||
return append([]repportRepo.FarmDepreciationManualInputRow{}, m.manualInputs...), nil
|
||||
}
|
||||
|
||||
type expenseRealizationRepoMock struct {
|
||||
expenseRepo.ExpenseRealizationRepository
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func (m *expenseRealizationRepoMock) DB() *gorm.DB {
|
||||
return m.db
|
||||
}
|
||||
|
||||
type hppCostRepoMock struct {
|
||||
commonRepo.HppCostRepository
|
||||
kandangIDsByFarm map[uint][]uint
|
||||
@@ -352,6 +398,167 @@ func TestComputeExpenseDepreciationSnapshots_ZeroWhenDepreciationMissing(t *test
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetExpenseDepreciation_UsesExistingSnapshotWhenForceRecomputeFalse(t *testing.T) {
|
||||
periodDate := mustJakartaDate(t, "2026-06-05")
|
||||
repo := &expenseDepreciationRepoMock{
|
||||
candidateRows: []repportRepo.FarmDepreciationCandidateRow{
|
||||
{ProjectFlockID: 1, FarmName: "Farm A"},
|
||||
},
|
||||
snapshots: []entity.FarmDepreciationSnapshot{
|
||||
{
|
||||
ProjectFlockId: 1,
|
||||
PeriodDate: periodDate,
|
||||
DepreciationPercentEffective: 11.1,
|
||||
DepreciationValue: 111,
|
||||
PulletCostDayNTotal: 1001,
|
||||
Components: json.RawMessage(`{"kandang_count":0,"kandang":[]}`),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
svc := &repportService{
|
||||
Validate: validator.New(),
|
||||
ExpenseDepreciationRepo: repo,
|
||||
ExpenseRealizationRepo: &expenseRealizationRepoMock{},
|
||||
HppCostRepo: &hppCostRepoMock{},
|
||||
HppV2Svc: &hppV2ServiceMock{},
|
||||
}
|
||||
|
||||
rows, meta, err := getExpenseDepreciationByQuery(t, svc, "page=1&limit=10&period=2026-06-05")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("expected 1 row, got %d", len(rows))
|
||||
}
|
||||
if rows[0].DepreciationValue != 111 {
|
||||
t.Fatalf("expected depreciation value 111, got %v", rows[0].DepreciationValue)
|
||||
}
|
||||
if meta == nil || meta.TotalResults != 1 {
|
||||
t.Fatalf("expected meta total_results 1, got %+v", meta)
|
||||
}
|
||||
if repo.upsertSnapshotCalls != 0 {
|
||||
t.Fatalf("expected no snapshot upsert, got %d", repo.upsertSnapshotCalls)
|
||||
}
|
||||
if repo.getSnapshotsCalls != 1 {
|
||||
t.Fatalf("expected snapshot fetch called once, got %d", repo.getSnapshotsCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetExpenseDepreciation_ForceRecomputeRebuildsAllSnapshots(t *testing.T) {
|
||||
periodDate := mustJakartaDate(t, "2026-06-05")
|
||||
repo := &expenseDepreciationRepoMock{
|
||||
candidateRows: []repportRepo.FarmDepreciationCandidateRow{
|
||||
{ProjectFlockID: 1, FarmName: "Farm A"},
|
||||
},
|
||||
snapshots: []entity.FarmDepreciationSnapshot{
|
||||
{
|
||||
ProjectFlockId: 1,
|
||||
PeriodDate: periodDate,
|
||||
DepreciationValue: 999,
|
||||
PulletCostDayNTotal: 999,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
svc := &repportService{
|
||||
Validate: validator.New(),
|
||||
ExpenseDepreciationRepo: repo,
|
||||
ExpenseRealizationRepo: &expenseRealizationRepoMock{},
|
||||
HppCostRepo: &hppCostRepoMock{
|
||||
kandangIDsByFarm: map[uint][]uint{
|
||||
1: {10},
|
||||
},
|
||||
},
|
||||
HppV2Svc: &hppV2ServiceMock{
|
||||
breakdownByPFK: map[uint]*approvalService.HppV2Breakdown{
|
||||
10: depreciationBreakdown(10, 100, "Kandang A", 100, 1000, 10),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
rows, _, err := getExpenseDepreciationByQuery(t, svc, "page=1&limit=10&period=2026-06-05&force_recompute=true")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("expected 1 row, got %d", len(rows))
|
||||
}
|
||||
if rows[0].DepreciationValue != 100 {
|
||||
t.Fatalf("expected recomputed depreciation value 100, got %v", rows[0].DepreciationValue)
|
||||
}
|
||||
if repo.upsertSnapshotCalls != 1 {
|
||||
t.Fatalf("expected snapshot upsert called once, got %d", repo.upsertSnapshotCalls)
|
||||
}
|
||||
if len(repo.upsertedSnapshots) != 1 || repo.upsertedSnapshots[0].ProjectFlockId != 1 {
|
||||
t.Fatalf("expected upserted snapshot for farm 1, got %+v", repo.upsertedSnapshots)
|
||||
}
|
||||
if repo.getSnapshotsCalls != 0 {
|
||||
t.Fatalf("expected no snapshot fetch in force recompute mode, got %d", repo.getSnapshotsCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetExpenseDepreciation_ForceRecomputeFalseComputesOnlyMissingFarms(t *testing.T) {
|
||||
periodDate := mustJakartaDate(t, "2026-06-05")
|
||||
repo := &expenseDepreciationRepoMock{
|
||||
candidateRows: []repportRepo.FarmDepreciationCandidateRow{
|
||||
{ProjectFlockID: 1, FarmName: "Farm A"},
|
||||
{ProjectFlockID: 2, FarmName: "Farm B"},
|
||||
},
|
||||
snapshots: []entity.FarmDepreciationSnapshot{
|
||||
{
|
||||
ProjectFlockId: 1,
|
||||
PeriodDate: periodDate,
|
||||
DepreciationPercentEffective: 11.1,
|
||||
DepreciationValue: 111,
|
||||
PulletCostDayNTotal: 1001,
|
||||
Components: json.RawMessage(`{"kandang_count":0,"kandang":[]}`),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
svc := &repportService{
|
||||
Validate: validator.New(),
|
||||
ExpenseDepreciationRepo: repo,
|
||||
ExpenseRealizationRepo: &expenseRealizationRepoMock{},
|
||||
HppCostRepo: &hppCostRepoMock{
|
||||
kandangIDsByFarm: map[uint][]uint{
|
||||
1: {10},
|
||||
2: {20},
|
||||
},
|
||||
},
|
||||
HppV2Svc: &hppV2ServiceMock{
|
||||
breakdownByPFK: map[uint]*approvalService.HppV2Breakdown{
|
||||
10: depreciationBreakdown(10, 100, "Kandang A", 999, 9999, 10),
|
||||
20: depreciationBreakdown(20, 200, "Kandang B", 200, 2000, 10),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
rows, _, err := getExpenseDepreciationByQuery(t, svc, "page=1&limit=10&period=2026-06-05")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("expected 2 rows, got %d", len(rows))
|
||||
}
|
||||
if rows[0].ProjectFlockID != 1 || rows[0].DepreciationValue != 111 {
|
||||
t.Fatalf("expected farm 1 use existing snapshot value 111, got %+v", rows[0])
|
||||
}
|
||||
if rows[1].ProjectFlockID != 2 || rows[1].DepreciationValue != 200 {
|
||||
t.Fatalf("expected farm 2 recomputed value 200, got %+v", rows[1])
|
||||
}
|
||||
if repo.upsertSnapshotCalls != 1 {
|
||||
t.Fatalf("expected one upsert call for missing farms, got %d", repo.upsertSnapshotCalls)
|
||||
}
|
||||
if len(repo.upsertedSnapshots) != 1 || repo.upsertedSnapshots[0].ProjectFlockId != 2 {
|
||||
t.Fatalf("expected upsert only farm 2 snapshot, got %+v", repo.upsertedSnapshots)
|
||||
}
|
||||
if repo.getSnapshotsCalls != 1 {
|
||||
t.Fatalf("expected snapshot fetch called once, got %d", repo.getSnapshotsCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertExpenseDepreciationManualInput_InvalidatesSnapshotsFromCutoverDate(t *testing.T) {
|
||||
repo := &expenseDepreciationRepoMock{
|
||||
manualInputs: []repportRepo.FarmDepreciationManualInputRow{
|
||||
@@ -411,6 +618,82 @@ func TestUpsertExpenseDepreciationManualInput_InvalidatesSnapshotsFromCutoverDat
|
||||
}
|
||||
}
|
||||
|
||||
func getExpenseDepreciationByQuery(t *testing.T, svc *repportService, query string) ([]dto.ExpenseDepreciationRowDTO, *dto.ExpenseDepreciationMetaDTO, error) {
|
||||
t.Helper()
|
||||
|
||||
app := fiber.New()
|
||||
var (
|
||||
rows []dto.ExpenseDepreciationRowDTO
|
||||
meta *dto.ExpenseDepreciationMetaDTO
|
||||
)
|
||||
app.Get("/", func(c *fiber.Ctx) error {
|
||||
resultRows, resultMeta, err := svc.GetExpenseDepreciation(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows = resultRows
|
||||
meta = resultMeta
|
||||
return c.SendStatus(fiber.StatusOK)
|
||||
})
|
||||
|
||||
target := "/"
|
||||
if query != "" {
|
||||
target += "?" + query
|
||||
}
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodGet, target, nil))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if resp.StatusCode != fiber.StatusOK {
|
||||
return nil, nil, fiber.NewError(resp.StatusCode, "request failed")
|
||||
}
|
||||
return rows, meta, nil
|
||||
}
|
||||
|
||||
func depreciationBreakdown(
|
||||
projectFlockKandangID uint,
|
||||
kandangID uint,
|
||||
kandangName string,
|
||||
depreciationValue float64,
|
||||
pulletCostDayN float64,
|
||||
depreciationPercent float64,
|
||||
) *approvalService.HppV2Breakdown {
|
||||
return &approvalService.HppV2Breakdown{
|
||||
ProjectFlockKandangID: projectFlockKandangID,
|
||||
KandangID: kandangID,
|
||||
KandangName: kandangName,
|
||||
HouseType: "close_house",
|
||||
Components: []approvalService.HppV2Component{
|
||||
{
|
||||
Code: "DEPRECIATION",
|
||||
Title: "Depreciation",
|
||||
Total: depreciationValue,
|
||||
Parts: []approvalService.HppV2ComponentPart{
|
||||
{
|
||||
Code: "normal_transfer",
|
||||
Total: depreciationValue,
|
||||
Details: map[string]any{
|
||||
"schedule_day": 1,
|
||||
"depreciation_percent": depreciationPercent,
|
||||
"pullet_cost_day_n": pulletCostDayN,
|
||||
"source_project_flock_id": 77,
|
||||
"origin_date": "2026-01-01",
|
||||
},
|
||||
References: []approvalService.HppV2Reference{
|
||||
{
|
||||
Type: "laying_transfer",
|
||||
ID: 701,
|
||||
Date: "2026-05-20",
|
||||
Qty: 100,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func decodeDepreciationComponents(t *testing.T, raw []byte) depreciationFarmComponents {
|
||||
t.Helper()
|
||||
var out depreciationFarmComponents
|
||||
|
||||
@@ -231,25 +231,9 @@ func (s *repportService) GetExpenseDepreciation(ctx *fiber.Ctx) ([]dto.ExpenseDe
|
||||
farmNameByID[row.ProjectFlockID] = row.FarmName
|
||||
}
|
||||
|
||||
snapshots, err := s.ExpenseDepreciationRepo.GetSnapshotsByPeriodAndFarmIDs(ctx.Context(), periodDate, farmIDs)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
snapshotByFarmID := make(map[uint]entity.FarmDepreciationSnapshot, len(snapshots))
|
||||
for _, row := range snapshots {
|
||||
snapshotByFarmID[row.ProjectFlockId] = row
|
||||
}
|
||||
|
||||
missingFarmIDs := make([]uint, 0)
|
||||
for _, farmID := range farmIDs {
|
||||
if _, exists := snapshotByFarmID[farmID]; exists {
|
||||
continue
|
||||
}
|
||||
missingFarmIDs = append(missingFarmIDs, farmID)
|
||||
}
|
||||
|
||||
if len(missingFarmIDs) > 0 {
|
||||
computedSnapshots, computeErr := s.computeExpenseDepreciationSnapshots(ctx.Context(), periodDate, missingFarmIDs, farmNameByID)
|
||||
snapshotByFarmID := make(map[uint]entity.FarmDepreciationSnapshot)
|
||||
if params.ForceRecompute {
|
||||
computedSnapshots, computeErr := s.computeExpenseDepreciationSnapshots(ctx.Context(), periodDate, farmIDs, farmNameByID)
|
||||
if computeErr != nil {
|
||||
return nil, nil, computeErr
|
||||
}
|
||||
@@ -257,10 +241,43 @@ func (s *repportService) GetExpenseDepreciation(ctx *fiber.Ctx) ([]dto.ExpenseDe
|
||||
if err := s.ExpenseDepreciationRepo.UpsertSnapshots(ctx.Context(), computedSnapshots); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
snapshotByFarmID = make(map[uint]entity.FarmDepreciationSnapshot, len(computedSnapshots))
|
||||
for _, row := range computedSnapshots {
|
||||
snapshotByFarmID[row.ProjectFlockId] = row
|
||||
}
|
||||
}
|
||||
} else {
|
||||
snapshots, err := s.ExpenseDepreciationRepo.GetSnapshotsByPeriodAndFarmIDs(ctx.Context(), periodDate, farmIDs)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
snapshotByFarmID = make(map[uint]entity.FarmDepreciationSnapshot, len(snapshots))
|
||||
for _, row := range snapshots {
|
||||
snapshotByFarmID[row.ProjectFlockId] = row
|
||||
}
|
||||
|
||||
missingFarmIDs := make([]uint, 0)
|
||||
for _, farmID := range farmIDs {
|
||||
if _, exists := snapshotByFarmID[farmID]; exists {
|
||||
continue
|
||||
}
|
||||
missingFarmIDs = append(missingFarmIDs, farmID)
|
||||
}
|
||||
|
||||
if len(missingFarmIDs) > 0 {
|
||||
computedSnapshots, computeErr := s.computeExpenseDepreciationSnapshots(ctx.Context(), periodDate, missingFarmIDs, farmNameByID)
|
||||
if computeErr != nil {
|
||||
return nil, nil, computeErr
|
||||
}
|
||||
if len(computedSnapshots) > 0 {
|
||||
if err := s.ExpenseDepreciationRepo.UpsertSnapshots(ctx.Context(), computedSnapshots); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for _, row := range computedSnapshots {
|
||||
snapshotByFarmID[row.ProjectFlockId] = row
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rows := make([]dto.ExpenseDepreciationRowDTO, 0, len(candidateRows))
|
||||
@@ -2366,8 +2383,8 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes
|
||||
}
|
||||
if hppCost != nil {
|
||||
eggPiecesFloatRemaining = hppCost.Estimation.Butir - hppCost.Real.Butir
|
||||
// eggHpp = hppCost.Estimation.HargaKg
|
||||
eggHpp = hppCost.Real.HargaKg
|
||||
eggHpp = hppCost.Estimation.HargaKg
|
||||
// eggHpp = hppCost.Real.HargaKg
|
||||
eggTotalPiecesFloat = hppCost.Estimation.Butir
|
||||
eggWeightFloat = hppCost.Estimation.Kg
|
||||
if eggTotalPiecesFloat > 0 {
|
||||
@@ -2717,6 +2734,7 @@ func (s *repportService) parseExpenseDepreciationQuery(ctx *fiber.Ctx) (*validat
|
||||
rawLocation := ctx.Query("location_id", "")
|
||||
rawProjectFlock := ctx.Query("project_flock_id", "")
|
||||
period := strings.TrimSpace(ctx.Query("period", ""))
|
||||
forceRecompute := ctx.QueryBool("force_recompute", false)
|
||||
|
||||
areaIDs, err := parseCommaSeparatedInt64s(rawArea)
|
||||
if err != nil {
|
||||
@@ -2766,6 +2784,7 @@ func (s *repportService) parseExpenseDepreciationQuery(ctx *fiber.Ctx) (*validat
|
||||
Page: page,
|
||||
Limit: limit,
|
||||
Period: period,
|
||||
ForceRecompute: forceRecompute,
|
||||
ProjectFlockIDs: projectFlockIDs,
|
||||
AreaIDs: areaIDs,
|
||||
LocationIDs: locationIDs,
|
||||
|
||||
@@ -84,6 +84,7 @@ type ExpenseDepreciationQuery struct {
|
||||
Page int `query:"page" validate:"omitempty,min=1,gt=0"`
|
||||
Limit int `query:"limit" validate:"omitempty,min=1,max=1000,gt=0"`
|
||||
Period string `query:"period" validate:"required,datetime=2006-01-02"`
|
||||
ForceRecompute bool `query:"force_recompute"`
|
||||
ProjectFlockIDs []int64 `query:"-"`
|
||||
AreaIDs []int64 `query:"-"`
|
||||
LocationIDs []int64 `query:"-"`
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations"
|
||||
)
|
||||
|
||||
func MapStocks(recordingID uint, items []validation.Stock) []entity.RecordingStock {
|
||||
func MapStocks(recordingID uint, ownerProjectFlockKandangID *uint, items []validation.Stock) []entity.RecordingStock {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -18,9 +18,10 @@ func MapStocks(recordingID uint, items []validation.Stock) []entity.RecordingSto
|
||||
usagePtr := new(float64)
|
||||
*usagePtr = item.Qty
|
||||
result = append(result, entity.RecordingStock{
|
||||
RecordingId: recordingID,
|
||||
ProductWarehouseId: item.ProductWarehouseId,
|
||||
UsageQty: usagePtr,
|
||||
RecordingId: recordingID,
|
||||
ProductWarehouseId: item.ProductWarehouseId,
|
||||
ProjectFlockKandangId: ownerProjectFlockKandangID,
|
||||
UsageQty: usagePtr,
|
||||
})
|
||||
}
|
||||
return result
|
||||
|
||||
Reference in New Issue
Block a user