Merge branch 'development' into 'production'

Development

See merge request mbugroup/lti-api!436
This commit is contained in:
Adnan Zahir
2026-04-22 13:13:06 +07:00
49 changed files with 3619 additions and 227 deletions
+1
View File
@@ -30,3 +30,4 @@ coverage/
.idea/
*.swp
.DS_Store
.gemini/
+604
View File
@@ -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
@@ -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;
@@ -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"`
+6 -5
View File
@@ -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)
}
+1
View File
@@ -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
}
+1
View File
@@ -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:"-"`
+5 -4
View File
@@ -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