feat: export input progress report for expenses, marketings, purchases, and recordings

This commit is contained in:
Adnan Zahir
2026-04-21 21:24:19 +07:00
parent a98a709766
commit 5e7c51e9c2
18 changed files with 1378 additions and 0 deletions
@@ -6,7 +6,9 @@ import (
"math"
"strconv"
"strings"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
"gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/dto"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/services"
@@ -29,6 +31,25 @@ func NewExpenseController(expenseService service.ExpenseService) *ExpenseControl
}
func (u *ExpenseController) GetAll(c *fiber.Ctx) error {
if exportprogress.IsProgressExportRequest(c) {
query, err := exportprogress.ParseQuery(c)
if err != nil {
return err
}
rows, err := u.ExpenseService.GetProgressRows(c, query)
if err != nil {
return err
}
content, err := exportprogress.BuildWorkbook("Expenses", query, rows)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to generate progress excel file")
}
filename := fmt.Sprintf("expenses_progress_%s.xlsx", time.Now().Format("20060102_150405"))
c.Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
return c.Status(fiber.StatusOK).Send(content)
}
query := &validation.Query{
Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10),
@@ -6,6 +6,7 @@ import (
"fmt"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
"gitlab.com/mbugroup/lti-api.git/internal/common/repository"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
@@ -20,6 +21,7 @@ type ExpenseRepository interface {
WithProjectFlockKandangFilter(pfkID, kandangID uint) func(*gorm.DB) *gorm.DB
CountUnfinishedByProjectFlockKandang(ctx context.Context, pfkID, kandangID uint, isFinished func(*entity.Approval) bool) (int64, error)
DeleteOne(ctx context.Context, id uint) error
GetProgressRows(ctx context.Context, startDate, endDate time.Time, allowedLocationIDs []uint, restrict bool) ([]exportprogress.Row, error)
}
type ExpenseRepositoryImpl struct {
@@ -130,3 +132,64 @@ func (r *ExpenseRepositoryImpl) DeleteOne(ctx context.Context, id uint) error {
}
return nil
}
func (r *ExpenseRepositoryImpl) GetProgressRows(ctx context.Context, startDate, endDate time.Time, allowedLocationIDs []uint, restrict bool) ([]exportprogress.Row, error) {
const unassignedSQL = "'" + exportprogress.UnassignedKandangName + "'"
query := r.DB().WithContext(ctx).
Table("expenses AS e").
Select(`
'Expenses' AS module,
COALESCE(pf.flock_name, loc.name, fallback_loc.name, 'Unknown Farm') AS farm_name,
COALESCE(k.name, `+unassignedSQL+`, 'Unknown Kandang') AS kandang_name,
DATE(e.transaction_date) AS activity_date,
COUNT(*) AS count
`).
Joins("LEFT JOIN (SELECT DISTINCT expense_id, project_flock_kandang_id, kandang_id FROM expense_nonstocks) en ON en.expense_id = e.id").
Joins("LEFT JOIN project_flock_kandangs pfk ON pfk.id = en.project_flock_kandang_id").
Joins("LEFT JOIN project_flocks pf ON pf.id = pfk.project_flock_id").
Joins("LEFT JOIN kandangs k ON k.id = COALESCE(en.kandang_id, pfk.kandang_id)").
Joins("LEFT JOIN locations loc ON loc.id = k.location_id").
Joins("LEFT JOIN locations fallback_loc ON fallback_loc.id = e.location_id").
Where("e.deleted_at IS NULL").
Where("DATE(e.transaction_date) >= DATE(?)", startDate).
Where("DATE(e.transaction_date) <= DATE(?)", endDate)
if restrict {
if len(allowedLocationIDs) == 0 {
return []exportprogress.Row{}, nil
}
query = query.Where("e.location_id IN ?", allowedLocationIDs)
}
type progressRowResult struct {
Module string
FarmName string
KandangName string
ActivityDate string
Count int
}
scanned := make([]progressRowResult, 0)
err := query.
Group("DATE(e.transaction_date), COALESCE(pf.flock_name, loc.name, fallback_loc.name, 'Unknown Farm'), COALESCE(k.name, " + unassignedSQL + ", 'Unknown Kandang')").
Order("activity_date ASC, farm_name ASC, kandang_name ASC").
Scan(&scanned).Error
if err != nil {
return nil, err
}
rows := make([]exportprogress.Row, 0, len(scanned))
for _, item := range scanned {
activityDate, err := time.Parse("2006-01-02", item.ActivityDate)
if err != nil {
return nil, err
}
rows = append(rows, exportprogress.Row{
Module: item.Module,
FarmName: item.FarmName,
KandangName: item.KandangName,
ActivityDate: activityDate,
Count: item.Count,
})
}
return rows, nil
}
@@ -0,0 +1,72 @@
package repository
import (
"context"
"testing"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func TestExpenseRepositoryGetProgressRows(t *testing.T) {
db := openExpenseProgressTestDB(t)
repo := NewExpenseRepository(db)
mustExec(t, db, `CREATE TABLE locations (id INTEGER PRIMARY KEY, name TEXT)`)
mustExec(t, db, `CREATE TABLE project_flocks (id INTEGER PRIMARY KEY, flock_name TEXT)`)
mustExec(t, db, `CREATE TABLE kandangs (id INTEGER PRIMARY KEY, name TEXT, location_id INTEGER)`)
mustExec(t, db, `CREATE TABLE project_flock_kandangs (id INTEGER PRIMARY KEY, project_flock_id INTEGER, kandang_id INTEGER)`)
mustExec(t, db, `CREATE TABLE expenses (id INTEGER PRIMARY KEY, location_id INTEGER, transaction_date DATE, deleted_at DATETIME)`)
mustExec(t, db, `CREATE TABLE expense_nonstocks (id INTEGER PRIMARY KEY, expense_id INTEGER, project_flock_kandang_id INTEGER, kandang_id INTEGER)`)
mustExec(t, db, `INSERT INTO locations (id, name) VALUES (1, 'Farm Location')`)
mustExec(t, db, `INSERT INTO project_flocks (id, flock_name) VALUES (1, 'Farm A')`)
mustExec(t, db, `INSERT INTO kandangs (id, name, location_id) VALUES (1, 'Kandang 1', 1), (2, 'Kandang 2', 1)`)
mustExec(t, db, `INSERT INTO project_flock_kandangs (id, project_flock_id, kandang_id) VALUES (1, 1, 1), (2, 1, 2)`)
mustExec(t, db, `INSERT INTO expenses (id, location_id, transaction_date, deleted_at) VALUES (1, 1, '2026-06-10', NULL), (2, 1, '2026-06-10', NULL)`)
mustExec(t, db, `INSERT INTO expense_nonstocks (id, expense_id, project_flock_kandang_id, kandang_id) VALUES
(1, 1, 1, NULL),
(2, 1, 1, NULL),
(3, 1, 2, NULL)`)
rows, err := repo.GetProgressRows(context.Background(), time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC), time.Date(2026, 6, 30, 0, 0, 0, 0, time.UTC), nil, false)
if err != nil {
t.Fatalf("GetProgressRows failed: %v", err)
}
if len(rows) != 3 {
t.Fatalf("expected 3 grouped rows, got %d", len(rows))
}
assertProgressRow(t, rows, "Farm A", "Kandang 1", "2026-06-10", 1)
assertProgressRow(t, rows, "Farm A", "Kandang 2", "2026-06-10", 1)
assertProgressRow(t, rows, "Farm Location", "Farm-level / Unassigned", "2026-06-10", 1)
}
func openExpenseProgressTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{})
if err != nil {
t.Fatalf("failed opening sqlite db: %v", err)
}
return db
}
func mustExec(t *testing.T, db *gorm.DB, query string, args ...any) {
t.Helper()
if err := db.Exec(query, args...).Error; err != nil {
t.Fatalf("exec failed for %q: %v", query, err)
}
}
func assertProgressRow(t *testing.T, rows []exportprogress.Row, farm, kandang, date string, count int) {
t.Helper()
for _, row := range rows {
if row.FarmName == farm && row.KandangName == kandang && row.ActivityDate.Format("2006-01-02") == date && row.Count == count {
return
}
}
t.Fatalf("expected row farm=%s kandang=%s date=%s count=%d, got %+v", farm, kandang, date, count, rows)
}
@@ -7,6 +7,7 @@ import (
"fmt"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
@@ -38,6 +39,7 @@ type ExpenseService interface {
DeleteDocument(ctx *fiber.Ctx, expenseID uint, documentID uint64, isRealization bool) error
Approval(ctx *fiber.Ctx, req *validation.ApprovalRequest, approvalType string) ([]expenseDto.ExpenseDetailDTO, error)
BulkApproveToStatus(ctx *fiber.Ctx, req *validation.BulkApprovalRequest, target approvalutils.ApprovalStep) ([]expenseDto.ExpenseDetailDTO, error)
GetProgressRows(ctx *fiber.Ctx, query *exportprogress.Query) ([]exportprogress.Row, error)
}
type expenseService struct {
@@ -156,6 +158,14 @@ func (s expenseService) GetOne(c *fiber.Ctx, id uint) (*expenseDto.ExpenseDetail
return &responseDTO, nil
}
func (s expenseService) GetProgressRows(c *fiber.Ctx, query *exportprogress.Query) ([]exportprogress.Row, error) {
locationScope, err := middleware.ResolveLocationScope(c, s.Repository.DB())
if err != nil {
return nil, err
}
return s.Repository.GetProgressRows(c.Context(), query.StartDate, query.EndDate, locationScope.IDs, locationScope.Restrict)
}
func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expenseDto.ExpenseDetailDTO, error) {
if err := s.Validate.Struct(req); err != nil {
return nil, err
@@ -1,10 +1,13 @@
package controller
import (
"fmt"
"math"
"strconv"
"strings"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
"gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/dto"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/services"
@@ -27,6 +30,25 @@ func NewDeliveryOrdersController(deliveryOrdersService service.DeliveryOrdersSer
}
func (u *DeliveryOrdersController) GetAll(c *fiber.Ctx) error {
if exportprogress.IsProgressExportRequest(c) {
query, err := exportprogress.ParseQuery(c)
if err != nil {
return err
}
rows, err := u.DeliveryOrdersService.GetProgressRows(c, query)
if err != nil {
return err
}
content, err := exportprogress.BuildWorkbook("Marketings", query, rows)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to generate progress excel file")
}
filename := fmt.Sprintf("marketings_progress_%s.xlsx", time.Now().Format("20060102_150405"))
c.Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
return c.Status(fiber.StatusOK).Send(content)
}
parseUintListParam := func(param string) ([]uint, error) {
if param == "" {
return nil, nil
@@ -0,0 +1,75 @@
package repository
import (
"context"
"testing"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func TestMarketingRepositoryGetProgressRows(t *testing.T) {
db := openMarketingProgressTestDB(t)
repo := NewMarketingRepository(db)
mustExecMarketing(t, db, `CREATE TABLE locations (id INTEGER PRIMARY KEY, name TEXT)`)
mustExecMarketing(t, db, `CREATE TABLE project_flocks (id INTEGER PRIMARY KEY, flock_name TEXT)`)
mustExecMarketing(t, db, `CREATE TABLE kandangs (id INTEGER PRIMARY KEY, name TEXT, location_id INTEGER)`)
mustExecMarketing(t, db, `CREATE TABLE project_flock_kandangs (id INTEGER PRIMARY KEY, project_flock_id INTEGER, kandang_id INTEGER)`)
mustExecMarketing(t, db, `CREATE TABLE warehouses (id INTEGER PRIMARY KEY, location_id INTEGER, kandang_id INTEGER)`)
mustExecMarketing(t, db, `CREATE TABLE product_warehouses (id INTEGER PRIMARY KEY, warehouse_id INTEGER, project_flock_kandang_id INTEGER)`)
mustExecMarketing(t, db, `CREATE TABLE marketings (id INTEGER PRIMARY KEY, so_date DATE, deleted_at DATETIME)`)
mustExecMarketing(t, db, `CREATE TABLE marketing_products (id INTEGER PRIMARY KEY, marketing_id INTEGER, product_warehouse_id INTEGER)`)
mustExecMarketing(t, db, `INSERT INTO locations (id, name) VALUES (1, 'Location A'), (2, 'Location B')`)
mustExecMarketing(t, db, `INSERT INTO project_flocks (id, flock_name) VALUES (1, 'Farm A')`)
mustExecMarketing(t, db, `INSERT INTO kandangs (id, name, location_id) VALUES (1, 'Kandang 1', 1)`)
mustExecMarketing(t, db, `INSERT INTO project_flock_kandangs (id, project_flock_id, kandang_id) VALUES (1, 1, 1)`)
mustExecMarketing(t, db, `INSERT INTO warehouses (id, location_id, kandang_id) VALUES (1, 1, 1), (2, 2, NULL)`)
mustExecMarketing(t, db, `INSERT INTO product_warehouses (id, warehouse_id, project_flock_kandang_id) VALUES (1, 1, 1), (2, 2, NULL)`)
mustExecMarketing(t, db, `INSERT INTO marketings (id, so_date, deleted_at) VALUES (1, '2026-06-12', NULL), (2, '2026-06-12', NULL)`)
mustExecMarketing(t, db, `INSERT INTO marketing_products (id, marketing_id, product_warehouse_id) VALUES
(1, 1, 1),
(2, 1, 1),
(3, 2, 2)`)
rows, err := repo.GetProgressRows(context.Background(), time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC), time.Date(2026, 6, 30, 0, 0, 0, 0, time.UTC), nil, false)
if err != nil {
t.Fatalf("GetProgressRows failed: %v", err)
}
if len(rows) != 2 {
t.Fatalf("expected 2 grouped rows, got %d", len(rows))
}
assertProgressRowMarketing(t, rows, "Farm A", "Kandang 1", "2026-06-12", 1)
assertProgressRowMarketing(t, rows, "Location B", "Farm-level / Unassigned", "2026-06-12", 1)
}
func openMarketingProgressTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{})
if err != nil {
t.Fatalf("failed opening sqlite db: %v", err)
}
return db
}
func mustExecMarketing(t *testing.T, db *gorm.DB, query string, args ...any) {
t.Helper()
if err := db.Exec(query, args...).Error; err != nil {
t.Fatalf("exec failed for %q: %v", query, err)
}
}
func assertProgressRowMarketing(t *testing.T, rows []exportprogress.Row, farm, kandang, date string, count int) {
t.Helper()
for _, row := range rows {
if row.FarmName == farm && row.KandangName == kandang && row.ActivityDate.Format("2006-01-02") == date && row.Count == count {
return
}
}
t.Fatalf("expected row farm=%s kandang=%s date=%s count=%d, got %+v", farm, kandang, date, count, rows)
}
@@ -3,7 +3,9 @@ package repository
import (
"context"
"fmt"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
"gitlab.com/mbugroup/lti-api.git/internal/common/repository"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gorm.io/gorm"
@@ -14,6 +16,7 @@ type MarketingRepository interface {
IdExists(ctx context.Context, id uint) (bool, error)
GetNextSequence(ctx context.Context) (uint, error)
NextSoNumber(ctx context.Context, tx *gorm.DB) (string, error)
GetProgressRows(ctx context.Context, startDate, endDate time.Time, allowedLocationIDs []uint, restrict bool) ([]exportprogress.Row, error)
}
type MarketingRepositoryImpl struct {
@@ -55,3 +58,67 @@ func (r *MarketingRepositoryImpl) NextSoNumber(ctx context.Context, tx *gorm.DB)
return soNumber, nil
}
func (r *MarketingRepositoryImpl) GetProgressRows(ctx context.Context, startDate, endDate time.Time, allowedLocationIDs []uint, restrict bool) ([]exportprogress.Row, error) {
const unassignedSQL = "'" + exportprogress.UnassignedKandangName + "'"
subQuery := r.DB().WithContext(ctx).
Table("marketings AS m").
Select(`
DISTINCT m.id AS marketing_id,
'Marketings' AS module,
COALESCE(pf.flock_name, loc.name, 'Unknown Farm') AS farm_name,
COALESCE(k.name, `+unassignedSQL+`, 'Unknown Kandang') AS kandang_name,
DATE(m.so_date) AS activity_date
`).
Joins("JOIN marketing_products mp ON mp.marketing_id = m.id").
Joins("JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id").
Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
Joins("LEFT JOIN project_flock_kandangs pfk ON pfk.id = pw.project_flock_kandang_id").
Joins("LEFT JOIN project_flocks pf ON pf.id = pfk.project_flock_id").
Joins("LEFT JOIN kandangs k ON k.id = COALESCE(pfk.kandang_id, w.kandang_id)").
Joins("LEFT JOIN locations loc ON loc.id = COALESCE(k.location_id, w.location_id)").
Where("m.deleted_at IS NULL").
Where("DATE(m.so_date) >= DATE(?)", startDate).
Where("DATE(m.so_date) <= DATE(?)", endDate)
if restrict {
if len(allowedLocationIDs) == 0 {
return []exportprogress.Row{}, nil
}
subQuery = subQuery.Where("w.location_id IN ?", allowedLocationIDs)
}
type progressRowResult struct {
Module string
FarmName string
KandangName string
ActivityDate string
Count int
}
scanned := make([]progressRowResult, 0)
err := r.DB().WithContext(ctx).
Table("(?) AS progress_rows", subQuery).
Select("module, farm_name, kandang_name, activity_date, COUNT(*) AS count").
Group("module, farm_name, kandang_name, activity_date").
Order("activity_date ASC, farm_name ASC, kandang_name ASC").
Scan(&scanned).Error
if err != nil {
return nil, err
}
rows := make([]exportprogress.Row, 0, len(scanned))
for _, item := range scanned {
activityDate, err := time.Parse("2006-01-02", item.ActivityDate)
if err != nil {
return nil, err
}
rows = append(rows, exportprogress.Row{
Module: item.Module,
FarmName: item.FarmName,
KandangName: item.KandangName,
ActivityDate: activityDate,
Count: item.Count,
})
}
return rows, nil
}
@@ -8,6 +8,7 @@ import (
"strings"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
fifoV2 "gitlab.com/mbugroup/lti-api.git/internal/common/service/fifo_stock_v2"
@@ -34,6 +35,7 @@ type DeliveryOrdersService interface {
CreateOne(ctx *fiber.Ctx, req *validation.DeliveryOrderCreate) (*dto.MarketingDetailDTO, error)
UpdateOne(ctx *fiber.Ctx, req *validation.DeliveryOrderUpdate, id uint) (*dto.MarketingDetailDTO, error)
BulkApproveToStatus(ctx *fiber.Ctx, req *validation.BulkApprovalRequest, target approvalutils.ApprovalStep) ([]dto.MarketingDetailDTO, error)
GetProgressRows(ctx *fiber.Ctx, query *exportprogress.Query) ([]exportprogress.Row, error)
}
type deliveryOrdersService struct {
@@ -249,6 +251,14 @@ func (s deliveryOrdersService) GetAll(c *fiber.Ctx, params *validation.DeliveryO
return result, total, nil
}
func (s deliveryOrdersService) GetProgressRows(c *fiber.Ctx, query *exportprogress.Query) ([]exportprogress.Row, error) {
scope, err := m.ResolveLocationScope(c, s.MarketingRepo.DB())
if err != nil {
return nil, err
}
return s.MarketingRepo.GetProgressRows(c.Context(), query.StartDate, query.EndDate, scope.IDs, scope.Restrict)
}
func (s deliveryOrdersService) GetOne(c *fiber.Ctx, id uint) (*dto.MarketingDetailDTO, error) {
if err := m.EnsureMarketingAccess(c, s.MarketingRepo.DB(), id); err != nil {
return nil, err
@@ -1,11 +1,13 @@
package controller
import (
"fmt"
"math"
"strconv"
"strings"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
"gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/dto"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations"
@@ -28,6 +30,25 @@ func (u *RecordingController) GetAll(c *fiber.Ctx) error {
projectFlockID := c.QueryInt("project_flock_kandang_id", 0)
exportType := strings.TrimSpace(c.Query("export"))
if exportprogress.IsProgressExportRequest(c) {
query, err := exportprogress.ParseQuery(c)
if err != nil {
return err
}
rows, err := u.RecordingService.GetProgressRows(c, query)
if err != nil {
return err
}
content, err := exportprogress.BuildWorkbook("Recordings", query, rows)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to generate progress excel file")
}
filename := fmt.Sprintf("recordings_progress_%s.xlsx", time.Now().Format("20060102_150405"))
c.Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
return c.Status(fiber.StatusOK).Send(content)
}
page := c.QueryInt("page", 1)
limit := c.QueryInt("limit", 10)
offset := (page - 1) * limit
@@ -8,6 +8,7 @@ import (
"strings"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
"gitlab.com/mbugroup/lti-api.git/internal/common/repository"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gorm.io/gorm"
@@ -74,6 +75,7 @@ type RecordingRepository interface {
GetProjectFlockKandangIDsByPopulationWarehouseIDs(ctx context.Context, tx *gorm.DB, productWarehouseIDs []uint) ([]uint, error)
ResyncProjectFlockPopulationUsage(ctx context.Context, tx *gorm.DB, projectFlockKandangID uint) error
ValidateProductWarehousesByFlags(ctx context.Context, ids []uint, flags []string) (uint, error)
GetProgressRows(ctx context.Context, startDate, endDate time.Time, allowedLocationIDs []uint, restrict bool) ([]exportprogress.Row, error)
}
type RecordingRepositoryImpl struct {
@@ -250,6 +252,65 @@ func (r *RecordingRepositoryImpl) GetLatestByProjectFlockKandangID(ctx context.C
return &record, nil
}
func (r *RecordingRepositoryImpl) GetProgressRows(ctx context.Context, startDate, endDate time.Time, allowedLocationIDs []uint, restrict bool) ([]exportprogress.Row, error) {
const unassignedSQL = "'" + exportprogress.UnassignedKandangName + "'"
query := r.DB().WithContext(ctx).
Table("recordings AS r").
Select(`
'Recordings' AS module,
COALESCE(pf.flock_name, loc.name, 'Unknown Farm') AS farm_name,
COALESCE(k.name, `+unassignedSQL+`, 'Unknown Kandang') AS kandang_name,
DATE(r.record_datetime) AS activity_date,
COUNT(*) AS count
`).
Joins("JOIN project_flock_kandangs pfk ON pfk.id = r.project_flock_kandangs_id").
Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id").
Joins("JOIN kandangs k ON k.id = pfk.kandang_id").
Joins("LEFT JOIN locations loc ON loc.id = k.location_id").
Where("r.deleted_at IS NULL").
Where("DATE(r.record_datetime) >= DATE(?)", startDate).
Where("DATE(r.record_datetime) <= DATE(?)", endDate)
if restrict {
if len(allowedLocationIDs) == 0 {
return []exportprogress.Row{}, nil
}
query = query.Where("pf.location_id IN ?", allowedLocationIDs)
}
type progressRowResult struct {
Module string
FarmName string
KandangName string
ActivityDate string
Count int
}
scanned := make([]progressRowResult, 0)
err := query.
Group("DATE(r.record_datetime), COALESCE(pf.flock_name, loc.name, 'Unknown Farm'), COALESCE(k.name, " + unassignedSQL + ", 'Unknown Kandang')").
Order("activity_date ASC, farm_name ASC, kandang_name ASC").
Scan(&scanned).Error
if err != nil {
return nil, err
}
rows := make([]exportprogress.Row, 0, len(scanned))
for _, item := range scanned {
activityDate, err := time.Parse("2006-01-02", item.ActivityDate)
if err != nil {
return nil, err
}
rows = append(rows, exportprogress.Row{
Module: item.Module,
FarmName: item.FarmName,
KandangName: item.KandangName,
ActivityDate: activityDate,
Count: item.Count,
})
}
return rows, nil
}
func (r *RecordingRepositoryImpl) ListByProjectFlockKandangID(ctx context.Context, tx *gorm.DB, projectFlockKandangId uint, from *time.Time) ([]entity.Recording, error) {
if projectFlockKandangId == 0 {
return nil, errors.New("project_flock_kandang_id is required")
@@ -0,0 +1,68 @@
package repository
import (
"context"
"testing"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func TestRecordingRepositoryGetProgressRows(t *testing.T) {
db := openRecordingProgressTestDB(t)
repo := NewRecordingRepository(db)
mustExecRecording(t, db, `CREATE TABLE locations (id INTEGER PRIMARY KEY, name TEXT)`)
mustExecRecording(t, db, `CREATE TABLE project_flocks (id INTEGER PRIMARY KEY, flock_name TEXT, location_id INTEGER)`)
mustExecRecording(t, db, `CREATE TABLE kandangs (id INTEGER PRIMARY KEY, name TEXT, location_id INTEGER)`)
mustExecRecording(t, db, `CREATE TABLE project_flock_kandangs (id INTEGER PRIMARY KEY, project_flock_id INTEGER, kandang_id INTEGER)`)
mustExecRecording(t, db, `CREATE TABLE recordings (id INTEGER PRIMARY KEY, project_flock_kandangs_id INTEGER, record_datetime DATETIME, deleted_at DATETIME)`)
mustExecRecording(t, db, `INSERT INTO locations (id, name) VALUES (1, 'Location A')`)
mustExecRecording(t, db, `INSERT INTO project_flocks (id, flock_name, location_id) VALUES (1, 'Farm A', 1)`)
mustExecRecording(t, db, `INSERT INTO kandangs (id, name, location_id) VALUES (1, 'Kandang 1', 1)`)
mustExecRecording(t, db, `INSERT INTO project_flock_kandangs (id, project_flock_id, kandang_id) VALUES (1, 1, 1)`)
mustExecRecording(t, db, `INSERT INTO recordings (id, project_flock_kandangs_id, record_datetime, deleted_at) VALUES
(1, 1, '2026-06-03 08:00:00', NULL),
(2, 1, '2026-06-03 10:00:00', NULL),
(3, 1, '2026-07-01 08:00:00', NULL)`)
rows, err := repo.GetProgressRows(context.Background(), time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC), time.Date(2026, 6, 30, 0, 0, 0, 0, time.UTC), nil, false)
if err != nil {
t.Fatalf("GetProgressRows failed: %v", err)
}
if len(rows) != 1 {
t.Fatalf("expected 1 grouped row, got %d", len(rows))
}
assertProgressRowRecording(t, rows, "Farm A", "Kandang 1", "2026-06-03", 2)
}
func openRecordingProgressTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{})
if err != nil {
t.Fatalf("failed opening sqlite db: %v", err)
}
return db
}
func mustExecRecording(t *testing.T, db *gorm.DB, query string, args ...any) {
t.Helper()
if err := db.Exec(query, args...).Error; err != nil {
t.Fatalf("exec failed for %q: %v", query, err)
}
}
func assertProgressRowRecording(t *testing.T, rows []exportprogress.Row, farm, kandang, date string, count int) {
t.Helper()
for _, row := range rows {
if row.FarmName == farm && row.KandangName == kandang && row.ActivityDate.Format("2006-01-02") == date && row.Count == count {
return
}
}
t.Fatalf("expected row farm=%s kandang=%s date=%s count=%d, got %+v", farm, kandang, date, count, rows)
}
@@ -8,6 +8,7 @@ import (
"strings"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
fifoV2 "gitlab.com/mbugroup/lti-api.git/internal/common/service/fifo_stock_v2"
@@ -42,6 +43,7 @@ type RecordingService interface {
UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Recording, error)
DeleteOne(ctx *fiber.Ctx, id uint) error
Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.Recording, error)
GetProgressRows(ctx *fiber.Ctx, query *exportprogress.Query) ([]exportprogress.Row, error)
}
type recordingService struct {
@@ -202,6 +204,14 @@ func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
return recordings, total, nil
}
func (s recordingService) GetProgressRows(c *fiber.Ctx, query *exportprogress.Query) ([]exportprogress.Row, error) {
scope, err := m.ResolveLocationScope(c, s.Repository.DB())
if err != nil {
return nil, err
}
return s.Repository.GetProgressRows(c.Context(), query.StartDate, query.EndDate, scope.IDs, scope.Restrict)
}
func (s recordingService) GetOne(c *fiber.Ctx, id uint) (*entity.Recording, error) {
if err := m.EnsureRecordingAccess(c, s.Repository.DB(), id); err != nil {
return nil, err
@@ -7,7 +7,9 @@ import (
"mime/multipart"
"strconv"
"strings"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
"gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/dto"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/services"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/validations"
@@ -27,6 +29,25 @@ func NewPurchaseController(s service.PurchaseService) *PurchaseController {
}
func (ctrl *PurchaseController) GetAll(c *fiber.Ctx) error {
if exportprogress.IsProgressExportRequest(c) {
query, err := exportprogress.ParseQuery(c)
if err != nil {
return err
}
rows, err := ctrl.service.GetProgressRows(c, query)
if err != nil {
return err
}
content, err := exportprogress.BuildWorkbook("Purchases", query, rows)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to generate progress excel file")
}
filename := fmt.Sprintf("purchases_progress_%s.xlsx", time.Now().Format("20060102_150405"))
c.Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
return c.Status(fiber.StatusOK).Send(content)
}
query := &validation.Query{
Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10),
@@ -8,6 +8,7 @@ import (
"strings"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
"gitlab.com/mbugroup/lti-api.git/internal/common/repository"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
utils "gitlab.com/mbugroup/lti-api.git/internal/utils"
@@ -28,6 +29,7 @@ type PurchaseRepository interface {
SoftDeleteByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) error
GetItemsByProjectFlockID(ctx context.Context, projectFlockID uint) ([]entity.PurchaseItem, error)
GetItemsByWarehouseKandang(ctx context.Context, projectFlockID uint) ([]entity.PurchaseItem, error)
GetProgressRows(ctx context.Context, startDate, endDate time.Time, allowedLocationIDs []uint, restrict bool) ([]exportprogress.Row, error)
}
type PurchaseRepositoryImpl struct {
@@ -284,6 +286,75 @@ func (r *PurchaseRepositoryImpl) UpdateReceivingDetails(
return nil
}
func (r *PurchaseRepositoryImpl) GetProgressRows(ctx context.Context, startDate, endDate time.Time, allowedLocationIDs []uint, restrict bool) ([]exportprogress.Row, error) {
const unassignedSQL = "'" + exportprogress.UnassignedKandangName + "'"
subQuery := r.DB().WithContext(ctx).
Table("purchases AS p").
Select(`
DISTINCT p.id AS purchase_id,
'Purchases' AS module,
COALESCE(pf_explicit.flock_name, pf_active.flock_name, kandang_loc.name, warehouse_loc.name, 'Unknown Farm') AS farm_name,
COALESCE(k_explicit.name, k_active.name, wk.name, `+unassignedSQL+`, 'Unknown Kandang') AS kandang_name,
DATE(p.po_date) AS activity_date
`).
Joins("JOIN purchase_items pi ON pi.purchase_id = p.id").
Joins("JOIN warehouses w ON w.id = pi.warehouse_id").
Joins("LEFT JOIN project_flock_kandangs pfk_explicit ON pfk_explicit.id = pi.project_flock_kandang_id").
Joins("LEFT JOIN project_flocks pf_explicit ON pf_explicit.id = pfk_explicit.project_flock_id").
Joins("LEFT JOIN kandangs k_explicit ON k_explicit.id = pfk_explicit.kandang_id").
Joins("LEFT JOIN project_flock_kandangs pfk_active ON pfk_active.kandang_id = w.kandang_id AND pfk_active.closed_at IS NULL").
Joins("LEFT JOIN project_flocks pf_active ON pf_active.id = pfk_active.project_flock_id").
Joins("LEFT JOIN kandangs k_active ON k_active.id = pfk_active.kandang_id").
Joins("LEFT JOIN kandangs wk ON wk.id = w.kandang_id").
Joins("LEFT JOIN locations kandang_loc ON kandang_loc.id = COALESCE(k_explicit.location_id, k_active.location_id, wk.location_id)").
Joins("LEFT JOIN locations warehouse_loc ON warehouse_loc.id = w.location_id").
Where("p.deleted_at IS NULL").
Where("p.po_date IS NOT NULL").
Where("DATE(p.po_date) >= DATE(?)", startDate).
Where("DATE(p.po_date) <= DATE(?)", endDate)
if restrict {
if len(allowedLocationIDs) == 0 {
return []exportprogress.Row{}, nil
}
subQuery = subQuery.Where("w.location_id IN ?", allowedLocationIDs)
}
type progressRowResult struct {
Module string
FarmName string
KandangName string
ActivityDate string
Count int
}
scanned := make([]progressRowResult, 0)
err := r.DB().WithContext(ctx).
Table("(?) AS progress_rows", subQuery).
Select("module, farm_name, kandang_name, activity_date, COUNT(*) AS count").
Group("module, farm_name, kandang_name, activity_date").
Order("activity_date ASC, farm_name ASC, kandang_name ASC").
Scan(&scanned).Error
if err != nil {
return nil, err
}
rows := make([]exportprogress.Row, 0, len(scanned))
for _, item := range scanned {
activityDate, err := time.Parse("2006-01-02", item.ActivityDate)
if err != nil {
return nil, err
}
rows = append(rows, exportprogress.Row{
Module: item.Module,
FarmName: item.FarmName,
KandangName: item.KandangName,
ActivityDate: activityDate,
Count: item.Count,
})
}
return rows, nil
}
func (r *PurchaseRepositoryImpl) DeleteItems(ctx context.Context, purchaseID uint, itemIDs []uint) error {
if len(itemIDs) == 0 {
return errors.New("itemIDs cannot be empty")
@@ -0,0 +1,74 @@
package repositories
import (
"context"
"testing"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func TestPurchaseRepositoryGetProgressRows(t *testing.T) {
db := openPurchaseProgressTestDB(t)
repo := NewPurchaseRepository(db)
mustExecPurchase(t, db, `CREATE TABLE locations (id INTEGER PRIMARY KEY, name TEXT)`)
mustExecPurchase(t, db, `CREATE TABLE project_flocks (id INTEGER PRIMARY KEY, flock_name TEXT)`)
mustExecPurchase(t, db, `CREATE TABLE kandangs (id INTEGER PRIMARY KEY, name TEXT, location_id INTEGER)`)
mustExecPurchase(t, db, `CREATE TABLE project_flock_kandangs (id INTEGER PRIMARY KEY, project_flock_id INTEGER, kandang_id INTEGER, closed_at DATETIME)`)
mustExecPurchase(t, db, `CREATE TABLE warehouses (id INTEGER PRIMARY KEY, location_id INTEGER, kandang_id INTEGER)`)
mustExecPurchase(t, db, `CREATE TABLE purchases (id INTEGER PRIMARY KEY, po_date DATE, deleted_at DATETIME)`)
mustExecPurchase(t, db, `CREATE TABLE purchase_items (id INTEGER PRIMARY KEY, purchase_id INTEGER, warehouse_id INTEGER, project_flock_kandang_id INTEGER)`)
mustExecPurchase(t, db, `INSERT INTO locations (id, name) VALUES (1, 'Location A'), (2, 'Location B')`)
mustExecPurchase(t, db, `INSERT INTO project_flocks (id, flock_name) VALUES (1, 'Farm A')`)
mustExecPurchase(t, db, `INSERT INTO kandangs (id, name, location_id) VALUES (1, 'Kandang 1', 1)`)
mustExecPurchase(t, db, `INSERT INTO project_flock_kandangs (id, project_flock_id, kandang_id, closed_at) VALUES (1, 1, 1, NULL)`)
mustExecPurchase(t, db, `INSERT INTO warehouses (id, location_id, kandang_id) VALUES (1, 1, 1), (2, 2, NULL)`)
mustExecPurchase(t, db, `INSERT INTO purchases (id, po_date, deleted_at) VALUES (1, '2026-06-05', NULL), (2, '2026-06-05', NULL), (3, NULL, NULL)`)
mustExecPurchase(t, db, `INSERT INTO purchase_items (id, purchase_id, warehouse_id, project_flock_kandang_id) VALUES
(1, 1, 1, 1),
(2, 1, 1, 1),
(3, 2, 2, NULL),
(4, 3, 1, 1)`)
rows, err := repo.GetProgressRows(context.Background(), time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC), time.Date(2026, 6, 30, 0, 0, 0, 0, time.UTC), nil, false)
if err != nil {
t.Fatalf("GetProgressRows failed: %v", err)
}
if len(rows) != 2 {
t.Fatalf("expected 2 grouped rows, got %d", len(rows))
}
assertProgressRowPurchase(t, rows, "Farm A", "Kandang 1", "2026-06-05", 1)
assertProgressRowPurchase(t, rows, "Location B", "Farm-level / Unassigned", "2026-06-05", 1)
}
func openPurchaseProgressTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{})
if err != nil {
t.Fatalf("failed opening sqlite db: %v", err)
}
return db
}
func mustExecPurchase(t *testing.T, db *gorm.DB, query string, args ...any) {
t.Helper()
if err := db.Exec(query, args...).Error; err != nil {
t.Fatalf("exec failed for %q: %v", query, err)
}
}
func assertProgressRowPurchase(t *testing.T, rows []exportprogress.Row, farm, kandang, date string, count int) {
t.Helper()
for _, row := range rows {
if row.FarmName == farm && row.KandangName == kandang && row.ActivityDate.Format("2006-01-02") == date && row.Count == count {
return
}
}
t.Fatalf("expected row farm=%s kandang=%s date=%s count=%d, got %+v", farm, kandang, date, count, rows)
}
@@ -11,6 +11,7 @@ import (
"strings"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
@@ -43,6 +44,7 @@ type PurchaseService interface {
ReceiveProducts(ctx *fiber.Ctx, id uint, req *validation.ReceivePurchaseRequest) (*entity.Purchase, error)
DeleteItems(ctx *fiber.Ctx, id uint, req *validation.DeletePurchaseItemsRequest) (*entity.Purchase, error)
DeletePurchase(ctx *fiber.Ctx, id uint) error
GetProgressRows(ctx *fiber.Ctx, query *exportprogress.Query) ([]exportprogress.Row, error)
}
const (
@@ -446,6 +448,14 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
return purchases, total, nil
}
func (s *purchaseService) GetProgressRows(c *fiber.Ctx, query *exportprogress.Query) ([]exportprogress.Row, error) {
scope, err := m.ResolveLocationScope(c, s.PurchaseRepo.DB())
if err != nil {
return nil, err
}
return s.PurchaseRepo.GetProgressRows(c.Context(), query.StartDate, query.EndDate, scope.IDs, scope.Restrict)
}
func (s *purchaseService) GetOne(c *fiber.Ctx, id uint) (*entity.Purchase, error) {
scope, err := m.ResolveLocationScope(c, s.PurchaseRepo.DB())
if err != nil {