Merge branch 'development' into 'production'

Development

See merge request mbugroup/lti-api!443
This commit is contained in:
Adnan Zahir
2026-04-23 12:38:24 +07:00
43 changed files with 4475 additions and 497 deletions
@@ -0,0 +1,21 @@
BEGIN;
DROP INDEX IF EXISTS idx_daily_checklists_unique_non_rejected;
DROP INDEX IF EXISTS idx_daily_checklists_deleted_at;
DROP INDEX IF EXISTS idx_daily_checklists_deleted_by;
ALTER TABLE daily_checklists
DROP CONSTRAINT IF EXISTS fk_daily_checklists_deleted_by;
ALTER TABLE daily_checklists
DROP COLUMN IF EXISTS deleted_at,
DROP COLUMN IF EXISTS deleted_by;
ALTER TABLE daily_checklists
DROP CONSTRAINT IF EXISTS daily_checklists_date_kandang_category_key;
CREATE UNIQUE INDEX IF NOT EXISTS idx_daily_checklists_unique_non_rejected
ON daily_checklists (date, kandang_id, category)
WHERE (status IS NULL OR status <> 'REJECTED');
COMMIT;
@@ -0,0 +1,27 @@
BEGIN;
ALTER TABLE daily_checklists
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS deleted_by BIGINT;
CREATE INDEX IF NOT EXISTS idx_daily_checklists_deleted_at
ON daily_checklists (deleted_at);
CREATE INDEX IF NOT EXISTS idx_daily_checklists_deleted_by
ON daily_checklists (deleted_by);
ALTER TABLE daily_checklists
DROP CONSTRAINT IF EXISTS fk_daily_checklists_deleted_by,
ADD CONSTRAINT fk_daily_checklists_deleted_by
FOREIGN KEY (deleted_by) REFERENCES users(id) ON DELETE SET NULL;
ALTER TABLE daily_checklists
DROP CONSTRAINT IF EXISTS daily_checklists_date_kandang_category_key;
DROP INDEX IF EXISTS idx_daily_checklists_unique_non_rejected;
CREATE UNIQUE INDEX IF NOT EXISTS idx_daily_checklists_unique_non_rejected
ON daily_checklists (date, kandang_id, category)
WHERE (status IS NULL OR status <> 'REJECTED')
AND deleted_at IS NULL;
COMMIT;
@@ -0,0 +1,41 @@
BEGIN;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM daily_checklists
WHERE category::text = 'empty_kandang'
) THEN
RAISE EXCEPTION 'Cannot rollback category_code enum: daily_checklists still contains empty_kandang';
END IF;
IF EXISTS (
SELECT 1
FROM phases
WHERE category::text = 'empty_kandang'
) THEN
RAISE EXCEPTION 'Cannot rollback category_code enum: phases still contains empty_kandang';
END IF;
END $$;
ALTER TYPE category_code RENAME TO category_code_old;
CREATE TYPE category_code AS ENUM (
'pullet_open',
'pullet_close',
'produksi_open',
'produksi_close'
);
ALTER TABLE phases
ALTER COLUMN category TYPE category_code
USING category::text::category_code;
ALTER TABLE daily_checklists
ALTER COLUMN category TYPE category_code
USING category::text::category_code;
DROP TYPE category_code_old;
COMMIT;
@@ -0,0 +1,12 @@
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_type t
JOIN pg_enum e ON t.oid = e.enumtypid
WHERE t.typname = 'category_code'
AND e.enumlabel = 'empty_kandang'
) THEN
ALTER TYPE category_code ADD VALUE 'empty_kandang';
END IF;
END $$;
+10 -3
View File
@@ -1,6 +1,10 @@
package entities package entities
import "time" import (
"time"
"gorm.io/gorm"
)
type DailyChecklist struct { type DailyChecklist struct {
Id uint `gorm:"primaryKey"` Id uint `gorm:"primaryKey"`
@@ -14,12 +18,15 @@ type DailyChecklist struct {
DocumentPath *string DocumentPath *string
RejectReason *string RejectReason *string
CreatedBy *uint CreatedBy *uint
CreatedAt time.Time `gorm:"autoCreateTime"` DeletedBy *uint
UpdatedAt time.Time `gorm:"autoUpdateTime"` CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
Kandang KandangGroup `gorm:"foreignKey:KandangId;references:Id"` Kandang KandangGroup `gorm:"foreignKey:KandangId;references:Id"`
Checklist *Checklist `gorm:"foreignKey:ChecklistId;references:Id"` Checklist *Checklist `gorm:"foreignKey:ChecklistId;references:Id"`
Creator *User `gorm:"foreignKey:CreatedBy;references:Id"` Creator *User `gorm:"foreignKey:CreatedBy;references:Id"`
Deleter *User `gorm:"foreignKey:DeletedBy;references:Id"`
Tasks []DailyChecklistTask `gorm:"foreignKey:DailyChecklistId;references:Id"` Tasks []DailyChecklistTask `gorm:"foreignKey:DailyChecklistId;references:Id"`
} }
@@ -153,6 +153,20 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
} }
return normalized return normalized
} }
normalizeCutOverToken := func(raw string) string {
normalized := strings.ToUpper(strings.TrimSpace(raw))
normalized = strings.ReplaceAll(normalized, "-", "")
normalized = strings.ReplaceAll(normalized, " ", "")
return normalized
}
containsCutOver := func(values ...string) bool {
for _, value := range values {
if strings.Contains(normalizeCutOverToken(value), "CUTOVER") {
return true
}
}
return false
}
filter := normalizeFlag(flag) filter := normalizeFlag(flag)
byFlag := map[string]**SapronakCategoryDTO{} byFlag := map[string]**SapronakCategoryDTO{}
@@ -258,6 +272,7 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
UnitPrice: item.Harga, UnitPrice: item.Harga,
Notes: "-", Notes: "-",
} }
isCutOver := containsCutOver(baseRow.ProductCategory, baseRow.Description, item.ProductName)
row := getOrCreateRow(productKey, baseRow) row := getOrCreateRow(productKey, baseRow)
@@ -289,11 +304,21 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
} }
row.QtyUsed += item.QtyKeluar row.QtyUsed += item.QtyKeluar
row.TotalAmount += item.QtyKeluar * price row.TotalAmount += item.QtyKeluar * price
case "adjustment keluar", "mutasi keluar", "penjualan": case "adjustment keluar":
price := row.UnitPrice price := row.UnitPrice
if price == 0 { if price == 0 {
price = item.Harga price = item.Harga
} }
if row.UnitPrice == 0 {
row.UnitPrice = price
}
if isCutOver {
row.QtyUsed += item.QtyKeluar
row.TotalAmount += item.QtyKeluar * price
continue
}
row.QtyOut += item.QtyKeluar
case "mutasi keluar", "penjualan":
row.QtyOut += item.QtyKeluar row.QtyOut += item.QtyKeluar
if strings.ToLower(item.JenisTransaksi) == "mutasi keluar" { if strings.ToLower(item.JenisTransaksi) == "mutasi keluar" {
ref := strings.ToUpper(strings.TrimSpace(item.NoReferensi)) ref := strings.ToUpper(strings.TrimSpace(item.NoReferensi))
@@ -0,0 +1,124 @@
package dto
import "testing"
func TestToSapronakProjectAggregatedFromReportMovesCutOverAdjustmentOutToQtyUsed(t *testing.T) {
tests := []struct {
name string
groupFlag string
filter string
productFlag string
}{
{
name: "pakan cut-over",
groupFlag: "PAKAN",
filter: "PAKAN",
productFlag: "PAKAN CUT-OVER",
},
{
name: "ovk cut over",
groupFlag: "OVK",
filter: "OVK",
productFlag: "OVK CUT OVER",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
report := &SapronakReportDTO{
Groups: []SapronakGroupDTO{
{
Flag: tc.groupFlag,
Items: []SapronakDetailDTO{
{
ProductID: 1,
ProductName: "CUTOVER ITEM",
NoReferensi: "ADJ-CUT-01",
JenisTransaksi: "Adjustment Keluar",
QtyKeluar: 5,
Harga: 15000,
},
},
},
},
}
result := ToSapronakProjectAggregatedFromReport(
report,
tc.filter,
map[uint][]string{
1: {tc.productFlag},
},
)
var cat *SapronakCategoryDTO
if tc.groupFlag == "PAKAN" {
cat = result.Pakan
} else {
cat = result.Ovk
}
if cat == nil {
t.Fatalf("expected category payload for %s", tc.groupFlag)
}
if len(cat.Rows) != 1 {
t.Fatalf("expected 1 row, got %d", len(cat.Rows))
}
row := cat.Rows[0]
if row.QtyOut != 0 {
t.Fatalf("expected qty_out 0 for cut-over adjustment, got %.2f", row.QtyOut)
}
if row.QtyUsed != 5 {
t.Fatalf("expected qty_used 5 for cut-over adjustment, got %.2f", row.QtyUsed)
}
if row.UnitPrice != 15000 {
t.Fatalf("expected unit_price 15000, got %.2f", row.UnitPrice)
}
if row.TotalAmount != 75000 {
t.Fatalf("expected total_amount 75000, got %.2f", row.TotalAmount)
}
})
}
}
func TestToSapronakProjectAggregatedFromReportKeepsNonCutOverAdjustmentOutInQtyOut(t *testing.T) {
report := &SapronakReportDTO{
Groups: []SapronakGroupDTO{
{
Flag: "PAKAN",
Items: []SapronakDetailDTO{
{
ProductID: 7,
ProductName: "PAKAN REGULER",
NoReferensi: "ADJ-REG-01",
JenisTransaksi: "Adjustment Keluar",
QtyKeluar: 3,
Harga: 12000,
},
},
},
},
}
result := ToSapronakProjectAggregatedFromReport(
report,
"PAKAN",
map[uint][]string{
7: {"PAKAN"},
},
)
if result.Pakan == nil || len(result.Pakan.Rows) != 1 {
t.Fatalf("expected 1 pakan row, got %+v", result.Pakan)
}
row := result.Pakan.Rows[0]
if row.QtyOut != 3 {
t.Fatalf("expected qty_out 3 for non cut-over adjustment, got %.2f", row.QtyOut)
}
if row.QtyUsed != 0 {
t.Fatalf("expected qty_used 0 for non cut-over adjustment, got %.2f", row.QtyUsed)
}
if row.TotalAmount != 0 {
t.Fatalf("expected total_amount 0 for non cut-over adjustment, got %.2f", row.TotalAmount)
}
}
@@ -1588,11 +1588,20 @@ func splitStockLogs(rows []stockLogSapronakRow, refFn func(stockLogSapronakRow)
func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, kandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) { func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, kandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) {
poByWarehouse := r.DB(). poByWarehouse := r.DB().
Table("purchase_items pi"). Table("(?) AS ranked_po",
Select("DISTINCT ON (pi.product_warehouse_id) pi.product_warehouse_id, po.po_number, pi.received_date"). r.DB().
Joins("JOIN purchases po ON po.id = pi.purchase_id"). Table("purchase_items pi").
Where("pi.received_date IS NOT NULL"). Select(`
Order("pi.product_warehouse_id, pi.received_date ASC") pi.product_warehouse_id,
po.po_number,
pi.received_date,
ROW_NUMBER() OVER (PARTITION BY pi.product_warehouse_id ORDER BY pi.received_date ASC, pi.id ASC) AS rn
`).
Joins("JOIN purchases po ON po.id = pi.purchase_id").
Where("pi.received_date IS NOT NULL"),
).
Select("ranked_po.product_warehouse_id, ranked_po.po_number, ranked_po.received_date").
Where("ranked_po.rn = 1")
incomingQuery := r.withCtx(ctx). incomingQuery := r.withCtx(ctx).
Table("adjustment_stocks AS ast"). Table("adjustment_stocks AS ast").
@@ -1601,10 +1610,10 @@ func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, ka
p.name AS product_name, p.name AS product_name,
f.name AS flag, f.name AS flag,
ast.created_at AS date, ast.created_at AS date,
CONCAT('ADJ-', ast.id) AS reference, 'ADJ-' || CAST(ast.id AS TEXT) AS reference,
COALESCE(ast.total_qty, 0) AS qty_in, COALESCE(ast.total_qty, 0) AS qty_in,
0 AS qty_out, 0 AS qty_out,
COALESCE(p.product_price, 0) AS price COALESCE(ast.price, p.product_price, 0) AS price
`). `).
Joins("JOIN product_warehouses pw ON pw.id = ast.product_warehouse_id"). Joins("JOIN product_warehouses pw ON pw.id = ast.product_warehouse_id").
Joins("JOIN warehouses w ON w.id = pw.warehouse_id"). Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
@@ -1627,10 +1636,18 @@ func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, ka
p.name AS product_name, p.name AS product_name,
f.name AS flag, f.name AS flag,
COALESCE(pi.received_date, st.transfer_date, lt.transfer_date, pfp_po.received_date, pc.chick_in_date, ast_in.created_at, ast.created_at) AS date, COALESCE(pi.received_date, st.transfer_date, lt.transfer_date, pfp_po.received_date, pc.chick_in_date, ast_in.created_at, ast.created_at) AS date,
COALESCE(po.po_number, st.movement_number, lt.transfer_number, pfp_po.po_number, CONCAT('CHICKIN-', pc.id), CONCAT('ADJ-', ast_in.id), CONCAT('ADJ-', ast.id)) AS reference, COALESCE(
po.po_number,
st.movement_number,
lt.transfer_number,
pfp_po.po_number,
CASE WHEN ast_in.id IS NOT NULL THEN 'ADJ-' || CAST(ast_in.id AS TEXT) END,
CASE WHEN ast.id IS NOT NULL THEN 'ADJ-' || CAST(ast.id AS TEXT) END,
CASE WHEN pc.id IS NOT NULL THEN 'CHICKIN-' || CAST(pc.id AS TEXT) END
) AS reference,
0 AS qty_in, 0 AS qty_in,
COALESCE(SUM(sa.qty), 0) AS qty_out, COALESCE(SUM(sa.qty), 0) AS qty_out,
COALESCE(p.product_price, 0) AS price COALESCE(pi.price, ast_in.price, ast.price, p.product_price, 0) AS price
`). `).
Joins("JOIN adjustment_stocks ast ON ast.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyAdjustmentOut.String()). Joins("JOIN adjustment_stocks ast ON ast.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyAdjustmentOut.String()).
Joins("LEFT JOIN purchase_items pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyPurchaseItems.String()). Joins("LEFT JOIN purchase_items pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyPurchaseItems.String()).
@@ -1651,7 +1668,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, ka
Where("w.kandang_id = ?", kandangID). Where("w.kandang_id = ?", kandangID).
Where("f.name IN ?", sapronakFlagsAll). Where("f.name IN ?", sapronakFlagsAll).
Where("f.name NOT IN ?", sapronakFlags(utils.FlagDOC, utils.FlagPullet)). Where("f.name NOT IN ?", sapronakFlags(utils.FlagDOC, utils.FlagPullet)).
Group("pw.product_id, p.name, f.name, pi.received_date, st.transfer_date, lt.transfer_date, pfp_po.received_date, pc.chick_in_date, ast_in.created_at, ast.created_at, po.po_number, st.movement_number, lt.transfer_number, pfp_po.po_number, pc.id, ast_in.id, ast.id, p.product_price") Group("pw.product_id, p.name, f.name, pi.received_date, st.transfer_date, lt.transfer_date, pfp_po.received_date, pc.chick_in_date, ast_in.created_at, ast.created_at, po.po_number, st.movement_number, lt.transfer_number, pfp_po.po_number, pc.id, ast_in.id, ast.id, pi.price, ast_in.price, ast.price, p.product_price")
outgoingQuery = r.joinSapronakProductFlag(outgoingQuery, "p") outgoingQuery = r.joinSapronakProductFlag(outgoingQuery, "p")
outgoingQuery = applyDateRange(outgoingQuery, dateExpr, start, end) outgoingQuery = applyDateRange(outgoingQuery, dateExpr, start, end)
outgoing, err := scanAndGroupDetails(outgoingQuery) outgoing, err := scanAndGroupDetails(outgoingQuery)
@@ -89,6 +89,63 @@ func TestFetchSapronakIncomingIncludesAttributedFarmPurchasesAndHistoricalWareho
} }
} }
func TestFetchSapronakAdjustmentsUsesAdjustmentReferenceAndPrice(t *testing.T) {
db := setupClosingRepositoryTestDB(t)
repo := NewClosingRepository(db)
ctx := context.Background()
statements := []string{
`INSERT INTO warehouses (id, kandang_id) VALUES (5, 5)`,
`INSERT INTO product_categories (id, code) VALUES (1, 'OBT')`,
`INSERT INTO products (id, name, product_category_id, product_price) VALUES (17, 'OVK CUT-OVER', 1, 1)`,
`INSERT INTO flags (id, flagable_id, flagable_type, name) VALUES (1, 17, 'products', 'OVK')`,
`INSERT INTO product_warehouses (id, product_id, warehouse_id, project_flock_kandang_id) VALUES (1365, 17, 5, 66)`,
`INSERT INTO adjustment_stocks (id, product_warehouse_id, total_qty, usage_qty, price) VALUES
(1139, 1365, 1, 0, 298594487),
(1140, 1365, 0, 1, 298594487)`,
fmt.Sprintf(`INSERT INTO stock_allocations (id, product_warehouse_id, stockable_type, stockable_id, usable_type, usable_id, qty, allocation_purpose, status) VALUES
(25990, 1365, '%s', 1139, '%s', 1140, 1, 'CONSUME', 'ACTIVE')`,
fifo.StockableKeyAdjustmentIn.String(),
fifo.UsableKeyAdjustmentOut.String(),
),
}
for _, stmt := range statements {
if err := db.Exec(stmt).Error; err != nil {
t.Fatalf("failed seeding schema: %v", err)
}
}
incoming, outgoing, err := repo.FetchSapronakAdjustments(ctx, 5, nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
incomingRows := incoming[17]
if len(incomingRows) != 1 {
t.Fatalf("expected 1 incoming row for product 17, got %d", len(incomingRows))
}
if incomingRows[0].Reference != "ADJ-1139" {
t.Fatalf("expected incoming reference ADJ-1139, got %q", incomingRows[0].Reference)
}
if incomingRows[0].Price != 298594487 {
t.Fatalf("expected incoming price 298594487 from adjustment_stocks.price, got %.3f", incomingRows[0].Price)
}
outgoingRows := outgoing[17]
if len(outgoingRows) != 1 {
t.Fatalf("expected 1 outgoing row for product 17, got %d", len(outgoingRows))
}
if outgoingRows[0].Reference != "ADJ-1139" {
t.Fatalf("expected outgoing reference ADJ-1139, got %q", outgoingRows[0].Reference)
}
if outgoingRows[0].Reference == "CHICKIN-" {
t.Fatalf("expected outgoing reference to avoid CHICKIN- placeholder")
}
if outgoingRows[0].Price != 298594487 {
t.Fatalf("expected outgoing price 298594487 from adjustment_stocks.price, got %.3f", outgoingRows[0].Price)
}
}
func setupClosingRepositoryTestDB(t *testing.T) *gorm.DB { func setupClosingRepositoryTestDB(t *testing.T) *gorm.DB {
t.Helper() t.Helper()
@@ -134,6 +191,7 @@ func setupClosingRepositoryTestDB(t *testing.T) *gorm.DB {
purchase_id INTEGER NOT NULL, purchase_id INTEGER NOT NULL,
product_id INTEGER NOT NULL, product_id INTEGER NOT NULL,
warehouse_id INTEGER NOT NULL, warehouse_id INTEGER NOT NULL,
product_warehouse_id INTEGER NULL,
project_flock_kandang_id INTEGER NULL, project_flock_kandang_id INTEGER NULL,
total_qty NUMERIC(15,3) NOT NULL DEFAULT 0, total_qty NUMERIC(15,3) NOT NULL DEFAULT 0,
price NUMERIC(15,3) NOT NULL DEFAULT 0, price NUMERIC(15,3) NOT NULL DEFAULT 0,
@@ -153,7 +211,13 @@ func setupClosingRepositoryTestDB(t *testing.T) *gorm.DB {
)`, )`,
`CREATE TABLE project_chickins ( `CREATE TABLE project_chickins (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
project_flock_kandang_id INTEGER NOT NULL project_flock_kandang_id INTEGER NOT NULL,
chick_in_date TIMESTAMP NULL
)`,
`CREATE TABLE project_flock_populations (
id INTEGER PRIMARY KEY,
project_chickin_id INTEGER NULL,
product_warehouse_id INTEGER NULL
)`, )`,
`CREATE TABLE stock_allocations ( `CREATE TABLE stock_allocations (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
@@ -180,6 +244,16 @@ func setupClosingRepositoryTestDB(t *testing.T) *gorm.DB {
movement_number TEXT NULL, movement_number TEXT NULL,
reason TEXT NULL reason TEXT NULL
)`, )`,
`CREATE TABLE laying_transfers (
id INTEGER PRIMARY KEY,
transfer_date TIMESTAMP NULL,
transfer_number TEXT NULL
)`,
`CREATE TABLE laying_transfer_targets (
id INTEGER PRIMARY KEY,
laying_transfer_id INTEGER NOT NULL,
product_warehouse_id INTEGER NULL
)`,
`CREATE TABLE stock_transfer_details ( `CREATE TABLE stock_transfer_details (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
stock_transfer_id INTEGER NOT NULL, stock_transfer_id INTEGER NOT NULL,
@@ -194,6 +268,7 @@ func setupClosingRepositoryTestDB(t *testing.T) *gorm.DB {
product_warehouse_id INTEGER NOT NULL, product_warehouse_id INTEGER NOT NULL,
total_qty NUMERIC(15,3) NOT NULL DEFAULT 0, total_qty NUMERIC(15,3) NOT NULL DEFAULT 0,
usage_qty NUMERIC(15,3) NOT NULL DEFAULT 0, usage_qty NUMERIC(15,3) NOT NULL DEFAULT 0,
price NUMERIC(15,3) NOT NULL DEFAULT 0,
adj_number TEXT NULL, adj_number TEXT NULL,
created_at TIMESTAMP NULL created_at TIMESTAMP NULL
)`, )`,
@@ -573,17 +573,21 @@ func (s closingService) getSapronakDateRange(ctx context.Context, projectFlockID
return nil, nil, err return nil, nil, err
} }
var minChickin *time.Time var firstChickin struct {
ChickInDate time.Time `gorm:"column:chick_in_date"`
}
if err := db.Table("project_chickins"). if err := db.Table("project_chickins").
Select("MIN(chick_in_date)"). Select("chick_in_date").
Where("project_flock_kandang_id = ?", pfk.Id). Where("project_flock_kandang_id = ? AND chick_in_date IS NOT NULL", pfk.Id).
Scan(&minChickin).Error; err != nil { Order("chick_in_date ASC").
Limit(1).
Scan(&firstChickin).Error; err != nil {
return nil, nil, err return nil, nil, err
} }
start := pfk.CreatedAt start := pfk.CreatedAt
if minChickin != nil && !minChickin.IsZero() { if !firstChickin.ChickInDate.IsZero() {
start = *minChickin start = firstChickin.ChickInDate
} }
startDate := dateOnlyUTC(start) startDate := dateOnlyUTC(start)
@@ -596,26 +600,34 @@ func (s closingService) getSapronakDateRange(ctx context.Context, projectFlockID
return &startDate, endDate, nil return &startDate, endDate, nil
} }
var minCreated time.Time var firstPFK entity.ProjectFlockKandang
if err := db.Model(&entity.ProjectFlockKandang{}). if err := db.Model(&entity.ProjectFlockKandang{}).
Select("MIN(created_at)"). Select("created_at").
Where("project_flock_id = ?", projectFlockID). Where("project_flock_id = ?", projectFlockID).
Scan(&minCreated).Error; err != nil { Order("created_at ASC").
First(&firstPFK).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, nil
}
return nil, nil, err return nil, nil, err
} }
var minChickin *time.Time var firstChickin struct {
ChickInDate time.Time `gorm:"column:chick_in_date"`
}
if err := db.Table("project_chickins pc"). if err := db.Table("project_chickins pc").
Select("MIN(pc.chick_in_date)"). Select("pc.chick_in_date").
Joins("JOIN project_flock_kandangs pfk ON pfk.id = pc.project_flock_kandang_id"). Joins("JOIN project_flock_kandangs pfk ON pfk.id = pc.project_flock_kandang_id").
Where("pfk.project_flock_id = ?", projectFlockID). Where("pfk.project_flock_id = ? AND pc.chick_in_date IS NOT NULL", projectFlockID).
Scan(&minChickin).Error; err != nil { Order("pc.chick_in_date ASC").
Limit(1).
Scan(&firstChickin).Error; err != nil {
return nil, nil, err return nil, nil, err
} }
start := minCreated start := firstPFK.CreatedAt
if minChickin != nil && !minChickin.IsZero() { if !firstChickin.ChickInDate.IsZero() {
start = *minChickin start = firstChickin.ChickInDate
} }
startDate := dateOnlyUTC(start) startDate := dateOnlyUTC(start)
@@ -627,15 +639,19 @@ func (s closingService) getSapronakDateRange(ctx context.Context, projectFlockID
return nil, nil, err return nil, nil, err
} }
if openCount == 0 { if openCount == 0 {
var maxClosed *time.Time var latestClosed entity.ProjectFlockKandang
if err := db.Model(&entity.ProjectFlockKandang{}). if err := db.Model(&entity.ProjectFlockKandang{}).
Select("MAX(closed_at)"). Select("closed_at").
Where("project_flock_id = ?", projectFlockID). Where("project_flock_id = ? AND closed_at IS NOT NULL", projectFlockID).
Scan(&maxClosed).Error; err != nil { Order("closed_at DESC").
First(&latestClosed).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return &startDate, nil, nil
}
return nil, nil, err return nil, nil, err
} }
if maxClosed != nil && !maxClosed.IsZero() { if latestClosed.ClosedAt != nil && !latestClosed.ClosedAt.IsZero() {
d := dateOnlyUTC(*maxClosed) d := dateOnlyUTC(*latestClosed.ClosedAt)
endDate = &d endDate = &d
} }
} }
@@ -0,0 +1,94 @@
package service
import (
"context"
"testing"
"time"
"github.com/glebarez/sqlite"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/repositories"
"gorm.io/gorm"
)
func TestGetSapronakDateRange_ProjectWithoutChickin_DoesNotError(t *testing.T) {
db := setupClosingServiceTestDB(t)
repo := repository.NewClosingRepository(db)
svc := closingService{Repository: repo}
createdAt := time.Date(2026, 4, 15, 7, 0, 0, 0, time.UTC)
if err := db.Exec(`INSERT INTO project_flock_kandangs (id, project_flock_id, created_at, closed_at) VALUES (66, 47, ?, NULL)`, createdAt).Error; err != nil {
t.Fatalf("failed seeding project_flock_kandangs: %v", err)
}
start, end, err := svc.getSapronakDateRange(context.Background(), 47, nil)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if start == nil {
t.Fatalf("expected non-nil start date")
}
expected := dateOnlyUTC(createdAt)
if !start.Equal(expected) {
t.Fatalf("expected start %s, got %s", expected.Format(time.RFC3339), start.Format(time.RFC3339))
}
if end != nil {
t.Fatalf("expected nil end date for open kandang, got %v", end)
}
}
func TestGetSapronakDateRange_KandangWithoutChickin_DoesNotError(t *testing.T) {
db := setupClosingServiceTestDB(t)
repo := repository.NewClosingRepository(db)
svc := closingService{Repository: repo}
createdAt := time.Date(2026, 4, 15, 7, 0, 0, 0, time.UTC)
if err := db.Exec(`INSERT INTO project_flock_kandangs (id, project_flock_id, created_at, closed_at) VALUES (66, 47, ?, NULL)`, createdAt).Error; err != nil {
t.Fatalf("failed seeding project_flock_kandangs: %v", err)
}
pfkID := uint(66)
start, end, err := svc.getSapronakDateRange(context.Background(), 47, &pfkID)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if start == nil {
t.Fatalf("expected non-nil start date")
}
expected := dateOnlyUTC(createdAt)
if !start.Equal(expected) {
t.Fatalf("expected start %s, got %s", expected.Format(time.RFC3339), start.Format(time.RFC3339))
}
if end != nil {
t.Fatalf("expected nil end date for open kandang, got %v", end)
}
}
func setupClosingServiceTestDB(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)
}
stmts := []string{
`CREATE TABLE project_flock_kandangs (
id INTEGER PRIMARY KEY,
project_flock_id INTEGER NOT NULL,
created_at TIMESTAMP NOT NULL,
closed_at TIMESTAMP NULL
)`,
`CREATE TABLE project_chickins (
id INTEGER PRIMARY KEY,
project_flock_kandang_id INTEGER NOT NULL,
chick_in_date TIMESTAMP NULL
)`,
}
for _, stmt := range stmts {
if err := db.Exec(stmt).Error; err != nil {
t.Fatalf("failed preparing schema: %v", err)
}
}
return db
}
@@ -371,7 +371,9 @@ func buildSapronakDetails(
addRows(result.Incoming, incomingRows, "Pembelian", true) addRows(result.Incoming, incomingRows, "Pembelian", true)
addRows(result.Usage, usageRows, "Pemakaian", false) addRows(result.Usage, usageRows, "Pemakaian", false)
addRows(result.AdjIncoming, adjIncomingRows, "Adjustment Masuk", true) addRows(result.AdjIncoming, adjIncomingRows, "Adjustment Masuk", true)
addRows(result.AdjOutgoing, adjOutgoingRows, "Adjustment Keluar", false) // Outgoing adjustment rows here are sourced from stock allocation
// consume flow (adjustment_stocks.usage_qty), so treat them as usage.
addRows(result.AdjOutgoing, adjOutgoingRows, "Pemakaian", false)
addRows(result.TransferIn, transferInRows, "Mutasi Masuk", true) addRows(result.TransferIn, transferInRows, "Mutasi Masuk", true)
addRows(result.TransferOut, transferOutRows, "Mutasi Keluar", false) addRows(result.TransferOut, transferOutRows, "Mutasi Keluar", false)
addRows(result.SalesOut, salesOutRows, "Penjualan", false) addRows(result.SalesOut, salesOutRows, "Penjualan", false)
@@ -0,0 +1,45 @@
package service
import (
"testing"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/repositories"
)
func TestBuildSapronakDetailsMapsAdjustmentOutgoingAsUsage(t *testing.T) {
res := buildSapronakDetails(
map[uint][]repository.SapronakDetailRow{},
map[uint][]repository.SapronakDetailRow{},
map[uint][]repository.SapronakDetailRow{},
map[uint][]repository.SapronakDetailRow{
17: {
{
ProductID: 17,
ProductName: "PAKAN GROWING CRUMBLE 8603 MALINDO",
Flag: "PAKAN",
QtyOut: 9000,
Price: 6450,
},
},
},
map[uint][]repository.SapronakDetailRow{},
map[uint][]repository.SapronakDetailRow{},
map[uint][]repository.SapronakDetailRow{},
)
rows := res.AdjOutgoing[17]
if len(rows) != 1 {
t.Fatalf("expected 1 adjustment outgoing row, got %d", len(rows))
}
row := rows[0]
if row.JenisTransaksi != "Pemakaian" {
t.Fatalf("expected jenis_transaksi Pemakaian, got %q", row.JenisTransaksi)
}
if row.QtyKeluar != 9000 {
t.Fatalf("expected qty_keluar 9000, got %.3f", row.QtyKeluar)
}
if row.Nilai != 58050000 {
t.Fatalf("expected nilai 58050000, got %.3f", row.Nilai)
}
}
@@ -40,7 +40,8 @@ func (r *DailyChecklistRepositoryImpl) ListScopedChecklistIDs(c *fiber.Ctx, ids
Joins("JOIN kandang_groups k ON k.id = dc.kandang_id"). Joins("JOIN kandang_groups k ON k.id = dc.kandang_id").
Joins("JOIN locations loc ON loc.id = k.location_id"). Joins("JOIN locations loc ON loc.id = k.location_id").
Joins("JOIN areas a ON a.id = loc.area_id"). Joins("JOIN areas a ON a.id = loc.area_id").
Where("dc.id IN ?", ids) Where("dc.id IN ?", ids).
Where("dc.deleted_at IS NULL")
db, err := m.ApplyLocationAreaScope(c, db, "loc.id", "a.id") db, err := m.ApplyLocationAreaScope(c, db, "loc.id", "a.id")
if err != nil { if err != nil {
@@ -122,6 +122,13 @@ type DailyChecklistReportCategory struct {
Baik int Baik int
} }
const (
dailyChecklistDateLayout = "2006-01-02"
dailyChecklistCategoryEmptyKandang = "empty_kandang"
dailyChecklistStatusRejected = "REJECTED"
dailyChecklistStatusDraft = "DRAFT"
)
func NewDailyChecklistService(repo repository.DailyChecklistRepository, phaseRepo phaseRepo.PhasesRepository, validate *validator.Validate, documentSvc commonSvc.DocumentService) DailyChecklistService { func NewDailyChecklistService(repo repository.DailyChecklistRepository, phaseRepo phaseRepo.PhasesRepository, validate *validator.Validate, documentSvc commonSvc.DocumentService) DailyChecklistService {
return &dailyChecklistService{ return &dailyChecklistService{
Log: utils.Log, Log: utils.Log,
@@ -146,7 +153,8 @@ func (s dailyChecklistService) ensureChecklistAccess(c *fiber.Ctx, checklistID u
Joins("JOIN kandang_groups k ON k.id = dc.kandang_id"). Joins("JOIN kandang_groups k ON k.id = dc.kandang_id").
Joins("JOIN locations loc ON loc.id = k.location_id"). Joins("JOIN locations loc ON loc.id = k.location_id").
Joins("JOIN areas a ON a.id = loc.area_id"). Joins("JOIN areas a ON a.id = loc.area_id").
Where("dc.id = ?", checklistID) Where("dc.id = ?", checklistID).
Where("dc.deleted_at IS NULL")
scopedDB, err := m.ApplyLocationAreaScope(c, db, "loc.id", "a.id") scopedDB, err := m.ApplyLocationAreaScope(c, db, "loc.id", "a.id")
if err != nil { if err != nil {
@@ -196,7 +204,7 @@ func (s dailyChecklistService) ensureTaskAccess(c *fiber.Ctx, taskID uint) error
db := s.Repository.DB().WithContext(c.Context()). db := s.Repository.DB().WithContext(c.Context()).
Table("daily_checklist_activity_tasks t"). Table("daily_checklist_activity_tasks t").
Joins("JOIN daily_checklists dc ON dc.id = t.checklist_id"). Joins("JOIN daily_checklists dc ON dc.id = t.checklist_id AND dc.deleted_at IS NULL").
Joins("JOIN kandang_groups k ON k.id = dc.kandang_id"). Joins("JOIN kandang_groups k ON k.id = dc.kandang_id").
Joins("JOIN locations loc ON loc.id = k.location_id"). Joins("JOIN locations loc ON loc.id = k.location_id").
Joins("JOIN areas a ON a.id = loc.area_id"). Joins("JOIN areas a ON a.id = loc.area_id").
@@ -228,7 +236,8 @@ func (s dailyChecklistService) GetAll(c *fiber.Ctx, params *validation.Query) ([
Table("daily_checklists dc"). Table("daily_checklists dc").
Joins("JOIN kandang_groups k ON k.id = dc.kandang_id"). Joins("JOIN kandang_groups k ON k.id = dc.kandang_id").
Joins("JOIN locations loc ON loc.id = k.location_id"). Joins("JOIN locations loc ON loc.id = k.location_id").
Joins("JOIN areas a ON a.id = loc.area_id") Joins("JOIN areas a ON a.id = loc.area_id").
Where("dc.deleted_at IS NULL")
var scopeErr error var scopeErr error
db, scopeErr = m.ApplyLocationAreaScope(c, db, "loc.id", "a.id") db, scopeErr = m.ApplyLocationAreaScope(c, db, "loc.id", "a.id")
@@ -501,66 +510,39 @@ func (s *dailyChecklistService) CreateOne(c *fiber.Ctx, req *validation.Create)
return nil, err return nil, err
} }
date, err := time.Parse("2006-01-02", req.Date) date, err := time.Parse(dailyChecklistDateLayout, strings.TrimSpace(req.Date))
if err != nil { if err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "invalid date format, use YYYY-MM-DD") return nil, fiber.NewError(fiber.StatusBadRequest, "invalid date format, use YYYY-MM-DD")
} }
status := req.Status status := req.Status
category := req.Category category := req.Category
endDate := date
if req.EmptyKandang {
if strings.TrimSpace(req.EmptyKandangEndDate) == "" {
return nil, fiber.NewError(fiber.StatusBadRequest, "empty_kandang_end_date is required when empty_kandang is true")
}
endDate, err = time.Parse(dailyChecklistDateLayout, strings.TrimSpace(req.EmptyKandangEndDate))
if err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "invalid empty_kandang_end_date format, use YYYY-MM-DD")
}
if endDate.Before(date) {
return nil, fiber.NewError(fiber.StatusBadRequest, "empty_kandang_end_date must be greater than or equal to date")
}
category = dailyChecklistCategoryEmptyKandang
}
targetID := uint(0) targetID := uint(0)
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
existing := new(entity.DailyChecklist) if req.EmptyKandang {
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). return s.createBulkDailyChecklists(tx, req.KandangId, date, endDate, category, status, &targetID)
Where("date = ? AND kandang_id = ? AND category = ? AND (status IS NULL OR status <> ?)", date, req.KandangId, category, "REJECTED").
Take(existing).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
} }
if err == nil { return s.createOrReuseSingleDailyChecklist(tx, req.KandangId, date, category, status, &targetID)
if err := tx.Model(&entity.DailyChecklist{}).
Where("id = ?", existing.Id).
Update("updated_at", time.Now()).Error; err != nil {
return err
}
targetID = existing.Id
return nil
}
createStatus := status
var rejectedCount int64
if err := tx.Model(&entity.DailyChecklist{}).
Where("date = ? AND kandang_id = ? AND category = ? AND status = ?", date, req.KandangId, category, "REJECTED").
Count(&rejectedCount).Error; err != nil {
return err
}
if rejectedCount > 0 {
createStatus = "DRAFT"
}
createBody := &entity.DailyChecklist{
KandangId: req.KandangId,
Date: date,
Category: category,
Status: &createStatus,
}
if err := tx.Create(createBody).Error; err != nil {
// Handle concurrent insert for active checklist with same key.
if findErr := tx.
Where("date = ? AND kandang_id = ? AND category = ? AND (status IS NULL OR status <> ?)", date, req.KandangId, category, "REJECTED").
Take(existing).Error; findErr == nil {
targetID = existing.Id
return nil
}
return err
}
targetID = createBody.Id
return nil
}) })
if err != nil { if err != nil {
s.Log.Errorf("Failed to create/upsert dailyChecklist: %+v", err) s.Log.Errorf("Failed to create/upsert dailyChecklist: %+v", err)
@@ -570,6 +552,109 @@ func (s *dailyChecklistService) CreateOne(c *fiber.Ctx, req *validation.Create)
return s.GetOne(c, targetID) return s.GetOne(c, targetID)
} }
func (s *dailyChecklistService) createOrReuseSingleDailyChecklist(tx *gorm.DB, kandangID uint, date time.Time, category, status string, targetID *uint) error {
existing := new(entity.DailyChecklist)
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("date = ? AND kandang_id = ? AND category = ? AND (status IS NULL OR status <> ?) AND deleted_at IS NULL", date, kandangID, category, dailyChecklistStatusRejected).
Take(existing).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
if err == nil {
if err := tx.Model(&entity.DailyChecklist{}).
Where("id = ?", existing.Id).
Update("updated_at", time.Now()).Error; err != nil {
return err
}
*targetID = existing.Id
return nil
}
createStatus := status
var rejectedCount int64
if err := tx.Model(&entity.DailyChecklist{}).
Where("date = ? AND kandang_id = ? AND category = ? AND status = ? AND deleted_at IS NULL", date, kandangID, category, dailyChecklistStatusRejected).
Count(&rejectedCount).Error; err != nil {
return err
}
if rejectedCount > 0 {
createStatus = dailyChecklistStatusDraft
}
createBody := &entity.DailyChecklist{
KandangId: kandangID,
Date: date,
Category: category,
Status: &createStatus,
}
if err := tx.Create(createBody).Error; err != nil {
// Handle concurrent insert for active checklist with same key.
if findErr := tx.
Where("date = ? AND kandang_id = ? AND category = ? AND (status IS NULL OR status <> ?) AND deleted_at IS NULL", date, kandangID, category, dailyChecklistStatusRejected).
Take(existing).Error; findErr == nil {
*targetID = existing.Id
return nil
}
return err
}
*targetID = createBody.Id
return nil
}
func (s *dailyChecklistService) createBulkDailyChecklists(tx *gorm.DB, kandangID uint, startDate, endDate time.Time, category, status string, targetID *uint) error {
var conflictCount int64
if err := tx.Model(&entity.DailyChecklist{}).
Where("kandang_id = ? AND category = ? AND date BETWEEN ? AND ? AND (status IS NULL OR status <> ?) AND deleted_at IS NULL", kandangID, category, startDate, endDate, dailyChecklistStatusRejected).
Count(&conflictCount).Error; err != nil {
return err
}
if conflictCount > 0 {
return fiber.NewError(fiber.StatusConflict, "DailyChecklist already exists for at least one date in range")
}
for currentDate := startDate; !currentDate.After(endDate); currentDate = currentDate.AddDate(0, 0, 1) {
createStatus := status
var rejectedCount int64
if err := tx.Model(&entity.DailyChecklist{}).
Where("date = ? AND kandang_id = ? AND category = ? AND status = ? AND deleted_at IS NULL", currentDate, kandangID, category, dailyChecklistStatusRejected).
Count(&rejectedCount).Error; err != nil {
return err
}
if rejectedCount > 0 {
createStatus = dailyChecklistStatusDraft
}
createBody := &entity.DailyChecklist{
KandangId: kandangID,
Date: currentDate,
Category: category,
Status: &createStatus,
}
if err := tx.Create(createBody).Error; err != nil {
// Handle concurrent insert for active checklist in same date range.
var existingActiveCount int64
checkErr := tx.Model(&entity.DailyChecklist{}).
Where("date = ? AND kandang_id = ? AND category = ? AND (status IS NULL OR status <> ?) AND deleted_at IS NULL", currentDate, kandangID, category, dailyChecklistStatusRejected).
Count(&existingActiveCount).Error
if checkErr == nil && existingActiveCount > 0 {
return fiber.NewError(fiber.StatusConflict, "DailyChecklist already exists for at least one date in range")
}
return err
}
if currentDate.Equal(startDate) {
*targetID = createBody.Id
}
}
return nil
}
func (s dailyChecklistService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.DailyChecklist, error) { func (s dailyChecklistService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.DailyChecklist, error) {
if err := s.Validate.Struct(req); err != nil { if err := s.Validate.Struct(req); err != nil {
return nil, err return nil, err
@@ -712,7 +797,35 @@ func (s dailyChecklistService) DeleteOne(c *fiber.Ctx, id uint) error {
if err := s.ensureChecklistAccess(c, id); err != nil { if err := s.ensureChecklistAccess(c, id); err != nil {
return err return err
} }
if err := s.Repository.DeleteOne(c.Context(), id); err != nil { actorID, err := m.ActorIDFromContext(c)
if err != nil {
return 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 {
updateResult := tx.Model(&entity.DailyChecklist{}).
Where("id = ?", id).
Updates(map[string]any{
"deleted_by": actorID,
"updated_at": time.Now(),
})
if updateResult.Error != nil {
return updateResult.Error
}
if updateResult.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
deleteResult := tx.Delete(&entity.DailyChecklist{}, id)
if deleteResult.Error != nil {
return deleteResult.Error
}
if deleteResult.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found") return fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found")
} }
@@ -1152,7 +1265,7 @@ func (s dailyChecklistService) GetSummary(c *fiber.Ctx, params *validation.Summa
SUM(CASE WHEN NOT a.checked THEN 1 ELSE 0 END) AS activity_left, SUM(CASE WHEN NOT a.checked THEN 1 ELSE 0 END) AS activity_left,
MAX(a.updated_at) AS last_activity`). MAX(a.updated_at) AS last_activity`).
Joins("JOIN daily_checklist_activity_tasks t ON t.id = a.task_id"). Joins("JOIN daily_checklist_activity_tasks t ON t.id = a.task_id").
Joins("JOIN daily_checklists d ON d.id = t.checklist_id"). Joins("JOIN daily_checklists d ON d.id = t.checklist_id AND d.deleted_at IS NULL").
Joins("JOIN kandang_groups k ON k.id = d.kandang_id"). Joins("JOIN kandang_groups k ON k.id = d.kandang_id").
Joins("JOIN employees e ON e.id = a.employee_id"). Joins("JOIN employees e ON e.id = a.employee_id").
Joins("JOIN locations loc ON loc.id = k.location_id"). Joins("JOIN locations loc ON loc.id = k.location_id").
@@ -1224,7 +1337,7 @@ func (s dailyChecklistService) GetReport(c *fiber.Ctx, params *validation.Report
db := s.Repository.DB().WithContext(c.Context()). db := s.Repository.DB().WithContext(c.Context()).
Table("daily_checklist_activity_task_assignments AS dca"). Table("daily_checklist_activity_task_assignments AS dca").
Joins("JOIN daily_checklist_activity_tasks dcat ON dcat.id = dca.task_id"). Joins("JOIN daily_checklist_activity_tasks dcat ON dcat.id = dca.task_id").
Joins("JOIN daily_checklists dc ON dc.id = dcat.checklist_id"). Joins("JOIN daily_checklists dc ON dc.id = dcat.checklist_id AND dc.deleted_at IS NULL").
Joins("JOIN employees e ON e.id = dca.employee_id"). Joins("JOIN employees e ON e.id = dca.employee_id").
Joins("JOIN kandang_groups k ON k.id = dc.kandang_id"). Joins("JOIN kandang_groups k ON k.id = dc.kandang_id").
Joins("JOIN locations loc ON loc.id = k.location_id"). Joins("JOIN locations loc ON loc.id = k.location_id").
@@ -5,10 +5,12 @@ import (
) )
type Create struct { type Create struct {
Date string `json:"date" validate:"required"` Date string `json:"date" validate:"required"`
KandangId uint `json:"kandang_id" validate:"required"` KandangId uint `json:"kandang_id" validate:"required"`
Category string `json:"category" validate:"required"` Category string `json:"category" validate:"required"`
Status string `json:"status" validate:"required"` Status string `json:"status" validate:"required"`
EmptyKandang bool `json:"empty_kandang"`
EmptyKandangEndDate string `json:"empty_kandang_end_date"`
} }
type Update struct { type Update struct {
@@ -24,6 +24,8 @@ type ExpenseController struct {
ExpenseService service.ExpenseService ExpenseService service.ExpenseService
} }
const expenseExcelExportFetchLimit = 100
func NewExpenseController(expenseService service.ExpenseService) *ExpenseController { func NewExpenseController(expenseService service.ExpenseService) *ExpenseController {
return &ExpenseController{ return &ExpenseController{
ExpenseService: expenseService, ExpenseService: expenseService,
@@ -51,9 +53,26 @@ func (u *ExpenseController) GetAll(c *fiber.Ctx) error {
} }
query := &validation.Query{ query := &validation.Query{
Page: c.QueryInt("page", 1), Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10), Limit: c.QueryInt("limit", 10),
Search: c.Query("search", ""), Search: strings.TrimSpace(c.Query("search", "")),
TransactionDate: strings.TrimSpace(c.Query("transaction_date", "")),
RealizationDate: strings.TrimSpace(c.Query("realization_date", "")),
LocationID: uint64(c.QueryInt("location_id", 0)),
VendorID: uint64(c.QueryInt("vendor_id", 0)),
Category: strings.TrimSpace(c.Query("category", "")),
ApprovalStatus: strings.TrimSpace(c.Query("approval_status", "")),
RealizationStatus: strings.TrimSpace(c.Query("realization_status", "")),
ProjectFlockID: uint64(c.QueryInt("project_flock_id", 0)),
ProjectFlockKandangID: uint64(c.QueryInt("project_flock_kandang_id", 0)),
}
if isAllExpenseExcelExportRequest(c) {
allResults, err := u.getAllExpensesForExcel(c, query)
if err != nil {
return err
}
return exportExpenseListExcel(c, allResults)
} }
if query.Page < 1 || query.Limit < 1 { if query.Page < 1 || query.Limit < 1 {
@@ -80,6 +99,33 @@ func (u *ExpenseController) GetAll(c *fiber.Ctx) error {
}) })
} }
func (u *ExpenseController) getAllExpensesForExcel(c *fiber.Ctx, baseQuery *validation.Query) ([]dto.ExpenseListDTO, error) {
query := *baseQuery
query.Page = 1
query.Limit = expenseExcelExportFetchLimit
results := make([]dto.ExpenseListDTO, 0)
for {
pageResults, total, err := u.ExpenseService.GetAll(c, &query)
if err != nil {
return nil, err
}
if len(pageResults) == 0 || total == 0 {
break
}
results = append(results, pageResults...)
if int64(len(results)) >= total {
break
}
query.Page++
}
return results, nil
}
func (u *ExpenseController) GetOne(c *fiber.Ctx) error { func (u *ExpenseController) GetOne(c *fiber.Ctx) error {
param := c.Params("id") param := c.Params("id")
@@ -0,0 +1,225 @@
package controller
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto"
"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"
locationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto"
supplierDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/dto"
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
"github.com/gofiber/fiber/v2"
"github.com/xuri/excelize/v2"
)
type expenseServiceStub struct {
getAllCalls []validation.Query
}
var _ service.ExpenseService = (*expenseServiceStub)(nil)
func (s *expenseServiceStub) GetAll(_ *fiber.Ctx, params *validation.Query) ([]dto.ExpenseListDTO, int64, error) {
callCopy := *params
s.getAllCalls = append(s.getAllCalls, callCopy)
switch params.Page {
case 1:
return []dto.ExpenseListDTO{
buildExpenseListForControllerTest("EXP-00001"),
buildExpenseListForControllerTest("EXP-00002"),
}, 3, nil
case 2:
return []dto.ExpenseListDTO{
buildExpenseListForControllerTest("EXP-00003"),
}, 3, nil
default:
return []dto.ExpenseListDTO{}, 3, nil
}
}
func (s *expenseServiceStub) GetOne(_ *fiber.Ctx, _ uint) (*dto.ExpenseDetailDTO, error) {
return &dto.ExpenseDetailDTO{}, nil
}
func (s *expenseServiceStub) CreateOne(_ *fiber.Ctx, _ *validation.Create) (*dto.ExpenseDetailDTO, error) {
return &dto.ExpenseDetailDTO{}, nil
}
func (s *expenseServiceStub) UpdateOne(_ *fiber.Ctx, _ *validation.Update, _ uint) (*dto.ExpenseDetailDTO, error) {
return &dto.ExpenseDetailDTO{}, nil
}
func (s *expenseServiceStub) DeleteOne(_ *fiber.Ctx, _ uint64) error {
return nil
}
func (s *expenseServiceStub) CreateRealization(_ *fiber.Ctx, _ uint, _ *validation.CreateRealization) (*dto.ExpenseDetailDTO, error) {
return &dto.ExpenseDetailDTO{}, nil
}
func (s *expenseServiceStub) CompleteExpense(_ *fiber.Ctx, _ uint, _ *string) (*dto.ExpenseDetailDTO, error) {
return &dto.ExpenseDetailDTO{}, nil
}
func (s *expenseServiceStub) UpdateRealization(_ *fiber.Ctx, _ uint, _ *validation.UpdateRealization) (*dto.ExpenseDetailDTO, error) {
return &dto.ExpenseDetailDTO{}, nil
}
func (s *expenseServiceStub) DeleteDocument(_ *fiber.Ctx, _ uint, _ uint64, _ bool) error {
return nil
}
func (s *expenseServiceStub) Approval(_ *fiber.Ctx, _ *validation.ApprovalRequest, _ string) ([]dto.ExpenseDetailDTO, error) {
return nil, nil
}
func (s *expenseServiceStub) BulkApproveToStatus(_ *fiber.Ctx, _ *validation.BulkApprovalRequest, _ approvalutils.ApprovalStep) ([]dto.ExpenseDetailDTO, error) {
return nil, nil
}
func (s *expenseServiceStub) GetProgressRows(_ *fiber.Ctx, _ *exportprogress.Query) ([]exportprogress.Row, error) {
return nil, nil
}
func TestExpenseControllerGetAllExportAllIgnoresRequestLimit(t *testing.T) {
app := fiber.New()
stub := &expenseServiceStub{}
ctrl := NewExpenseController(stub)
app.Get("/expenses", ctrl.GetAll)
req := httptest.NewRequest(
http.MethodGet,
"/expenses?export=excel&type=all&page=9&limit=1&search=operasional",
nil,
)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("unexpected app.Test error: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != fiber.StatusOK {
t.Fatalf("expected status 200, got %d", resp.StatusCode)
}
contentType := resp.Header.Get("Content-Type")
if !strings.Contains(contentType, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") {
t.Fatalf("unexpected content-type: %s", contentType)
}
disposition := resp.Header.Get("Content-Disposition")
if !strings.Contains(disposition, "expenses_all_") {
t.Fatalf("unexpected content-disposition: %s", disposition)
}
if len(stub.getAllCalls) != 2 {
t.Fatalf("expected 2 GetAll calls, got %d", len(stub.getAllCalls))
}
firstCall := stub.getAllCalls[0]
secondCall := stub.getAllCalls[1]
if firstCall.Page != 1 || secondCall.Page != 2 {
t.Fatalf("expected internal paging page 1 and 2, got %d and %d", firstCall.Page, secondCall.Page)
}
if firstCall.Limit != expenseExcelExportFetchLimit || secondCall.Limit != expenseExcelExportFetchLimit {
t.Fatalf("expected internal limit %d, got %d and %d", expenseExcelExportFetchLimit, firstCall.Limit, secondCall.Limit)
}
if firstCall.Search != "operasional" {
t.Fatalf("expected search filter to be forwarded, got %q", firstCall.Search)
}
payload, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("failed to read excel payload: %v", err)
}
file, err := excelize.OpenReader(bytes.NewReader(payload))
if err != nil {
t.Fatalf("failed to parse excel payload: %v", err)
}
defer file.Close()
if got, _ := file.GetCellValue(expenseExportSheetName, "A1"); got != "No" {
t.Fatalf("expected A1 header to be No, got %q", got)
}
if got, _ := file.GetCellValue(expenseExportSheetName, "C2"); got != "EXP-00001" {
t.Fatalf("expected first row reference EXP-00001, got %q", got)
}
}
func TestExpenseControllerGetAllKeepsPaginationValidationForNonExportAll(t *testing.T) {
app := fiber.New()
stub := &expenseServiceStub{}
ctrl := NewExpenseController(stub)
app.Get("/expenses", ctrl.GetAll)
req := httptest.NewRequest(http.MethodGet, "/expenses?page=1&limit=0", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("unexpected app.Test error: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != fiber.StatusBadRequest {
t.Fatalf("expected status 400, got %d", resp.StatusCode)
}
}
func TestExpenseControllerGetAllProgressExportUnchanged(t *testing.T) {
app := fiber.New()
stub := &expenseServiceStub{}
ctrl := NewExpenseController(stub)
app.Get("/expenses", ctrl.GetAll)
req := httptest.NewRequest(
http.MethodGet,
"/expenses?export=excel&type=progress&start_date=2026-04-01&end_date=2026-04-22&limit=0",
nil,
)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("unexpected app.Test error: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != fiber.StatusOK {
t.Fatalf("expected status 200, got %d", resp.StatusCode)
}
if len(stub.getAllCalls) != 0 {
t.Fatalf("expected list GetAll not to be called for progress export, got %d calls", len(stub.getAllCalls))
}
}
func buildExpenseListForControllerTest(referenceNumber string) dto.ExpenseListDTO {
approvedAction := string(entity.ApprovalActionApproved)
return dto.ExpenseListDTO{
ExpenseBaseDTO: dto.ExpenseBaseDTO{
ReferenceNumber: referenceNumber,
PoNumber: "PO-" + strings.TrimPrefix(referenceNumber, "EXP-"),
TransactionDate: time.Date(2026, time.April, 22, 0, 0, 0, 0, time.UTC),
Category: "BOP",
Supplier: &supplierDTO.SupplierRelationDTO{
Name: "Supplier A",
},
Location: &locationDTO.LocationRelationDTO{
Name: "Farm A",
},
},
GrandTotal: 1500000,
LatestApproval: &approvalDTO.ApprovalRelationDTO{
StepName: "Finance",
Action: &approvedAction,
},
}
}
@@ -0,0 +1,295 @@
package controller
import (
"fmt"
"math"
"strconv"
"strings"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto"
"gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/dto"
locationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto"
supplierDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/dto"
"github.com/gofiber/fiber/v2"
"github.com/xuri/excelize/v2"
)
const expenseExportSheetName = "Expenses"
func isAllExpenseExcelExportRequest(c *fiber.Ctx) bool {
return strings.EqualFold(strings.TrimSpace(c.Query("export")), "excel") &&
strings.EqualFold(strings.TrimSpace(c.Query("type")), "all")
}
func exportExpenseListExcel(c *fiber.Ctx, items []dto.ExpenseListDTO) error {
content, err := buildExpenseExportWorkbook(items)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to generate excel file")
}
filename := fmt.Sprintf("expenses_all_%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)
}
func buildExpenseExportWorkbook(items []dto.ExpenseListDTO) ([]byte, error) {
file := excelize.NewFile()
defer file.Close()
defaultSheet := file.GetSheetName(file.GetActiveSheetIndex())
if defaultSheet != expenseExportSheetName {
if err := file.SetSheetName(defaultSheet, expenseExportSheetName); err != nil {
return nil, err
}
}
if err := setExpenseExportColumns(file, expenseExportSheetName); err != nil {
return nil, err
}
if err := setExpenseExportHeaders(file, expenseExportSheetName); err != nil {
return nil, err
}
if err := setExpenseExportRows(file, expenseExportSheetName, items); err != nil {
return nil, err
}
if err := file.SetPanes(expenseExportSheetName, &excelize.Panes{
Freeze: true,
YSplit: 1,
TopLeftCell: "A2",
ActivePane: "bottomLeft",
}); err != nil {
return nil, err
}
buffer, err := file.WriteToBuffer()
if err != nil {
return nil, err
}
return buffer.Bytes(), nil
}
func setExpenseExportColumns(file *excelize.File, sheet string) error {
columnWidths := map[string]float64{
"A": 8,
"B": 16,
"C": 20,
"D": 18,
"E": 18,
"F": 16,
"G": 24,
"H": 22,
"I": 16,
"J": 24,
}
for col, width := range columnWidths {
if err := file.SetColWidth(sheet, col, col, width); err != nil {
return err
}
}
return file.SetRowHeight(sheet, 1, 24)
}
func setExpenseExportHeaders(file *excelize.File, sheet string) error {
headers := []string{
"No",
"No. PO",
"No. Referensi",
"Tanggal Realisasi",
"Tanggal Transaksi",
"Kategori",
"Produk",
"Lokasi",
"Grand Total",
"Status",
}
for i, header := range headers {
colName, err := excelize.ColumnNumberToName(i + 1)
if err != nil {
return err
}
if err := file.SetCellValue(sheet, colName+"1", header); err != nil {
return err
}
}
headerStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Color: "1F2937"},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"DCEBFA"}},
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 err
}
return file.SetCellStyle(sheet, "A1", "J1", headerStyle)
}
func setExpenseExportRows(file *excelize.File, sheet string, items []dto.ExpenseListDTO) error {
if len(items) == 0 {
return nil
}
for i, item := range items {
row := strconv.Itoa(i + 2)
if err := file.SetCellValue(sheet, "A"+row, i+1); err != nil {
return err
}
if err := file.SetCellValue(sheet, "B"+row, safeExpenseExportText(item.PoNumber)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "C"+row, safeExpenseExportText(item.ReferenceNumber)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "D"+row, formatExpenseExportDate(item.RealizationDate)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "E"+row, formatExpenseExportDate(&item.TransactionDate)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "F"+row, safeExpenseExportText(item.Category)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "G"+row, safeExpenseSupplierName(item.Supplier)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "H"+row, safeExpenseLocationName(item.Location)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "I"+row, safeExpenseExportNumber(item.GrandTotal)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "J"+row, formatExpenseExportStatus(item.LatestApproval)); err != nil {
return err
}
}
lastRow := len(items) + 1
dataStyle, err := file.NewStyle(&excelize.Style{
Alignment: &excelize.Alignment{
Horizontal: "left",
Vertical: "center",
WrapText: false,
},
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 err
}
if err := file.SetCellStyle(sheet, "A2", "J"+strconv.Itoa(lastRow), dataStyle); err != nil {
return err
}
ordinalStyle, 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 err
}
if err := file.SetCellStyle(sheet, "A2", "A"+strconv.Itoa(lastRow), ordinalStyle); err != nil {
return err
}
numberFormat := "#,##0.##"
numberStyle, err := file.NewStyle(&excelize.Style{
Alignment: &excelize.Alignment{
Horizontal: "right",
Vertical: "center",
},
CustomNumFmt: &numberFormat,
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 err
}
return file.SetCellStyle(sheet, "I2", "I"+strconv.Itoa(lastRow), numberStyle)
}
func formatExpenseExportDate(value *time.Time) string {
if value == nil || value.IsZero() {
return "-"
}
t := *value
location, err := time.LoadLocation("Asia/Jakarta")
if err == nil {
t = t.In(location)
}
return t.Format("02-01-2006")
}
func formatExpenseExportStatus(latestApproval *approvalDTO.ApprovalRelationDTO) string {
if latestApproval == nil {
return "-"
}
if latestApproval.Action != nil &&
strings.EqualFold(strings.TrimSpace(*latestApproval.Action), string(entity.ApprovalActionRejected)) {
return "Ditolak"
}
return safeExpenseExportText(latestApproval.StepName)
}
func safeExpenseSupplierName(value *supplierDTO.SupplierRelationDTO) string {
if value == nil {
return "-"
}
return safeExpenseExportText(value.Name)
}
func safeExpenseLocationName(value *locationDTO.LocationRelationDTO) string {
if value == nil {
return "-"
}
return safeExpenseExportText(value.Name)
}
func safeExpenseExportNumber(value float64) float64 {
if math.IsNaN(value) || math.IsInf(value, 0) {
return 0
}
return value
}
func safeExpenseExportText(value string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return "-"
}
return trimmed
}
@@ -0,0 +1,137 @@
package controller
import (
"bytes"
"testing"
"time"
approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto"
"gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/dto"
locationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto"
supplierDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/dto"
"github.com/xuri/excelize/v2"
)
func TestBuildExpenseExportWorkbookHeadersAndRows(t *testing.T) {
realizationDate := time.Date(2026, time.April, 22, 0, 0, 0, 0, time.UTC)
content, err := buildExpenseExportWorkbook([]dto.ExpenseListDTO{
{
ExpenseBaseDTO: dto.ExpenseBaseDTO{
PoNumber: "PO-00011",
ReferenceNumber: "EXP-00011",
RealizationDate: &realizationDate,
TransactionDate: time.Date(2026, time.April, 22, 0, 0, 0, 0, time.UTC),
Category: "BOP",
Supplier: &supplierDTO.SupplierRelationDTO{
Name: "Supplier A",
},
Location: &locationDTO.LocationRelationDTO{
Name: "Farm A",
},
},
GrandTotal: 1234567,
LatestApproval: &approvalDTO.ApprovalRelationDTO{
StepName: "Finance",
},
},
{
ExpenseBaseDTO: dto.ExpenseBaseDTO{
PoNumber: "",
ReferenceNumber: "",
Category: "",
},
GrandTotal: 75000,
LatestApproval: &approvalDTO.ApprovalRelationDTO{
StepName: "Head Area",
Action: expenseStrPtr("REJECTED"),
},
},
{
ExpenseBaseDTO: dto.ExpenseBaseDTO{},
GrandTotal: 0,
LatestApproval: nil,
},
})
if err != nil {
t.Fatalf("buildExpenseExportWorkbook returned error: %v", err)
}
file, err := excelize.OpenReader(bytes.NewReader(content))
if err != nil {
t.Fatalf("failed to open workbook bytes: %v", err)
}
defer file.Close()
sheets := file.GetSheetList()
if len(sheets) != 1 || sheets[0] != expenseExportSheetName {
t.Fatalf("expected single sheet %q, got %+v", expenseExportSheetName, sheets)
}
expectedHeaders := map[string]string{
"A1": "No",
"B1": "No. PO",
"C1": "No. Referensi",
"D1": "Tanggal Realisasi",
"E1": "Tanggal Transaksi",
"F1": "Kategori",
"G1": "Supplier",
"H1": "Lokasi",
"I1": "Grand Total",
"J1": "Status",
}
for cell, expected := range expectedHeaders {
got, err := file.GetCellValue(expenseExportSheetName, cell)
if err != nil {
t.Fatalf("GetCellValue(%s) failed: %v", cell, err)
}
if got != expected {
t.Fatalf("expected %s=%q, got %q", cell, expected, got)
}
}
assertExpenseCellEquals(t, file, "A2", "1")
assertExpenseCellEquals(t, file, "B2", "PO-00011")
assertExpenseCellEquals(t, file, "C2", "EXP-00011")
assertExpenseCellEquals(t, file, "D2", "22-04-2026")
assertExpenseCellEquals(t, file, "E2", "22-04-2026")
assertExpenseCellEquals(t, file, "F2", "BOP")
assertExpenseCellEquals(t, file, "G2", "Supplier A")
assertExpenseCellEquals(t, file, "H2", "Farm A")
assertExpenseCellEquals(t, file, "J2", "Finance")
rawGrandTotal, err := file.GetCellValue(expenseExportSheetName, "I2", excelize.Options{RawCellValue: true})
if err != nil {
t.Fatalf("GetCellValue(I2, RawCellValue) failed: %v", err)
}
if rawGrandTotal != "1234567" {
t.Fatalf("expected raw I2 grand total 1234567, got %q", rawGrandTotal)
}
assertExpenseCellEquals(t, file, "B3", "-")
assertExpenseCellEquals(t, file, "C3", "-")
assertExpenseCellEquals(t, file, "D3", "-")
assertExpenseCellEquals(t, file, "E3", "-")
assertExpenseCellEquals(t, file, "F3", "-")
assertExpenseCellEquals(t, file, "G3", "-")
assertExpenseCellEquals(t, file, "H3", "-")
assertExpenseCellEquals(t, file, "J3", "Ditolak")
assertExpenseCellEquals(t, file, "J4", "-")
}
func assertExpenseCellEquals(t *testing.T, file *excelize.File, cell, expected string) {
t.Helper()
got, err := file.GetCellValue(expenseExportSheetName, cell)
if err != nil {
t.Fatalf("GetCellValue(%s) failed: %v", cell, err)
}
if got != expected {
t.Fatalf("expected %s=%q, got %q", cell, expected, got)
}
}
func expenseStrPtr(value string) *string {
return &value
}
@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"strings"
"time" "time"
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress" "gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
@@ -86,6 +87,25 @@ func (s expenseService) withRelations(db *gorm.DB) *gorm.DB {
}) })
} }
func normalizeExpenseApprovalStatusFilter(raw string) string {
switch strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(raw), " ", "_")) {
case "HEAD_AREA", "APPROVAL_HEAD_AREA":
return "Approval Head Area"
case "UNIT_VICE_PRESIDENT", "APPROVAL_UNIT_VICE_PRESIDENT", "BUSINESS_UNIT_VICE_PRESIDENT", "APPROVAL_BUSINESS_UNIT_VICE_PRESIDENT":
return "Approval Unit Vice President"
case "FINANCE", "APPROVAL_FINANCE":
return "Approval Finance"
case "REALISASI":
return "Realisasi"
case "SELESAI":
return "Selesai"
case "DITOLAK", "REJECTED":
return "REJECTED"
default:
return ""
}
}
func (s expenseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]expenseDto.ExpenseListDTO, int64, error) { func (s expenseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]expenseDto.ExpenseListDTO, int64, error) {
if err := s.Validate.Struct(params); err != nil { if err := s.Validate.Struct(params); err != nil {
return nil, 0, err return nil, 0, err
@@ -98,10 +118,177 @@ func (s expenseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]expens
expenses, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { expenses, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
db = s.withRelations(db) db = s.withRelations(db)
db, scopeErr = middleware.ApplyLocationScope(c, db, "expenses.location_id") db, scopeErr = middleware.ApplyLocationScope(c, db, "expenses.location_id")
if params.Search != "" { db = db.Where("expenses.deleted_at IS NULL")
return db.Where("category ILIKE ?", "%"+params.Search+"%")
if params.TransactionDate != "" {
db = db.Where("DATE(expenses.transaction_date) = DATE(?)", params.TransactionDate)
} }
return db.Order("created_at DESC").Order("updated_at DESC") if params.RealizationDate != "" {
db = db.Where("DATE(expenses.realization_date) = DATE(?)", params.RealizationDate)
}
if params.LocationID > 0 {
db = db.Where("expenses.location_id = ?", params.LocationID)
}
if params.VendorID > 0 {
db = db.Where("expenses.supplier_id = ?", params.VendorID)
}
if params.Category != "" {
db = db.Where("expenses.category = ?", params.Category)
}
if params.ProjectFlockID > 0 {
projectFlockJSON := fmt.Sprintf("[%d]", params.ProjectFlockID)
db = db.Where(`(
EXISTS (
SELECT 1
FROM expense_nonstocks en
LEFT JOIN project_flock_kandangs pfk ON pfk.id = en.project_flock_kandang_id
WHERE en.expense_id = expenses.id
AND (
pfk.project_flock_id = ? OR
en.kandang_id IN (
SELECT kandang_id
FROM project_flock_kandangs
WHERE project_flock_id = ?
)
)
) OR
(expenses.project_flock_id IS NOT NULL AND expenses.project_flock_id::jsonb @> ?::jsonb)
)`, params.ProjectFlockID, params.ProjectFlockID, projectFlockJSON)
}
if params.ProjectFlockKandangID > 0 {
db = db.Where(`EXISTS (
SELECT 1
FROM expense_nonstocks en
LEFT JOIN project_flock_kandangs selected_pfk ON selected_pfk.id = ?
WHERE en.expense_id = expenses.id
AND (
en.project_flock_kandang_id = ? OR
(selected_pfk.kandang_id IS NOT NULL AND en.kandang_id = selected_pfk.kandang_id)
)
)`, params.ProjectFlockKandangID, params.ProjectFlockKandangID)
}
latestApprovalSubQuery := s.Repository.DB().
WithContext(c.Context()).
Table("approvals").
Select("DISTINCT ON (approvable_id) approvable_id, step_name, action, step_number").
Where("approvable_type = ?", utils.ApprovalWorkflowExpense.String()).
Order("approvable_id, action_at DESC, id DESC")
if approvalStatus := normalizeExpenseApprovalStatusFilter(params.ApprovalStatus); approvalStatus != "" {
if approvalStatus == "REJECTED" {
db = db.Where(`EXISTS (
SELECT 1
FROM (?) AS latest_approval
WHERE latest_approval.approvable_id = expenses.id
AND latest_approval.action = ?
)`, latestApprovalSubQuery, string(entity.ApprovalActionRejected))
} else {
db = db.Where(`EXISTS (
SELECT 1
FROM (?) AS latest_approval
WHERE latest_approval.approvable_id = expenses.id
AND LOWER(latest_approval.step_name) = LOWER(?)
AND (latest_approval.action IS NULL OR latest_approval.action <> ?)
)`, latestApprovalSubQuery, approvalStatus, string(entity.ApprovalActionRejected))
}
}
switch strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(params.RealizationStatus), " ", "_")) {
case "REALIZED", "SUDAH_REALISASI":
db = db.Where(`EXISTS (
SELECT 1
FROM (?) AS latest_approval
WHERE latest_approval.approvable_id = expenses.id
AND (latest_approval.action IS NULL OR latest_approval.action <> ?)
AND latest_approval.step_number >= 5
)`, latestApprovalSubQuery, string(entity.ApprovalActionRejected))
case "NOT_REALIZED", "BELUM_REALISASI":
db = db.Where(`NOT EXISTS (
SELECT 1
FROM (?) AS latest_approval
WHERE latest_approval.approvable_id = expenses.id
AND (latest_approval.action IS NULL OR latest_approval.action <> ?)
AND latest_approval.step_number >= 5
)`, latestApprovalSubQuery, string(entity.ApprovalActionRejected))
db = db.Where(`NOT EXISTS (
SELECT 1
FROM (?) AS latest_approval
WHERE latest_approval.approvable_id = expenses.id
AND latest_approval.action = ?
)`, latestApprovalSubQuery, string(entity.ApprovalActionRejected))
case "REJECTED", "DITOLAK":
db = db.Where(`EXISTS (
SELECT 1
FROM (?) AS latest_approval
WHERE latest_approval.approvable_id = expenses.id
AND latest_approval.action = ?
)`, latestApprovalSubQuery, string(entity.ApprovalActionRejected))
}
if search := strings.ToLower(strings.TrimSpace(params.Search)); search != "" {
like := "%" + search + "%"
db = db.Where(`(
LOWER(COALESCE(expenses.reference_number, '')) LIKE ?
OR LOWER(COALESCE(expenses.po_number, '')) LIKE ?
OR LOWER(COALESCE(expenses.category, '')) LIKE ?
OR EXISTS (
SELECT 1
FROM suppliers s
WHERE s.id = expenses.supplier_id
AND LOWER(COALESCE(s.name, '')) LIKE ?
)
OR EXISTS (
SELECT 1
FROM locations l
WHERE l.id = expenses.location_id
AND LOWER(COALESCE(l.name, '')) LIKE ?
)
OR EXISTS (
SELECT 1
FROM users u
WHERE u.id = expenses.created_by
AND LOWER(COALESCE(u.name, '')) LIKE ?
)
OR EXISTS (
SELECT 1
FROM expense_nonstocks en
LEFT JOIN project_flock_kandangs pfk ON pfk.id = en.project_flock_kandang_id
LEFT JOIN project_flocks pf ON pf.id = pfk.project_flock_id
LEFT JOIN kandangs k ON k.id = COALESCE(en.kandang_id, pfk.kandang_id)
WHERE en.expense_id = expenses.id
AND (
LOWER(COALESCE(pf.flock_name, '')) LIKE ? OR
LOWER(COALESCE(k.name, '')) LIKE ?
)
)
OR EXISTS (
SELECT 1
FROM approvals a
WHERE a.approvable_type = ?
AND a.approvable_id = expenses.id
AND (
LOWER(COALESCE(a.step_name, '')) LIKE ? OR
LOWER(COALESCE(CAST(a.action AS TEXT), '')) LIKE ? OR
LOWER(COALESCE(a.notes, '')) LIKE ?
)
)
)`,
like,
like,
like,
like,
like,
like,
like,
like,
utils.ApprovalWorkflowExpense.String(),
like,
like,
like,
)
}
return db.Order("expenses.created_at DESC").Order("expenses.updated_at DESC")
}) })
if scopeErr != nil { if scopeErr != nil {
@@ -42,9 +42,18 @@ type Update struct {
} }
type Query struct { type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` Page int `query:"page" validate:"omitempty,number,min=1,gt=0"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"`
Search string `query:"search" validate:"omitempty,max=50"` Search string `query:"search" validate:"omitempty,max=100"`
TransactionDate string `query:"transaction_date" validate:"omitempty,datetime=2006-01-02"`
RealizationDate string `query:"realization_date" validate:"omitempty,datetime=2006-01-02"`
LocationID uint64 `query:"location_id" validate:"omitempty,gt=0"`
VendorID uint64 `query:"vendor_id" validate:"omitempty,gt=0"`
Category string `query:"category" validate:"omitempty,oneof=BOP NON-BOP"`
ApprovalStatus string `query:"approval_status" validate:"omitempty,max=100"`
RealizationStatus string `query:"realization_status" validate:"omitempty,max=100"`
ProjectFlockID uint64 `query:"project_flock_id" validate:"omitempty,gt=0"`
ProjectFlockKandangID uint64 `query:"project_flock_kandang_id" validate:"omitempty,gt=0"`
} }
type CreateRealization struct { type CreateRealization struct {
@@ -23,6 +23,8 @@ type DeliveryOrdersController struct {
DeliveryOrdersService service.DeliveryOrdersService DeliveryOrdersService service.DeliveryOrdersService
} }
const marketingExcelExportFetchLimit = 100
func NewDeliveryOrdersController(deliveryOrdersService service.DeliveryOrdersService) *DeliveryOrdersController { func NewDeliveryOrdersController(deliveryOrdersService service.DeliveryOrdersService) *DeliveryOrdersController {
return &DeliveryOrdersController{ return &DeliveryOrdersController{
DeliveryOrdersService: deliveryOrdersService, DeliveryOrdersService: deliveryOrdersService,
@@ -49,39 +51,29 @@ func (u *DeliveryOrdersController) GetAll(c *fiber.Ctx) error {
return c.Status(fiber.StatusOK).Send(content) return c.Status(fiber.StatusOK).Send(content)
} }
parseUintListParam := func(param string) ([]uint, error) {
if param == "" {
return nil, nil
}
parts := strings.Split(param, ",")
ids := make([]uint, 0, len(parts))
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed == "" {
return nil, strconv.ErrSyntax
}
parsed, err := strconv.ParseUint(trimmed, 10, 64)
if err != nil {
return nil, err
}
ids = append(ids, uint(parsed))
}
return ids, nil
}
productIDs, err := parseUintListParam(c.Query("product_ids", "")) productIDs, err := parseUintListParam(c.Query("product_ids", ""))
if err != nil { if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid product_ids") return fiber.NewError(fiber.StatusBadRequest, "Invalid product_ids")
} }
query := &validation.DeliveryOrderQuery{ query := &validation.DeliveryOrderQuery{
Page: c.QueryInt("page", 1), Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10), Limit: c.QueryInt("limit", 10),
Search: strings.TrimSpace(c.Query("search", "")), Search: strings.TrimSpace(c.Query("search", "")),
ProductIDs: productIDs, ProductIDs: productIDs,
Status: strings.ReplaceAll(strings.TrimSpace(c.Query("status", "")), "_", " "), Status: strings.ReplaceAll(strings.TrimSpace(c.Query("status", "")), "_", " "),
CustomerId: uint(c.QueryInt("customer_id", 0)), CustomerId: uint(c.QueryInt("customer_id", 0)),
MarketingId: uint(c.QueryInt("marketing_id", 0)), MarketingId: uint(c.QueryInt("marketing_id", 0)),
ProjectFlockID: uint(c.QueryInt("project_flock_id", 0)),
ProjectFlockKandangID: uint(c.QueryInt("project_flock_kandang_id", 0)),
}
if isAllExcelExportRequest(c) {
allResults, err := u.getAllMarketingRowsForExcel(c, query)
if err != nil {
return err
}
return exportMarketingListExcel(c, allResults)
} }
if query.Page < 1 || query.Limit < 1 { if query.Page < 1 || query.Limit < 1 {
@@ -108,6 +100,56 @@ func (u *DeliveryOrdersController) GetAll(c *fiber.Ctx) error {
}) })
} }
func (u *DeliveryOrdersController) getAllMarketingRowsForExcel(c *fiber.Ctx, baseQuery *validation.DeliveryOrderQuery) ([]dto.MarketingListDTO, error) {
query := *baseQuery
query.Page = 1
query.Limit = marketingExcelExportFetchLimit
results := make([]dto.MarketingListDTO, 0)
for {
pageResults, total, err := u.DeliveryOrdersService.GetAll(c, &query)
if err != nil {
return nil, err
}
if len(pageResults) == 0 || total == 0 {
break
}
results = append(results, pageResults...)
if int64(len(results)) >= total {
break
}
query.Page++
}
return results, nil
}
func parseUintListParam(param string) ([]uint, error) {
if param == "" {
return nil, nil
}
parts := strings.Split(param, ",")
ids := make([]uint, 0, len(parts))
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed == "" {
return nil, strconv.ErrSyntax
}
parsed, err := strconv.ParseUint(trimmed, 10, 64)
if err != nil {
return nil, err
}
ids = append(ids, uint(parsed))
}
return ids, nil
}
func (u *DeliveryOrdersController) GetOne(c *fiber.Ctx) error { func (u *DeliveryOrdersController) GetOne(c *fiber.Ctx) error {
param := c.Params("id") param := c.Params("id")
@@ -0,0 +1,181 @@
package controller
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto"
"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"
customerDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/dto"
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
"github.com/gofiber/fiber/v2"
"github.com/xuri/excelize/v2"
)
type deliveryOrdersServiceStub struct {
getAllCalls []validation.DeliveryOrderQuery
}
var _ service.DeliveryOrdersService = (*deliveryOrdersServiceStub)(nil)
func (s *deliveryOrdersServiceStub) GetAll(_ *fiber.Ctx, params *validation.DeliveryOrderQuery) ([]dto.MarketingListDTO, int64, error) {
callCopy := *params
callCopy.ProductIDs = append([]uint(nil), params.ProductIDs...)
s.getAllCalls = append(s.getAllCalls, callCopy)
switch params.Page {
case 1:
return []dto.MarketingListDTO{
buildMarketingListForControllerTest("SO-00001"),
buildMarketingListForControllerTest("SO-00002"),
}, 3, nil
case 2:
return []dto.MarketingListDTO{
buildMarketingListForControllerTest("SO-00003"),
}, 3, nil
default:
return []dto.MarketingListDTO{}, 3, nil
}
}
func (s *deliveryOrdersServiceStub) GetOne(_ *fiber.Ctx, _ uint) (*dto.MarketingDetailDTO, error) {
return nil, nil
}
func (s *deliveryOrdersServiceStub) CreateOne(_ *fiber.Ctx, _ *validation.DeliveryOrderCreate) (*dto.MarketingDetailDTO, error) {
return nil, nil
}
func (s *deliveryOrdersServiceStub) UpdateOne(_ *fiber.Ctx, _ *validation.DeliveryOrderUpdate, _ uint) (*dto.MarketingDetailDTO, error) {
return nil, nil
}
func (s *deliveryOrdersServiceStub) BulkApproveToStatus(_ *fiber.Ctx, _ *validation.BulkApprovalRequest, _ approvalutils.ApprovalStep) ([]dto.MarketingDetailDTO, error) {
return nil, nil
}
func (s *deliveryOrdersServiceStub) GetProgressRows(_ *fiber.Ctx, _ *exportprogress.Query) ([]exportprogress.Row, error) {
return nil, nil
}
func TestDeliveryOrdersControllerGetAllExportAllIgnoresRequestLimit(t *testing.T) {
app := fiber.New()
stub := &deliveryOrdersServiceStub{}
ctrl := NewDeliveryOrdersController(stub)
app.Get("/marketing", ctrl.GetAll)
req := httptest.NewRequest(
http.MethodGet,
"/marketing?export=excel&type=all&page=9&limit=1&search=delivery&status=delivery_order&product_ids=1,2&customer_id=7&marketing_id=99",
nil,
)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("unexpected app.Test error: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != fiber.StatusOK {
t.Fatalf("expected status 200, got %d", resp.StatusCode)
}
contentType := resp.Header.Get("Content-Type")
if !strings.Contains(contentType, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") {
t.Fatalf("unexpected content-type: %s", contentType)
}
disposition := resp.Header.Get("Content-Disposition")
if !strings.Contains(disposition, "marketings_all_") {
t.Fatalf("unexpected content-disposition: %s", disposition)
}
if len(stub.getAllCalls) != 2 {
t.Fatalf("expected 2 GetAll calls, got %d", len(stub.getAllCalls))
}
firstCall := stub.getAllCalls[0]
secondCall := stub.getAllCalls[1]
if firstCall.Page != 1 || secondCall.Page != 2 {
t.Fatalf("expected internal paging to use pages 1 and 2, got %d and %d", firstCall.Page, secondCall.Page)
}
if firstCall.Limit != marketingExcelExportFetchLimit || secondCall.Limit != marketingExcelExportFetchLimit {
t.Fatalf("expected internal limit %d, got %d and %d", marketingExcelExportFetchLimit, firstCall.Limit, secondCall.Limit)
}
if firstCall.Status != "delivery order" {
t.Fatalf("expected status to normalize underscore to space, got %q", firstCall.Status)
}
if firstCall.Search != "delivery" {
t.Fatalf("expected search to be forwarded, got %q", firstCall.Search)
}
if !reflect.DeepEqual(firstCall.ProductIDs, []uint{1, 2}) {
t.Fatalf("unexpected product_ids: %+v", firstCall.ProductIDs)
}
if firstCall.CustomerId != 7 || firstCall.MarketingId != 99 {
t.Fatalf("expected customer_id=7 and marketing_id=99, got customer_id=%d marketing_id=%d", firstCall.CustomerId, firstCall.MarketingId)
}
payload, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("failed to read excel payload: %v", err)
}
file, err := excelize.OpenReader(bytes.NewReader(payload))
if err != nil {
t.Fatalf("failed to parse excel payload: %v", err)
}
defer file.Close()
if got, _ := file.GetCellValue(marketingExportSheetName, "A1"); got != "No. Order" {
t.Fatalf("expected A1 header to be No. Order, got %q", got)
}
if got, _ := file.GetCellValue(marketingExportSheetName, "A2"); got != "SO-00001" {
t.Fatalf("expected first row order number SO-00001, got %q", got)
}
}
func TestDeliveryOrdersControllerGetAllKeepsPaginationValidationForNonExportAll(t *testing.T) {
app := fiber.New()
stub := &deliveryOrdersServiceStub{}
ctrl := NewDeliveryOrdersController(stub)
app.Get("/marketing", ctrl.GetAll)
req := httptest.NewRequest(http.MethodGet, "/marketing?page=1&limit=0", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("unexpected app.Test error: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != fiber.StatusBadRequest {
t.Fatalf("expected status 400, got %d", resp.StatusCode)
}
}
func buildMarketingListForControllerTest(orderNumber string) dto.MarketingListDTO {
return dto.MarketingListDTO{
MarketingRelationDTO: dto.MarketingRelationDTO{
SoNumber: orderNumber,
SoDate: time.Date(2026, time.April, 22, 0, 0, 0, 0, time.UTC),
Notes: "tes",
},
Customer: customerDTO.CustomerRelationDTO{
Name: "AJAT",
},
SalesOrder: []dto.DeliveryMarketingProductDTO{
{TotalPrice: 5206200000},
},
LatestApproval: approvalDTO.ApprovalRelationDTO{
StepName: "Pengajuan",
},
}
}
@@ -0,0 +1,310 @@
package controller
import (
"fmt"
"math"
"strconv"
"strings"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/dto"
"github.com/gofiber/fiber/v2"
"github.com/xuri/excelize/v2"
)
const marketingExportSheetName = "Marketings"
func isAllExcelExportRequest(c *fiber.Ctx) bool {
return strings.EqualFold(strings.TrimSpace(c.Query("export")), "excel") &&
strings.EqualFold(strings.TrimSpace(c.Query("type")), "all")
}
func exportMarketingListExcel(c *fiber.Ctx, items []dto.MarketingListDTO) error {
content, err := buildMarketingExportWorkbook(items)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to generate excel file")
}
filename := fmt.Sprintf("marketings_all_%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)
}
func buildMarketingExportWorkbook(items []dto.MarketingListDTO) ([]byte, error) {
file := excelize.NewFile()
defer file.Close()
defaultSheet := file.GetSheetName(file.GetActiveSheetIndex())
if defaultSheet != marketingExportSheetName {
if err := file.SetSheetName(defaultSheet, marketingExportSheetName); err != nil {
return nil, err
}
}
if err := setMarketingExportColumns(file, marketingExportSheetName); err != nil {
return nil, err
}
if err := setMarketingExportHeaders(file, marketingExportSheetName); err != nil {
return nil, err
}
if err := setMarketingExportRows(file, marketingExportSheetName, items); err != nil {
return nil, err
}
if err := file.SetPanes(marketingExportSheetName, &excelize.Panes{
Freeze: true,
YSplit: 1,
TopLeftCell: "A2",
ActivePane: "bottomLeft",
}); err != nil {
return nil, err
}
buffer, err := file.WriteToBuffer()
if err != nil {
return nil, err
}
return buffer.Bytes(), nil
}
func setMarketingExportColumns(file *excelize.File, sheet string) error {
columnWidths := map[string]float64{
"A": 16,
"B": 14,
"C": 18,
"D": 20,
"E": 18,
"F": 60,
"G": 24,
}
for col, width := range columnWidths {
if err := file.SetColWidth(sheet, col, col, width); err != nil {
return err
}
}
if err := file.SetRowHeight(sheet, 1, 24); err != nil {
return err
}
return nil
}
func setMarketingExportHeaders(file *excelize.File, sheet string) error {
headers := []string{
"No. Order",
"Tanggal",
"Status",
"Customer",
"Grand Total",
"Products",
"Notes",
}
for i, header := range headers {
colName, err := excelize.ColumnNumberToName(i + 1)
if err != nil {
return err
}
cell := colName + "1"
if err := file.SetCellValue(sheet, cell, header); err != nil {
return err
}
}
headerStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Color: "1F2937"},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"DCEBFA"}},
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 err
}
return file.SetCellStyle(sheet, "A1", "G1", headerStyle)
}
func setMarketingExportRows(file *excelize.File, sheet string, items []dto.MarketingListDTO) error {
if len(items) == 0 {
return nil
}
for i, item := range items {
rowNumber := i + 2
if err := file.SetCellValue(sheet, "A"+strconv.Itoa(rowNumber), safeMarketingExportText(item.SoNumber)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "B"+strconv.Itoa(rowNumber), formatMarketingExportDate(item.SoDate)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "C"+strconv.Itoa(rowNumber), formatMarketingExportStatus(item)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "D"+strconv.Itoa(rowNumber), safeMarketingExportText(item.Customer.Name)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "E"+strconv.Itoa(rowNumber), formatMarketingRupiah(sumMarketingGrandTotal(item.SalesOrder))); err != nil {
return err
}
if err := file.SetCellValue(sheet, "F"+strconv.Itoa(rowNumber), formatMarketingProducts(item.SalesOrder)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "G"+strconv.Itoa(rowNumber), safeMarketingExportText(item.Notes)); err != nil {
return err
}
}
lastRow := len(items) + 1
dataStyle, err := file.NewStyle(&excelize.Style{
Alignment: &excelize.Alignment{
Horizontal: "left",
Vertical: "center",
WrapText: true,
},
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 err
}
if err := file.SetCellStyle(sheet, "A2", "G"+strconv.Itoa(lastRow), dataStyle); err != nil {
return err
}
moneyStyle, err := file.NewStyle(&excelize.Style{
Alignment: &excelize.Alignment{
Horizontal: "right",
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 err
}
return file.SetCellStyle(sheet, "E2", "E"+strconv.Itoa(lastRow), moneyStyle)
}
func formatMarketingExportDate(value time.Time) string {
if value.IsZero() {
return "-"
}
location, err := time.LoadLocation("Asia/Jakarta")
if err == nil {
value = value.In(location)
}
return value.Format("02-01-2006")
}
func formatMarketingExportStatus(item dto.MarketingListDTO) string {
if item.LatestApproval.Action != nil && strings.EqualFold(strings.TrimSpace(*item.LatestApproval.Action), string(entity.ApprovalActionRejected)) {
return "Ditolak"
}
return safeMarketingExportText(item.LatestApproval.StepName)
}
func formatMarketingProducts(items []dto.DeliveryMarketingProductDTO) string {
if len(items) == 0 {
return "-"
}
seen := make(map[string]struct{})
names := make([]string, 0, len(items))
for _, item := range items {
if item.ProductWarehouse == nil || item.ProductWarehouse.Product == nil {
continue
}
name := strings.TrimSpace(item.ProductWarehouse.Product.Name)
if name == "" {
continue
}
if _, exists := seen[name]; exists {
continue
}
seen[name] = struct{}{}
names = append(names, name)
}
if len(names) == 0 {
return "-"
}
return strings.Join(names, ", ")
}
func sumMarketingGrandTotal(items []dto.DeliveryMarketingProductDTO) float64 {
total := 0.0
for _, item := range items {
total += item.TotalPrice
}
return total
}
func formatMarketingRupiah(value float64) string {
if math.IsNaN(value) || math.IsInf(value, 0) {
return "Rp 0"
}
rounded := int64(math.Round(value))
sign := ""
if rounded < 0 {
sign = "-"
rounded = -rounded
}
raw := strconv.FormatInt(rounded, 10)
if raw == "" {
raw = "0"
}
var grouped strings.Builder
rem := len(raw) % 3
if rem > 0 {
grouped.WriteString(raw[:rem])
if len(raw) > rem {
grouped.WriteString(".")
}
}
for i := rem; i < len(raw); i += 3 {
grouped.WriteString(raw[i : i+3])
if i+3 < len(raw) {
grouped.WriteString(".")
}
}
return "Rp " + sign + grouped.String()
}
func safeMarketingExportText(value string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return "-"
}
return trimmed
}
@@ -0,0 +1,125 @@
package controller
import (
"bytes"
"testing"
"time"
approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto"
productwarehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/dto"
"gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/dto"
customerDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/dto"
productDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/dto"
"github.com/xuri/excelize/v2"
)
func TestBuildMarketingExportWorkbookHeadersAndRows(t *testing.T) {
items := []dto.MarketingListDTO{
{
MarketingRelationDTO: dto.MarketingRelationDTO{
SoNumber: "SO-00762",
SoDate: time.Date(2026, time.April, 22, 0, 0, 0, 0, time.UTC),
Notes: "tes",
},
Customer: customerDTO.CustomerRelationDTO{
Name: "AJAT",
},
SalesOrder: []dto.DeliveryMarketingProductDTO{
buildMarketingProductForExportTest("PAKAN GROWING CRUMBLE 8603 MALINDO", 5206200000),
buildMarketingProductForExportTest("PAKAN GROWING CRUMBLE 8603 MALINDO", 0),
buildMarketingProductForExportTest("295 GOLD PELLET", 0),
},
LatestApproval: approvalDTO.ApprovalRelationDTO{
StepName: "Pengajuan",
},
},
{
MarketingRelationDTO: dto.MarketingRelationDTO{
SoNumber: "SO-00761",
SoDate: time.Date(2026, time.April, 22, 0, 0, 0, 0, time.UTC),
Notes: "",
},
Customer: customerDTO.CustomerRelationDTO{
Name: "DHENIS",
},
SalesOrder: []dto.DeliveryMarketingProductDTO{
buildMarketingProductForExportTest("HS30 FOAM @20 LITER", 75000),
},
LatestApproval: approvalDTO.ApprovalRelationDTO{
StepName: "Delivery Order",
Action: strPtr("REJECTED"),
},
},
}
content, err := buildMarketingExportWorkbook(items)
if err != nil {
t.Fatalf("buildMarketingExportWorkbook returned error: %v", err)
}
file, err := excelize.OpenReader(bytes.NewReader(content))
if err != nil {
t.Fatalf("failed to open workbook bytes: %v", err)
}
defer file.Close()
expectedHeaders := map[string]string{
"A1": "No. Order",
"B1": "Tanggal",
"C1": "Status",
"D1": "Customer",
"E1": "Grand Total",
"F1": "Products",
"G1": "Notes",
}
for cell, expected := range expectedHeaders {
got, err := file.GetCellValue(marketingExportSheetName, cell)
if err != nil {
t.Fatalf("GetCellValue(%s) failed: %v", cell, err)
}
if got != expected {
t.Fatalf("expected %s=%q, got %q", cell, expected, got)
}
}
assertCellEquals(t, file, "A2", "SO-00762")
assertCellEquals(t, file, "B2", "22-04-2026")
assertCellEquals(t, file, "C2", "Pengajuan")
assertCellEquals(t, file, "D2", "AJAT")
assertCellEquals(t, file, "E2", "Rp 5.206.200.000")
assertCellEquals(t, file, "F2", "PAKAN GROWING CRUMBLE 8603 MALINDO, 295 GOLD PELLET")
assertCellEquals(t, file, "G2", "tes")
assertCellEquals(t, file, "A3", "SO-00761")
assertCellEquals(t, file, "C3", "Ditolak")
assertCellEquals(t, file, "E3", "Rp 75.000")
assertCellEquals(t, file, "F3", "HS30 FOAM @20 LITER")
assertCellEquals(t, file, "G3", "-")
}
func assertCellEquals(t *testing.T, file *excelize.File, cell, expected string) {
t.Helper()
got, err := file.GetCellValue(marketingExportSheetName, cell)
if err != nil {
t.Fatalf("GetCellValue(%s) failed: %v", cell, err)
}
if got != expected {
t.Fatalf("expected %s=%q, got %q", cell, expected, got)
}
}
func buildMarketingProductForExportTest(name string, totalPrice float64) dto.DeliveryMarketingProductDTO {
return dto.DeliveryMarketingProductDTO{
TotalPrice: totalPrice,
ProductWarehouse: &productwarehouseDTO.ProductWarehousNestedDTO{
Product: &productDTO.ProductRelationDTO{
Name: name,
},
},
}
}
func strPtr(value string) *string {
return &value
}
@@ -85,6 +85,102 @@ func (s deliveryOrdersService) withRelations(db *gorm.DB) *gorm.DB {
Preload("Products.DeliveryProduct") Preload("Products.DeliveryProduct")
} }
func (s deliveryOrdersService) marketingOwnerRelationQuery(ctx context.Context) *gorm.DB {
return s.MarketingRepo.DB().
WithContext(ctx).
Table("marketing_products mp").
Select("1").
Joins("JOIN product_warehouses pw ON pw.id = mp.product_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 warehouses w ON w.id = pw.warehouse_id").
Joins("LEFT JOIN kandangs k ON k.id = COALESCE(pfk.kandang_id, w.kandang_id)").
Where("mp.marketing_id = marketings.id")
}
func (s deliveryOrdersService) marketingAttributionRelationQuery(ctx context.Context) *gorm.DB {
baseDB := s.MarketingRepo.DB().WithContext(ctx)
return baseDB.
Table("marketing_delivery_products mdp").
Select("1").
Joins("JOIN marketing_products mp ON mp.id = mdp.marketing_product_id").
Joins("JOIN (?) AS mda ON mda.marketing_delivery_product_id = mdp.id", commonRepo.MarketingDeliveryAttributionRowsQuery(baseDB)).
Joins("JOIN project_flock_kandangs pfk_attr ON pfk_attr.id = mda.project_flock_kandang_id").
Joins("JOIN project_flocks pf_attr ON pf_attr.id = pfk_attr.project_flock_id").
Joins("JOIN kandangs k_attr ON k_attr.id = pfk_attr.kandang_id").
Where("mp.marketing_id = marketings.id")
}
func (s deliveryOrdersService) applyMarketingProjectFlockFilter(ctx context.Context, db *gorm.DB, projectFlockID, projectFlockKandangID uint) *gorm.DB {
if projectFlockID > 0 {
db = db.Where(
"(EXISTS (?) OR EXISTS (?))",
s.marketingOwnerRelationQuery(ctx).Where("pfk.project_flock_id = ?", projectFlockID),
s.marketingAttributionRelationQuery(ctx).Where("mda.project_flock_id = ?", projectFlockID),
)
}
if projectFlockKandangID > 0 {
db = db.Where(
"(EXISTS (?) OR EXISTS (?))",
s.marketingOwnerRelationQuery(ctx).Where("pw.project_flock_kandang_id = ?", projectFlockKandangID),
s.marketingAttributionRelationQuery(ctx).Where("mda.project_flock_kandang_id = ?", projectFlockKandangID),
)
}
return db
}
func (s deliveryOrdersService) applyMarketingSearchFilter(ctx context.Context, db *gorm.DB, rawSearch string) *gorm.DB {
searchPattern := "%" + strings.TrimSpace(rawSearch) + "%"
if searchPattern == "%%" {
return db
}
return db.Where(
`(
marketings.so_number ILIKE ? OR
EXISTS (
SELECT 1
FROM customers c
WHERE c.id = marketings.customer_id
AND c.name ILIKE ?
) OR
EXISTS (
SELECT 1
FROM users su
WHERE su.id = marketings.sales_person_id
AND su.name ILIKE ?
) OR
EXISTS (
SELECT 1
FROM marketing_products mp
JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id
JOIN products p ON p.id = pw.product_id
WHERE mp.marketing_id = marketings.id
AND p.name ILIKE ?
) OR
EXISTS (
SELECT 1
FROM marketing_products mp
JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id
JOIN warehouses w ON w.id = pw.warehouse_id
WHERE mp.marketing_id = marketings.id
AND w.name ILIKE ?
) OR
EXISTS (?) OR
EXISTS (?)
)`,
searchPattern,
searchPattern,
searchPattern,
searchPattern,
searchPattern,
s.marketingOwnerRelationQuery(ctx).Where("pf.flock_name ILIKE ? OR k.name ILIKE ?", searchPattern, searchPattern),
s.marketingAttributionRelationQuery(ctx).Where("pf_attr.flock_name ILIKE ? OR k_attr.name ILIKE ?", searchPattern, searchPattern),
)
}
func (s deliveryOrdersService) getMarketingWithDeliveries(c *fiber.Ctx, marketingId uint) (*dto.MarketingDetailDTO, error) { func (s deliveryOrdersService) getMarketingWithDeliveries(c *fiber.Ctx, marketingId uint) (*dto.MarketingDetailDTO, error) {
if err := m.EnsureMarketingAccess(c, s.MarketingRepo.DB(), marketingId); err != nil { if err := m.EnsureMarketingAccess(c, s.MarketingRepo.DB(), marketingId); err != nil {
return nil, err return nil, err
@@ -158,41 +254,6 @@ func (s deliveryOrdersService) GetAll(c *fiber.Ctx, params *validation.DeliveryO
} }
} }
if params.Search != "" {
searchPattern := "%" + params.Search + "%"
db = db.Where(`(
marketings.so_number ILIKE ? OR
EXISTS (
SELECT 1
FROM customers c
WHERE c.id = marketings.customer_id
AND c.name ILIKE ?
) OR
EXISTS (
SELECT 1
FROM users su
WHERE su.id = marketings.sales_person_id
AND su.name ILIKE ?
) OR
EXISTS (
SELECT 1
FROM marketing_products mp
JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id
JOIN products p ON p.id = pw.product_id
WHERE mp.marketing_id = marketings.id
AND p.name ILIKE ?
) OR
EXISTS (
SELECT 1
FROM marketing_products mp
JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id
JOIN warehouses w ON w.id = pw.warehouse_id
WHERE mp.marketing_id = marketings.id
AND w.name ILIKE ?
)
)`, searchPattern, searchPattern, searchPattern, searchPattern, searchPattern)
}
if len(params.ProductIDs) > 0 { if len(params.ProductIDs) > 0 {
db = db.Where(`EXISTS ( db = db.Where(`EXISTS (
SELECT 1 SELECT 1
@@ -208,6 +269,9 @@ func (s deliveryOrdersService) GetAll(c *fiber.Ctx, params *validation.DeliveryO
db = db.Where("marketings.customer_id = ?", params.CustomerId) db = db.Where("marketings.customer_id = ?", params.CustomerId)
} }
db = s.applyMarketingProjectFlockFilter(c.Context(), db, params.ProjectFlockID, params.ProjectFlockKandangID)
db = s.applyMarketingSearchFilter(c.Context(), db, params.Search)
if scope.Restrict { if scope.Restrict {
if len(scope.IDs) == 0 { if len(scope.IDs) == 0 {
return db.Where("1 = 0") return db.Where("1 = 0")
@@ -22,13 +22,15 @@ type DeliveryOrderUpdate struct {
} }
type DeliveryOrderQuery struct { type DeliveryOrderQuery struct {
Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` Page int `query:"page" validate:"omitempty,number,min=1,gt=0"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"`
Search string `query:"search" validate:"omitempty,max=100"` Search string `query:"search" validate:"omitempty,max=100"`
ProductIDs []uint `query:"product_ids" validate:"omitempty,dive,gt=0"` ProductIDs []uint `query:"product_ids" validate:"omitempty,dive,gt=0"`
Status string `query:"status" validate:"omitempty,max=50"` Status string `query:"status" validate:"omitempty,max=50"`
CustomerId uint `query:"customer_id" validate:"omitempty,gt=0"` CustomerId uint `query:"customer_id" validate:"omitempty,gt=0"`
MarketingId uint `query:"marketing_id" validate:"omitempty,gt=0"` MarketingId uint `query:"marketing_id" validate:"omitempty,gt=0"`
ProjectFlockID uint `query:"project_flock_id" validate:"omitempty,gt=0"`
ProjectFlockKandangID uint `query:"project_flock_kandang_id" validate:"omitempty,gt=0"`
} }
type DeliveryOrderApprove struct { type DeliveryOrderApprove struct {
@@ -27,7 +27,7 @@ func NewRecordingController(recordingService service.RecordingService) *Recordin
} }
func (u *RecordingController) GetAll(c *fiber.Ctx) error { func (u *RecordingController) GetAll(c *fiber.Ctx) error {
projectFlockID := c.QueryInt("project_flock_kandang_id", 0) projectFlockKandangID := c.QueryInt("project_flock_kandang_id", 0)
exportType := strings.TrimSpace(c.Query("export")) exportType := strings.TrimSpace(c.Query("export"))
if exportprogress.IsProgressExportRequest(c) { if exportprogress.IsProgressExportRequest(c) {
@@ -54,13 +54,19 @@ func (u *RecordingController) GetAll(c *fiber.Ctx) error {
offset := (page - 1) * limit offset := (page - 1) * limit
query := &validation.Query{ query := &validation.Query{
Page: page, Page: page,
Limit: limit, Limit: limit,
Offset: offset, Offset: offset,
Search: c.Query("search"), Search: strings.TrimSpace(c.Query("search")),
ProjectFlockId: uint(c.QueryInt("project_flock_id", 0)),
AreaId: uint(c.QueryInt("area_id", 0)),
LocationId: uint(c.QueryInt("location_id", 0)),
KandangId: uint(c.QueryInt("kandang_id", 0)),
ProjectFlockCategory: strings.TrimSpace(c.Query("project_flock_category")),
ApprovalStatus: strings.TrimSpace(c.Query("approval_status")),
} }
if projectFlockID > 0 { if projectFlockKandangID > 0 {
query.ProjectFlockKandangId = uint(projectFlockID) query.ProjectFlockKandangId = uint(projectFlockKandangID)
} }
result, totalResults, err := u.RecordingService.GetAll(c, query) result, totalResults, err := u.RecordingService.GetAll(c, query)
@@ -11,6 +11,8 @@ import (
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress" "gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
"gitlab.com/mbugroup/lti-api.git/internal/common/repository" "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -19,10 +21,10 @@ type RecordingRepository interface {
WithRelations(db *gorm.DB) *gorm.DB WithRelations(db *gorm.DB) *gorm.DB
WithRelationsList(db *gorm.DB) *gorm.DB WithRelationsList(db *gorm.DB) *gorm.DB
ApplyListFilters(db *gorm.DB, search string, projectFlockKandangId uint) *gorm.DB ApplyListFilters(db *gorm.DB, params *validation.Query) *gorm.DB
ApplyListCountFilters(db *gorm.DB, search string, projectFlockKandangId uint) *gorm.DB ApplyListCountFilters(db *gorm.DB, params *validation.Query) *gorm.DB
ApplySearchFilters(db *gorm.DB, rawSearch string) *gorm.DB ApplySearchFilters(db *gorm.DB, rawSearch string) *gorm.DB
GetAllWithFilters(ctx context.Context, offset, limit int, search string, projectFlockKandangId uint, modifier func(*gorm.DB) *gorm.DB) ([]entity.Recording, int64, error) GetAllWithFilters(ctx context.Context, offset, limit int, params *validation.Query, modifier func(*gorm.DB) *gorm.DB) ([]entity.Recording, int64, error)
GetLatestByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (*entity.Recording, error) GetLatestByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (*entity.Recording, error)
ListByProjectFlockKandangID(ctx context.Context, tx *gorm.DB, projectFlockKandangId uint, from *time.Time) ([]entity.Recording, error) ListByProjectFlockKandangID(ctx context.Context, tx *gorm.DB, projectFlockKandangId uint, from *time.Time) ([]entity.Recording, error)
GenerateNextDay(tx *gorm.DB, projectFlockKandangId uint) (int, error) GenerateNextDay(tx *gorm.DB, projectFlockKandangId uint) (int, error)
@@ -147,36 +149,89 @@ func (r *RecordingRepositoryImpl) WithRelationsList(db *gorm.DB) *gorm.DB {
Preload("ProjectFlockKandang.ProjectFlock.ProductionStandard") Preload("ProjectFlockKandang.ProjectFlock.ProductionStandard")
} }
func (r *RecordingRepositoryImpl) ApplyListFilters(db *gorm.DB, search string, projectFlockKandangId uint) *gorm.DB { func (r *RecordingRepositoryImpl) latestApprovalSubQuery(db *gorm.DB) *gorm.DB {
return db.Session(&gorm.Session{NewDB: true}).
Table("approvals").
Select("DISTINCT ON (approvable_id) approvable_id, action, step_name, notes").
Where("approvable_type = ?", utils.ApprovalWorkflowRecording.String()).
Order("approvable_id, action_at DESC, id DESC")
}
func (r *RecordingRepositoryImpl) applyStructuredListFilters(db *gorm.DB, params *validation.Query) *gorm.DB {
if params == nil {
return db
}
if params.ProjectFlockKandangId != 0 {
db = db.Where("recordings.project_flock_kandangs_id = ?", params.ProjectFlockKandangId)
}
if params.ProjectFlockId != 0 {
db = db.Where("pfk.project_flock_id = ?", params.ProjectFlockId)
}
if params.KandangId != 0 {
db = db.Where("pfk.kandang_id = ?", params.KandangId)
}
if params.LocationId != 0 {
db = db.Where("pf.location_id = ?", params.LocationId)
}
if params.AreaId != 0 {
db = db.Where("pf.area_id = ?", params.AreaId)
}
if params.ProjectFlockCategory != "" {
db = db.Where("UPPER(COALESCE(pf.category, '')) = ?", strings.ToUpper(strings.TrimSpace(params.ProjectFlockCategory)))
}
if params.ApprovalStatus != "" {
db = db.Where(
`EXISTS (
SELECT 1
FROM (?) AS latest_approval
WHERE latest_approval.approvable_id = recordings.id
AND UPPER(COALESCE(CAST(latest_approval.action AS TEXT), '')) = ?
)`,
r.latestApprovalSubQuery(db),
strings.ToUpper(strings.TrimSpace(params.ApprovalStatus)),
)
}
return db
}
func (r *RecordingRepositoryImpl) ApplyListFilters(db *gorm.DB, params *validation.Query) *gorm.DB {
search := ""
if params != nil {
search = params.Search
}
db = r.WithRelationsList(db) db = r.WithRelationsList(db)
db = db. db = db.
Joins("JOIN project_flock_kandangs pfk ON pfk.id = recordings.project_flock_kandangs_id"). Joins("JOIN project_flock_kandangs pfk ON pfk.id = recordings.project_flock_kandangs_id").
Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id") Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id").
if projectFlockKandangId != 0 { Joins("LEFT JOIN kandangs k ON k.id = pfk.kandang_id")
db = db.Where("recordings.project_flock_kandangs_id = ?", projectFlockKandangId) db = r.applyStructuredListFilters(db, params)
}
db = r.ApplySearchFilters(db, search) db = r.ApplySearchFilters(db, search)
return db.Order("recordings.record_datetime DESC").Order("recordings.created_at DESC") return db.Order("recordings.record_datetime DESC").Order("recordings.created_at DESC")
} }
func (r *RecordingRepositoryImpl) ApplyListCountFilters(db *gorm.DB, search string, projectFlockKandangId uint) *gorm.DB { func (r *RecordingRepositoryImpl) ApplyListCountFilters(db *gorm.DB, params *validation.Query) *gorm.DB {
search := ""
if params != nil {
search = params.Search
}
db = db. db = db.
Joins("JOIN project_flock_kandangs pfk ON pfk.id = recordings.project_flock_kandangs_id"). Joins("JOIN project_flock_kandangs pfk ON pfk.id = recordings.project_flock_kandangs_id").
Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id") Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id").
if projectFlockKandangId != 0 { Joins("LEFT JOIN kandangs k ON k.id = pfk.kandang_id")
db = db.Where("recordings.project_flock_kandangs_id = ?", projectFlockKandangId) db = r.applyStructuredListFilters(db, params)
}
db = r.ApplySearchFilters(db, search) db = r.ApplySearchFilters(db, search)
return db return db
} }
func (r *RecordingRepositoryImpl) GetAllWithFilters(ctx context.Context, offset, limit int, search string, projectFlockKandangId uint, modifier func(*gorm.DB) *gorm.DB) ([]entity.Recording, int64, error) { func (r *RecordingRepositoryImpl) GetAllWithFilters(ctx context.Context, offset, limit int, params *validation.Query, modifier func(*gorm.DB) *gorm.DB) ([]entity.Recording, int64, error) {
var ( var (
records []entity.Recording records []entity.Recording
total int64 total int64
) )
countQ := r.ApplyListCountFilters(r.DB().WithContext(ctx).Model(&entity.Recording{}), search, projectFlockKandangId) countQ := r.ApplyListCountFilters(r.DB().WithContext(ctx).Model(&entity.Recording{}), params)
if modifier != nil { if modifier != nil {
countQ = modifier(countQ) countQ = modifier(countQ)
} }
@@ -184,7 +239,7 @@ func (r *RecordingRepositoryImpl) GetAllWithFilters(ctx context.Context, offset,
return nil, 0, err return nil, 0, err
} }
listQ := r.ApplyListFilters(r.DB().WithContext(ctx).Model(&entity.Recording{}), search, projectFlockKandangId) listQ := r.ApplyListFilters(r.DB().WithContext(ctx).Model(&entity.Recording{}), params)
if modifier != nil { if modifier != nil {
listQ = modifier(listQ) listQ = modifier(listQ)
} }
@@ -218,6 +273,8 @@ func (r *RecordingRepositoryImpl) ApplySearchFilters(db *gorm.DB, rawSearch stri
Joins("LEFT JOIN warehouses ws ON ws.id = pws.warehouse_id"). Joins("LEFT JOIN warehouses ws ON ws.id = pws.warehouse_id").
Joins("LEFT JOIN warehouses wd ON wd.id = pwd.warehouse_id"). Joins("LEFT JOIN warehouses wd ON wd.id = pwd.warehouse_id").
Joins("LEFT JOIN warehouses we ON we.id = pwe.warehouse_id"). Joins("LEFT JOIN warehouses we ON we.id = pwe.warehouse_id").
Joins("LEFT JOIN users cu ON cu.id = recordings.created_by").
Joins("LEFT JOIN (?) AS latest_approval ON latest_approval.approvable_id = recordings.id", r.latestApprovalSubQuery(db)).
Where(` Where(`
LOWER(pf.flock_name) LIKE ? LOWER(pf.flock_name) LIKE ?
OR LOWER(k.name) LIKE ? OR LOWER(k.name) LIKE ?
@@ -225,8 +282,12 @@ func (r *RecordingRepositoryImpl) ApplySearchFilters(db *gorm.DB, rawSearch stri
OR LOWER(l.address) LIKE ? OR LOWER(l.address) LIKE ?
OR LOWER(ws.name) LIKE ? OR LOWER(ws.name) LIKE ?
OR LOWER(wd.name) LIKE ? OR LOWER(wd.name) LIKE ?
OR LOWER(we.name) LIKE ?`, OR LOWER(we.name) LIKE ?
likeQuery, likeQuery, likeQuery, likeQuery, likeQuery, likeQuery, likeQuery, OR LOWER(COALESCE(cu.name, '')) LIKE ?
OR LOWER(COALESCE(latest_approval.step_name, '')) LIKE ?
OR LOWER(COALESCE(CAST(latest_approval.action AS TEXT), '')) LIKE ?
OR LOWER(COALESCE(latest_approval.notes, '')) LIKE ?`,
likeQuery, likeQuery, likeQuery, likeQuery, likeQuery, likeQuery, likeQuery, likeQuery, likeQuery, likeQuery, likeQuery,
) )
return db.Where("recordings.id IN (?)", subQuery) return db.Where("recordings.id IN (?)", subQuery)
} }
@@ -116,8 +116,7 @@ func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
c.Context(), c.Context(),
params.Offset, params.Offset,
params.Limit, params.Limit,
params.Search, params,
params.ProjectFlockKandangId,
func(db *gorm.DB) *gorm.DB { func(db *gorm.DB) *gorm.DB {
db, scopeErr = m.ApplyLocationScope(c, db, "pf.location_id") db, scopeErr = m.ApplyLocationScope(c, db, "pf.location_id")
return db return db
@@ -450,12 +449,12 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
return err return err
} }
mappedStocks := recordingutil.MapStocks(createdRecording.Id, stockOwnerProjectFlockKandangID, req.Stocks) mappedStocks := recordingutil.MapStocks(createdRecording.Id, stockOwnerProjectFlockKandangID, req.Stocks)
stockDesired := resetStockQuantitiesForFIFO(mappedStocks) stockDesired := resetStockQuantitiesForFIFO(mappedStocks)
if err := s.Repository.CreateStocks(tx, mappedStocks); err != nil { if err := s.Repository.CreateStocks(tx, mappedStocks); err != nil {
s.Log.Errorf("Failed to persist stocks: %+v", err) s.Log.Errorf("Failed to persist stocks: %+v", err)
return err return err
} }
for i := range mappedStocks { for i := range mappedStocks {
if i >= len(stockDesired) { if i >= len(stockDesired) {
@@ -519,14 +518,14 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
s.Log.Errorf("Failed to compute recording metrics: %+v", err) s.Log.Errorf("Failed to compute recording metrics: %+v", err)
return err return err
} }
if err := s.recalculateFrom(ctx, tx, createdRecording.ProjectFlockKandangId, createdRecording.RecordDatetime); err != nil { if err := s.recalculateFrom(ctx, tx, createdRecording.ProjectFlockKandangId, createdRecording.RecordDatetime); err != nil {
s.Log.Errorf("Failed to recalculate recordings after create: %+v", err) s.Log.Errorf("Failed to recalculate recordings after create: %+v", err)
return err return err
} }
if err := s.syncFarmDepreciationManualInputFromRecordingStocks(ctx, tx, createdRecording.ProjectFlockKandangId, createdRecording.RecordDatetime); err != nil { 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) s.Log.Errorf("Failed to sync farm depreciation manual input after create: %+v", err)
return err return err
} }
action := entity.ApprovalActionCreated action := entity.ApprovalActionCreated
if err := s.createRecordingApproval(ctx, tx, createdRecording.Id, utils.RecordingStepPengajuan, action, createdRecording.CreatedBy, nil); err != nil { if err := s.createRecordingApproval(ctx, tx, createdRecording.Id, utils.RecordingStepPengajuan, action, createdRecording.CreatedBy, nil); err != nil {
@@ -587,8 +586,8 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
return err return err
} }
recordingEntity = recording recordingEntity = recording
pfkForRoute := recordingEntity.ProjectFlockKandang pfkForRoute := recordingEntity.ProjectFlockKandang
if pfkForRoute == nil || pfkForRoute.Id == 0 { if pfkForRoute == nil || pfkForRoute.Id == 0 {
fetchedPfk, fetchErr := s.ProjectFlockKandangRepo.GetByIDLight(ctx, recordingEntity.ProjectFlockKandangId) fetchedPfk, fetchErr := s.ProjectFlockKandangRepo.GetByIDLight(ctx, recordingEntity.ProjectFlockKandangId)
if fetchErr != nil { if fetchErr != nil {
@@ -599,43 +598,43 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
return fetchErr return fetchErr
} }
pfkForRoute = fetchedPfk pfkForRoute = fetchedPfk
} }
if err := s.tryAutoExecuteTransferForRecordingCreate(c, pfkForRoute, recordingEntity.RecordDatetime); err != nil { if err := s.tryAutoExecuteTransferForRecordingCreate(c, pfkForRoute, recordingEntity.RecordDatetime); err != nil {
return err return err
} }
routePayload := buildRecordingRoutePayloadFromUpdate(req) routePayload := buildRecordingRoutePayloadFromUpdate(req)
if err := s.enforceTransferRecordingRoute(ctx, pfkForRoute, recordingEntity.RecordDatetime, routePayload); err != nil { if err := s.enforceTransferRecordingRoute(ctx, pfkForRoute, recordingEntity.RecordDatetime, routePayload); err != nil {
return err return err
} }
hasStockChanges := req.Stocks != nil hasStockChanges := req.Stocks != nil
hasDepletionChanges := req.Depletions != nil hasDepletionChanges := req.Depletions != nil
hasEggChanges := req.Eggs != nil hasEggChanges := req.Eggs != nil
var existingStocks []entity.RecordingStock var existingStocks []entity.RecordingStock
var existingDepletions []entity.RecordingDepletion var existingDepletions []entity.RecordingDepletion
var existingEggs []entity.RecordingEgg var existingEggs []entity.RecordingEgg
var mappedDepletions []entity.RecordingDepletion var mappedDepletions []entity.RecordingDepletion
var stockOwnerProjectFlockKandangID *uint var stockOwnerProjectFlockKandangID *uint
note := recordingutil.RecordingNote("Edit", recordingEntity.Id) note := recordingutil.RecordingNote("Edit", recordingEntity.Id)
if hasStockChanges { if hasStockChanges {
stockOwnerProjectFlockKandangID, err = s.resolveRecordingStockOwnerProjectFlockKandangID(ctx, pfkForRoute, recordingEntity.RecordDatetime) stockOwnerProjectFlockKandangID, err = s.resolveRecordingStockOwnerProjectFlockKandangID(ctx, pfkForRoute, recordingEntity.RecordDatetime)
if err != nil { if err != nil {
return err return err
} }
existingStocks, err = s.Repository.ListStocks(tx, recordingEntity.Id) existingStocks, err = s.Repository.ListStocks(tx, recordingEntity.Id)
if err != nil { if err != nil {
s.Log.Errorf("Failed to list existing stocks: %+v", err) s.Log.Errorf("Failed to list existing stocks: %+v", err)
return err return err
} }
existingUsage := recordingutil.StockUsageByWarehouse(existingStocks) existingUsage := recordingutil.StockUsageByWarehouse(existingStocks)
incomingUsage := recordingutil.StockUsageByWarehouseReq(req.Stocks) incomingUsage := recordingutil.StockUsageByWarehouseReq(req.Stocks)
match := recordingutil.FloatMapsEqual(existingUsage, incomingUsage) && recordingStocksAllOwnedBy(existingStocks, stockOwnerProjectFlockKandangID) match := recordingutil.FloatMapsEqual(existingUsage, incomingUsage) && recordingStocksAllOwnedBy(existingStocks, stockOwnerProjectFlockKandangID)
if match { if match {
hasStockChanges = false hasStockChanges = false
} else { } else {
if err := s.ensureProductWarehousesExist(c, req.Stocks, nil, nil); err != nil { if err := s.ensureProductWarehousesExist(c, req.Stocks, nil, nil); err != nil {
return err return err
} }
@@ -643,11 +642,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 { if err := s.ensureProductWarehousesByFlags(ctx, feedIDs, []string{"PAKAN", "OVK"}, "feed"); err != nil {
return err return err
} }
if err := s.reflowSyncRecordingStocks(ctx, tx, recordingEntity.Id, existingStocks, req.Stocks, stockOwnerProjectFlockKandangID, note, actorID); err != nil { if err := s.reflowSyncRecordingStocks(ctx, tx, recordingEntity.Id, existingStocks, req.Stocks, stockOwnerProjectFlockKandangID, note, actorID); err != nil {
return err return err
}
} }
} }
}
if hasDepletionChanges { if hasDepletionChanges {
existingDepletions, err = s.Repository.ListDepletions(tx, recordingEntity.Id) existingDepletions, err = s.Repository.ListDepletions(tx, recordingEntity.Id)
@@ -809,22 +808,22 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
return err return err
} }
if hasStockChanges || hasDepletionChanges || hasEggChanges { if hasStockChanges || hasDepletionChanges || hasEggChanges {
if err := s.computeAndUpdateMetrics(ctx, tx, recordingEntity); err != nil { if err := s.computeAndUpdateMetrics(ctx, tx, recordingEntity); err != nil {
s.Log.Errorf("Failed to recompute recording metrics: %+v", err) s.Log.Errorf("Failed to recompute recording metrics: %+v", err)
return err return err
} }
if err := s.recalculateFrom(ctx, tx, recordingEntity.ProjectFlockKandangId, recordingEntity.RecordDatetime); err != nil { if err := s.recalculateFrom(ctx, tx, recordingEntity.ProjectFlockKandangId, recordingEntity.RecordDatetime); err != nil {
s.Log.Errorf("Failed to recalculate recordings after update: %+v", err) s.Log.Errorf("Failed to recalculate recordings after update: %+v", err)
return err return err
}
} }
if hasStockChanges { }
if err := s.syncFarmDepreciationManualInputFromRecordingStocks(ctx, tx, recordingEntity.ProjectFlockKandangId, recordingEntity.RecordDatetime); err != nil { if hasStockChanges {
s.Log.Errorf("Failed to sync farm depreciation manual input after update: %+v", err) if err := s.syncFarmDepreciationManualInputFromRecordingStocks(ctx, tx, recordingEntity.ProjectFlockKandangId, recordingEntity.RecordDatetime); err != nil {
return err s.Log.Errorf("Failed to sync farm depreciation manual input after update: %+v", err)
} return err
} }
}
action := entity.ApprovalActionUpdated action := entity.ApprovalActionUpdated
actorID := recordingEntity.CreatedBy actorID := recordingEntity.CreatedBy
@@ -1082,15 +1081,15 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error {
return err return err
} }
if err := s.recalculateFrom(ctx, tx, recording.ProjectFlockKandangId, recording.RecordDatetime); err != nil { if err := s.recalculateFrom(ctx, tx, recording.ProjectFlockKandangId, recording.RecordDatetime); err != nil {
s.Log.Errorf("Failed to recalculate recordings after delete: %+v", err) s.Log.Errorf("Failed to recalculate recordings after delete: %+v", err)
return err return err
} }
if err := s.syncFarmDepreciationManualInputFromRecordingStocks(ctx, tx, recording.ProjectFlockKandangId, recording.RecordDatetime); err != nil { 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) s.Log.Errorf("Failed to sync farm depreciation manual input after delete: %+v", err)
return err return err
} }
s.invalidateDepreciationSnapshots(ctx, tx, recording.ProjectFlockKandangId, recording.RecordDatetime) s.invalidateDepreciationSnapshots(ctx, tx, recording.ProjectFlockKandangId, recording.RecordDatetime)
return nil return nil
}) })
@@ -2905,32 +2904,32 @@ func (s *recordingService) reflowSyncRecordingStocks(
if len(list) > 0 { if len(list) > 0 {
stock = list[0] stock = list[0]
existingByWarehouse[item.ProductWarehouseId] = list[1:] existingByWarehouse[item.ProductWarehouseId] = list[1:]
} else { } else {
zero := 0.0 zero := 0.0
stock = entity.RecordingStock{ stock = entity.RecordingStock{
RecordingId: recordingID, RecordingId: recordingID,
ProductWarehouseId: item.ProductWarehouseId, ProductWarehouseId: item.ProductWarehouseId,
ProjectFlockKandangId: ownerProjectFlockKandangID, ProjectFlockKandangId: ownerProjectFlockKandangID,
UsageQty: &zero, UsageQty: &zero,
PendingQty: &zero, PendingQty: &zero,
}
if err := s.Repository.CreateStock(tx, &stock); err != nil {
return err
}
} }
stock.ProjectFlockKandangId = ownerProjectFlockKandangID if err := s.Repository.CreateStock(tx, &stock); err != nil {
if stock.Id != 0 { return err
if err := tx.Model(&entity.RecordingStock{}).
Where("id = ?", stock.Id).
Updates(map[string]any{
"project_flock_kandang_id": ownerProjectFlockKandangID,
}).Error; 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 desired := item.Qty
stock.UsageQty = &desired stock.UsageQty = &desired
zero := 0.0 zero := 0.0
stock.PendingQty = &zero stock.PendingQty = &zero
stocksToApply = append(stocksToApply, stock) stocksToApply = append(stocksToApply, stock)
@@ -39,8 +39,14 @@ type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1"` Page int `query:"page" validate:"omitempty,number,min=1"`
Limit int `query:"limit" validate:"omitempty,number,min=1"` Limit int `query:"limit" validate:"omitempty,number,min=1"`
Offset int `query:"-" validate:"omitempty,number,min=0"` Offset int `query:"-" validate:"omitempty,number,min=0"`
ProjectFlockId uint `query:"project_flock_id" validate:"omitempty,number,min=1"`
ProjectFlockKandangId uint `query:"project_flock_kandang_id" validate:"omitempty,number,min=1"` ProjectFlockKandangId uint `query:"project_flock_kandang_id" validate:"omitempty,number,min=1"`
Search string `query:"search" validate:"omitempty,max=50"` AreaId uint `query:"area_id" validate:"omitempty,number,min=1"`
LocationId uint `query:"location_id" validate:"omitempty,number,min=1"`
KandangId uint `query:"kandang_id" validate:"omitempty,number,min=1"`
ProjectFlockCategory string `query:"project_flock_category" validate:"omitempty,oneof=GROWING LAYING"`
ApprovalStatus string `query:"approval_status" validate:"omitempty,max=50"`
Search string `query:"search" validate:"omitempty,max=100"`
} }
type Approve struct { type Approve struct {
@@ -10,6 +10,7 @@ import (
"time" "time"
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress" "gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/dto" "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/dto"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/services" service "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/services"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/validations" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/validations"
@@ -24,6 +25,8 @@ type PurchaseController struct {
service service.PurchaseService service service.PurchaseService
} }
const purchaseExcelExportFetchLimit = 100
func NewPurchaseController(s service.PurchaseService) *PurchaseController { func NewPurchaseController(s service.PurchaseService) *PurchaseController {
return &PurchaseController{service: s} return &PurchaseController{service: s}
} }
@@ -48,20 +51,14 @@ func (ctrl *PurchaseController) GetAll(c *fiber.Ctx) error {
return c.Status(fiber.StatusOK).Send(content) return c.Status(fiber.StatusOK).Send(content)
} }
query := &validation.Query{ query := buildPurchaseQuery(c)
Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10), if isAllPurchaseExcelExportRequest(c) {
Search: strings.TrimSpace(c.Query("search")), results, err := ctrl.getAllPurchasesForExcel(c, query)
ApprovalStatus: strings.TrimSpace(c.Query("approval_status")), if err != nil {
PoDate: strings.TrimSpace(c.Query("po_date")), return err
PoDateFrom: strings.TrimSpace(c.Query("po_date_from")), }
PoDateTo: strings.TrimSpace(c.Query("po_date_to")), return exportPurchaseListExcel(c, results)
CreatedFrom: strings.TrimSpace(c.Query("created_from")),
CreatedTo: strings.TrimSpace(c.Query("created_to")),
SupplierID: uint(c.QueryInt("supplier_id", 0)),
AreaID: uint(c.QueryInt("area_id", 0)),
LocationID: uint(c.QueryInt("location_id", 0)),
ProductCategoryID: strings.TrimSpace(c.Query("product_category_id")),
} }
if query.Page < 1 || query.Limit < 1 { if query.Page < 1 || query.Limit < 1 {
@@ -88,6 +85,53 @@ func (ctrl *PurchaseController) GetAll(c *fiber.Ctx) error {
}) })
} }
func buildPurchaseQuery(c *fiber.Ctx) *validation.Query {
return &validation.Query{
Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10),
Search: strings.TrimSpace(c.Query("search")),
ApprovalStatus: strings.TrimSpace(c.Query("approval_status")),
PoDate: strings.TrimSpace(c.Query("po_date")),
PoDateFrom: strings.TrimSpace(c.Query("po_date_from")),
PoDateTo: strings.TrimSpace(c.Query("po_date_to")),
CreatedFrom: strings.TrimSpace(c.Query("created_from")),
CreatedTo: strings.TrimSpace(c.Query("created_to")),
SupplierID: uint(c.QueryInt("supplier_id", 0)),
AreaID: uint(c.QueryInt("area_id", 0)),
LocationID: uint(c.QueryInt("location_id", 0)),
ProjectFlockID: uint(c.QueryInt("project_flock_id", 0)),
ProjectFlockKandangID: uint(c.QueryInt("project_flock_kandang_id", 0)),
ProductCategoryID: strings.TrimSpace(c.Query("product_category_id")),
}
}
func (ctrl *PurchaseController) getAllPurchasesForExcel(c *fiber.Ctx, baseQuery *validation.Query) ([]entity.Purchase, error) {
query := *baseQuery
query.Page = 1
query.Limit = purchaseExcelExportFetchLimit
results := make([]entity.Purchase, 0)
for {
pageResults, total, err := ctrl.service.GetAll(c, &query)
if err != nil {
return nil, err
}
if len(pageResults) == 0 || total == 0 {
break
}
results = append(results, pageResults...)
if int64(len(results)) >= total {
break
}
query.Page++
}
return results, nil
}
func (ctrl *PurchaseController) GetOne(c *fiber.Ctx) error { func (ctrl *PurchaseController) GetOne(c *fiber.Ctx) error {
param := c.Params("id") param := c.Params("id")
@@ -0,0 +1,203 @@
package controller
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/services"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/validations"
"github.com/gofiber/fiber/v2"
"github.com/xuri/excelize/v2"
)
type purchaseServiceStub struct {
getAllCalls []validation.Query
}
var _ service.PurchaseService = (*purchaseServiceStub)(nil)
func (s *purchaseServiceStub) GetAll(_ *fiber.Ctx, params *validation.Query) ([]entity.Purchase, int64, error) {
callCopy := *params
s.getAllCalls = append(s.getAllCalls, callCopy)
switch params.Page {
case 1:
return []entity.Purchase{
buildPurchaseForControllerTest(1, "PR-00001"),
buildPurchaseForControllerTest(2, "PR-00002"),
}, 3, nil
case 2:
return []entity.Purchase{
buildPurchaseForControllerTest(3, "PR-00003"),
}, 3, nil
default:
return []entity.Purchase{}, 3, nil
}
}
func (s *purchaseServiceStub) GetOne(_ *fiber.Ctx, _ uint) (*entity.Purchase, error) {
return &entity.Purchase{}, nil
}
func (s *purchaseServiceStub) CreateOne(_ *fiber.Ctx, _ *validation.CreatePurchaseRequest) (*entity.Purchase, error) {
return &entity.Purchase{}, nil
}
func (s *purchaseServiceStub) ApproveStaffPurchase(_ *fiber.Ctx, _ uint, _ *validation.ApproveStaffPurchaseRequest) (*entity.Purchase, error) {
return &entity.Purchase{}, nil
}
func (s *purchaseServiceStub) ApproveManagerPurchase(_ *fiber.Ctx, _ uint, _ *validation.ApproveManagerPurchaseRequest) (*entity.Purchase, error) {
return &entity.Purchase{}, nil
}
func (s *purchaseServiceStub) ReceiveProducts(_ *fiber.Ctx, _ uint, _ *validation.ReceivePurchaseRequest) (*entity.Purchase, error) {
return &entity.Purchase{}, nil
}
func (s *purchaseServiceStub) DeleteItems(_ *fiber.Ctx, _ uint, _ *validation.DeletePurchaseItemsRequest) (*entity.Purchase, error) {
return &entity.Purchase{}, nil
}
func (s *purchaseServiceStub) DeletePurchase(_ *fiber.Ctx, _ uint) error {
return nil
}
func (s *purchaseServiceStub) GetProgressRows(_ *fiber.Ctx, _ *exportprogress.Query) ([]exportprogress.Row, error) {
return nil, nil
}
func TestPurchaseControllerGetAllExportAllIgnoresRequestLimit(t *testing.T) {
app := fiber.New()
stub := &purchaseServiceStub{}
ctrl := NewPurchaseController(stub)
app.Get("/purchases", ctrl.GetAll)
req := httptest.NewRequest(
http.MethodGet,
"/purchases?export=excel&type=all&page=9&limit=1&search=po&supplier_id=7&area_id=4&location_id=2&product_category_id=1,2&approval_status=pending&po_date_from=2026-01-01&po_date_to=2026-01-31&created_from=2026-02-01&created_to=2026-02-20",
nil,
)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("unexpected app.Test error: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != fiber.StatusOK {
t.Fatalf("expected status 200, got %d", resp.StatusCode)
}
contentType := resp.Header.Get("Content-Type")
if !strings.Contains(contentType, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") {
t.Fatalf("unexpected content-type: %s", contentType)
}
disposition := resp.Header.Get("Content-Disposition")
if !strings.Contains(disposition, "purchases_all_") {
t.Fatalf("unexpected content-disposition: %s", disposition)
}
if len(stub.getAllCalls) != 2 {
t.Fatalf("expected 2 GetAll calls, got %d", len(stub.getAllCalls))
}
firstCall := stub.getAllCalls[0]
secondCall := stub.getAllCalls[1]
if firstCall.Page != 1 || secondCall.Page != 2 {
t.Fatalf("expected internal paging page 1 and 2, got %d and %d", firstCall.Page, secondCall.Page)
}
if firstCall.Limit != purchaseExcelExportFetchLimit || secondCall.Limit != purchaseExcelExportFetchLimit {
t.Fatalf("expected internal limit %d, got %d and %d", purchaseExcelExportFetchLimit, firstCall.Limit, secondCall.Limit)
}
if firstCall.Search != "po" ||
firstCall.SupplierID != 7 ||
firstCall.AreaID != 4 ||
firstCall.LocationID != 2 ||
firstCall.ProductCategoryID != "1,2" ||
firstCall.ApprovalStatus != "pending" ||
firstCall.PoDateFrom != "2026-01-01" ||
firstCall.PoDateTo != "2026-01-31" ||
firstCall.CreatedFrom != "2026-02-01" ||
firstCall.CreatedTo != "2026-02-20" {
t.Fatalf("unexpected forwarded filters: %+v", firstCall)
}
payload, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("failed to read excel payload: %v", err)
}
file, err := excelize.OpenReader(bytes.NewReader(payload))
if err != nil {
t.Fatalf("failed to parse excel payload: %v", err)
}
defer file.Close()
if got, _ := file.GetCellValue(purchaseExportSheetName, "A1"); got != "PR Number" {
t.Fatalf("expected A1 header to be PR Number, got %q", got)
}
if got, _ := file.GetCellValue(purchaseExportSheetName, "A2"); got != "PR-00001" {
t.Fatalf("expected first row PR-00001, got %q", got)
}
}
func TestPurchaseControllerGetAllKeepsPaginationValidationForNonExportAll(t *testing.T) {
app := fiber.New()
stub := &purchaseServiceStub{}
ctrl := NewPurchaseController(stub)
app.Get("/purchases", ctrl.GetAll)
req := httptest.NewRequest(http.MethodGet, "/purchases?page=1&limit=0", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("unexpected app.Test error: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != fiber.StatusBadRequest {
t.Fatalf("expected status 400, got %d", resp.StatusCode)
}
}
func buildPurchaseForControllerTest(id uint, prNumber string) entity.Purchase {
poNumber := "PO-" + strings.TrimPrefix(prNumber, "PR-")
poDate := time.Date(2026, time.April, 22, 0, 0, 0, 0, time.UTC)
notes := "catatan"
approvalAction := entity.ApprovalActionApproved
return entity.Purchase{
Id: id,
PrNumber: prNumber,
PoNumber: &poNumber,
PoDate: &poDate,
Notes: &notes,
Supplier: entity.Supplier{
Id: 10,
Name: "Supplier A",
},
LatestApproval: &entity.Approval{
Id: 1,
StepName: "Manager Purchase",
Action: &approvalAction,
},
Items: []entity.PurchaseItem{
{
Id: id*10 + 1,
TotalPrice: 1000000,
Product: &entity.Product{
Id: id*100 + 1,
Name: "Pakan Starter",
},
},
},
}
}
@@ -0,0 +1,334 @@
package controller
import (
"fmt"
"math"
"sort"
"strconv"
"strings"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/dto"
"github.com/gofiber/fiber/v2"
"github.com/xuri/excelize/v2"
)
const purchaseExportSheetName = "Purchases"
func isAllPurchaseExcelExportRequest(c *fiber.Ctx) bool {
return strings.EqualFold(strings.TrimSpace(c.Query("export")), "excel") &&
strings.EqualFold(strings.TrimSpace(c.Query("type")), "all")
}
func exportPurchaseListExcel(c *fiber.Ctx, purchases []entity.Purchase) error {
content, err := buildPurchaseExportWorkbook(purchases)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to generate excel file")
}
filename := fmt.Sprintf("purchases_all_%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)
}
func buildPurchaseExportWorkbook(purchases []entity.Purchase) ([]byte, error) {
file := excelize.NewFile()
defer file.Close()
defaultSheet := file.GetSheetName(file.GetActiveSheetIndex())
if defaultSheet != purchaseExportSheetName {
if err := file.SetSheetName(defaultSheet, purchaseExportSheetName); err != nil {
return nil, err
}
}
listItems := dto.ToPurchaseListDTOs(purchases)
grandTotals := buildPurchaseGrandTotalMap(purchases)
if err := setPurchaseExportColumns(file, purchaseExportSheetName); err != nil {
return nil, err
}
if err := setPurchaseExportHeaders(file, purchaseExportSheetName); err != nil {
return nil, err
}
if err := setPurchaseExportRows(file, purchaseExportSheetName, listItems, grandTotals); err != nil {
return nil, err
}
if err := file.SetPanes(purchaseExportSheetName, &excelize.Panes{
Freeze: true,
YSplit: 1,
TopLeftCell: "A2",
ActivePane: "bottomLeft",
}); err != nil {
return nil, err
}
buffer, err := file.WriteToBuffer()
if err != nil {
return nil, err
}
return buffer.Bytes(), nil
}
func setPurchaseExportColumns(file *excelize.File, sheet string) error {
columnWidths := map[string]float64{
"A": 16,
"B": 16,
"C": 14,
"D": 22,
"E": 18,
"F": 18,
"G": 52,
"H": 24,
}
for col, width := range columnWidths {
if err := file.SetColWidth(sheet, col, col, width); err != nil {
return err
}
}
if err := file.SetRowHeight(sheet, 1, 24); err != nil {
return err
}
return nil
}
func setPurchaseExportHeaders(file *excelize.File, sheet string) error {
headers := []string{
"PR Number",
"PO Number",
"Tanggal PO",
"Supplier",
"Status",
"Grand Total",
"Products",
"Notes",
}
for i, header := range headers {
colName, err := excelize.ColumnNumberToName(i + 1)
if err != nil {
return err
}
if err := file.SetCellValue(sheet, colName+"1", header); err != nil {
return err
}
}
headerStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Color: "1F2937"},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"DCEBFA"}},
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 err
}
return file.SetCellStyle(sheet, "A1", "H1", headerStyle)
}
func setPurchaseExportRows(file *excelize.File, sheet string, items []dto.PurchaseListDTO, grandTotals map[uint]float64) error {
if len(items) == 0 {
return nil
}
for i, item := range items {
row := strconv.Itoa(i + 2)
if err := file.SetCellValue(sheet, "A"+row, safePurchaseExportText(item.PrNumber)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "B"+row, safePurchaseExportPointerText(item.PoNumber)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "C"+row, formatPurchaseExportDate(item.PoDate)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "D"+row, safePurchaseSupplierName(item)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "E"+row, formatPurchaseExportStatus(item)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "F"+row, formatPurchaseRupiah(grandTotals[item.Id])); err != nil {
return err
}
if err := file.SetCellValue(sheet, "G"+row, formatPurchaseProducts(item)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "H"+row, safePurchaseExportPointerText(item.Notes)); err != nil {
return err
}
}
lastRow := len(items) + 1
dataStyle, err := file.NewStyle(&excelize.Style{
Alignment: &excelize.Alignment{
Horizontal: "left",
Vertical: "center",
WrapText: true,
},
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 err
}
if err := file.SetCellStyle(sheet, "A2", "H"+strconv.Itoa(lastRow), dataStyle); err != nil {
return err
}
moneyStyle, err := file.NewStyle(&excelize.Style{
Alignment: &excelize.Alignment{
Horizontal: "right",
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 err
}
return file.SetCellStyle(sheet, "F2", "F"+strconv.Itoa(lastRow), moneyStyle)
}
func buildPurchaseGrandTotalMap(items []entity.Purchase) map[uint]float64 {
result := make(map[uint]float64, len(items))
for i := range items {
total := 0.0
for j := range items[i].Items {
total += items[i].Items[j].TotalPrice
}
result[items[i].Id] = total
}
return result
}
func safePurchaseSupplierName(item dto.PurchaseListDTO) string {
if item.Supplier == nil {
return "-"
}
return safePurchaseExportText(item.Supplier.Name)
}
func formatPurchaseExportStatus(item dto.PurchaseListDTO) string {
if item.LatestApproval == nil {
return "-"
}
if item.LatestApproval.Action != nil &&
strings.EqualFold(strings.TrimSpace(*item.LatestApproval.Action), string(entity.ApprovalActionRejected)) {
return "Ditolak"
}
return safePurchaseExportText(item.LatestApproval.StepName)
}
func formatPurchaseExportDate(value *time.Time) string {
if value == nil || value.IsZero() {
return "-"
}
t := *value
location, err := time.LoadLocation("Asia/Jakarta")
if err == nil {
t = t.In(location)
}
return t.Format("02-01-2006")
}
func formatPurchaseProducts(item dto.PurchaseListDTO) string {
if len(item.Products) == 0 {
return "-"
}
seen := make(map[string]struct{})
names := make([]string, 0, len(item.Products))
for i := range item.Products {
name := strings.TrimSpace(item.Products[i].Name)
if name == "" {
continue
}
if _, exists := seen[name]; exists {
continue
}
seen[name] = struct{}{}
names = append(names, name)
}
if len(names) == 0 {
return "-"
}
sort.Strings(names)
return strings.Join(names, ", ")
}
func safePurchaseExportPointerText(value *string) string {
if value == nil {
return "-"
}
return safePurchaseExportText(*value)
}
func safePurchaseExportText(value string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return "-"
}
return trimmed
}
func formatPurchaseRupiah(value float64) string {
if math.IsNaN(value) || math.IsInf(value, 0) {
return "Rp 0"
}
rounded := int64(math.Round(value))
sign := ""
if rounded < 0 {
sign = "-"
rounded = -rounded
}
raw := strconv.FormatInt(rounded, 10)
if raw == "" {
raw = "0"
}
var grouped strings.Builder
rem := len(raw) % 3
if rem > 0 {
grouped.WriteString(raw[:rem])
if len(raw) > rem {
grouped.WriteString(".")
}
}
for i := rem; i < len(raw); i += 3 {
grouped.WriteString(raw[i : i+3])
if i+3 < len(raw) {
grouped.WriteString(".")
}
}
return "Rp " + sign + grouped.String()
}
@@ -0,0 +1,157 @@
package controller
import (
"bytes"
"testing"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"github.com/xuri/excelize/v2"
)
func TestBuildPurchaseExportWorkbookHeadersAndRows(t *testing.T) {
content, err := buildPurchaseExportWorkbook([]entity.Purchase{
buildPurchaseForExportTest(
1,
"PR-00011",
"PO-00011",
time.Date(2026, time.April, 22, 0, 0, 0, 0, time.UTC),
"Supplier A",
"Manager Purchase",
nil,
"catatan",
[]entity.PurchaseItem{
buildPurchaseItemForExportTest(11, "Pakan Starter", 1000000),
buildPurchaseItemForExportTest(12, "Vitamin A", 350000),
buildPurchaseItemForExportTest(11, "Pakan Starter", 0),
},
),
buildPurchaseForExportTest(
2,
"PR-00012",
"",
time.Time{},
"Supplier B",
"Manager Purchase",
ptrApprovalAction(entity.ApprovalActionRejected),
"",
[]entity.PurchaseItem{
buildPurchaseItemForExportTest(21, "Obat X", 75000),
},
),
})
if err != nil {
t.Fatalf("buildPurchaseExportWorkbook returned error: %v", err)
}
file, err := excelize.OpenReader(bytes.NewReader(content))
if err != nil {
t.Fatalf("failed to open workbook bytes: %v", err)
}
defer file.Close()
expectedHeaders := map[string]string{
"A1": "PR Number",
"B1": "PO Number",
"C1": "Tanggal PO",
"D1": "Supplier",
"E1": "Status",
"F1": "Grand Total",
"G1": "Products",
"H1": "Notes",
}
for cell, expected := range expectedHeaders {
got, err := file.GetCellValue(purchaseExportSheetName, cell)
if err != nil {
t.Fatalf("GetCellValue(%s) failed: %v", cell, err)
}
if got != expected {
t.Fatalf("expected %s=%q, got %q", cell, expected, got)
}
}
assertPurchaseCellEquals(t, file, "A2", "PR-00011")
assertPurchaseCellEquals(t, file, "B2", "PO-00011")
assertPurchaseCellEquals(t, file, "C2", "22-04-2026")
assertPurchaseCellEquals(t, file, "D2", "Supplier A")
assertPurchaseCellEquals(t, file, "E2", "Manager Purchase")
assertPurchaseCellEquals(t, file, "F2", "Rp 1.350.000")
assertPurchaseCellEquals(t, file, "G2", "Pakan Starter, Vitamin A")
assertPurchaseCellEquals(t, file, "H2", "catatan")
assertPurchaseCellEquals(t, file, "A3", "PR-00012")
assertPurchaseCellEquals(t, file, "B3", "-")
assertPurchaseCellEquals(t, file, "C3", "-")
assertPurchaseCellEquals(t, file, "E3", "Ditolak")
assertPurchaseCellEquals(t, file, "F3", "Rp 75.000")
assertPurchaseCellEquals(t, file, "G3", "Obat X")
assertPurchaseCellEquals(t, file, "H3", "-")
}
func assertPurchaseCellEquals(t *testing.T, file *excelize.File, cell, expected string) {
t.Helper()
got, err := file.GetCellValue(purchaseExportSheetName, cell)
if err != nil {
t.Fatalf("GetCellValue(%s) failed: %v", cell, err)
}
if got != expected {
t.Fatalf("expected %s=%q, got %q", cell, expected, got)
}
}
func buildPurchaseForExportTest(
id uint,
prNumber, poNumber string,
poDate time.Time,
supplierName, stepName string,
action *entity.ApprovalAction,
notes string,
items []entity.PurchaseItem,
) entity.Purchase {
var poNumberRef *string
if poNumber != "" {
poNumberRef = &poNumber
}
var poDateRef *time.Time
if !poDate.IsZero() {
poDateRef = &poDate
}
var notesRef *string
if notes != "" {
notesRef = &notes
}
return entity.Purchase{
Id: id,
PrNumber: prNumber,
PoNumber: poNumberRef,
PoDate: poDateRef,
Notes: notesRef,
Supplier: entity.Supplier{
Id: id + 100,
Name: supplierName,
},
LatestApproval: &entity.Approval{
Id: id + 1000,
StepName: stepName,
Action: action,
},
Items: items,
}
}
func buildPurchaseItemForExportTest(productID uint, productName string, totalPrice float64) entity.PurchaseItem {
return entity.PurchaseItem{
ProductId: productID,
TotalPrice: totalPrice,
Product: &entity.Product{
Id: productID,
Name: productName,
},
}
}
func ptrApprovalAction(value entity.ApprovalAction) *entity.ApprovalAction {
return &value
}
@@ -247,7 +247,7 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
if len(productCategoryIDs) > 0 { if len(productCategoryIDs) > 0 {
db = db.Where( db = db.Where(
`EXISTS ( `EXISTS (
SELECT 1 SELECT 1
FROM purchase_items pi FROM purchase_items pi
JOIN products p ON p.id = pi.product_id JOIN products p ON p.id = pi.product_id
WHERE pi.purchase_id = purchases.id AND p.product_category_id IN ? WHERE pi.purchase_id = purchases.id AND p.product_category_id IN ?
@@ -256,183 +256,9 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
) )
} }
if len(approvalStatuses) > 0 { db = applyPurchaseProjectFlockFilter(db, params.ProjectFlockID, params.ProjectFlockKandangID)
approvalConditions := make([]string, 0, len(approvalStatuses)) db = applyPurchaseApprovalStatusFilter(db, approvalStatuses)
approvalArgs := make([]any, 0, 2+(len(approvalStatuses)*3)) db = applyPurchaseSearchFilter(db, search)
approvalArgs = append(approvalArgs, utils.ApprovalWorkflowPurchase.String(), utils.ApprovalWorkflowPurchase.String())
for _, status := range approvalStatuses {
if status == "" {
continue
}
like := "%" + status + "%"
approvalConditions = append(approvalConditions, `(LOWER(COALESCE(a.step_name, '')) LIKE ? OR LOWER(COALESCE(CAST(a.action AS TEXT), '')) LIKE ? OR CAST(a.step_number AS TEXT) = ?)`)
approvalArgs = append(approvalArgs, like, like, status)
}
if len(approvalConditions) > 0 {
approvalClause := strings.Join(approvalConditions, " OR ")
approvalQuery := fmt.Sprintf(
`EXISTS (
SELECT 1
FROM approvals a
WHERE a.approvable_type = ?
AND a.approvable_id = purchases.id
AND a.id = (
SELECT a2.id
FROM approvals a2
WHERE a2.approvable_type = ?
AND a2.approvable_id = purchases.id
ORDER BY a2.action_at DESC, a2.id DESC
LIMIT 1
)
AND (%s)
)`,
approvalClause,
)
db = db.Where(approvalQuery, approvalArgs...)
}
}
if search != "" {
like := "%" + search + "%"
db = db.Where(
`(
LOWER(COALESCE(purchases.pr_number, '')) LIKE ?
OR LOWER(COALESCE(purchases.po_number, '')) LIKE ?
OR EXISTS (
SELECT 1
FROM suppliers s
WHERE s.id = purchases.supplier_id
AND LOWER(COALESCE(s.name, '')) LIKE ?
)
OR EXISTS (
SELECT 1
FROM users u
WHERE u.id = purchases.created_by
AND LOWER(COALESCE(u.name, '')) LIKE ?
)
OR EXISTS (
SELECT 1
FROM purchase_items pi
JOIN products p ON p.id = pi.product_id
WHERE pi.purchase_id = purchases.id
AND LOWER(COALESCE(p.name, '')) LIKE ?
)
OR EXISTS (
SELECT 1
FROM purchase_items pi
JOIN warehouses w ON w.id = pi.warehouse_id
JOIN locations l ON l.id = w.location_id
WHERE pi.purchase_id = purchases.id
AND LOWER(COALESCE(l.name, '')) LIKE ?
)
OR EXISTS (
SELECT 1
FROM purchase_items pi
JOIN expense_nonstocks en ON en.id = pi.expense_nonstock_id
JOIN expenses e ON e.id = en.expense_id
WHERE pi.purchase_id = purchases.id
AND LOWER(COALESCE(e.reference_number, '')) LIKE ?
)
)`,
like,
like,
like,
like,
like,
like,
like,
)
}
if len(approvalStatuses) > 0 {
approvalConditions := make([]string, 0, len(approvalStatuses))
approvalArgs := make([]any, 0, 2+(len(approvalStatuses)*3))
approvalArgs = append(approvalArgs, utils.ApprovalWorkflowPurchase.String(), utils.ApprovalWorkflowPurchase.String())
for _, status := range approvalStatuses {
if status == "" {
continue
}
like := "%" + status + "%"
approvalConditions = append(approvalConditions, `(LOWER(COALESCE(a.step_name, '')) LIKE ? OR LOWER(COALESCE(CAST(a.action AS TEXT), '')) LIKE ? OR CAST(a.step_number AS TEXT) = ?)`)
approvalArgs = append(approvalArgs, like, like, status)
}
if len(approvalConditions) > 0 {
approvalClause := strings.Join(approvalConditions, " OR ")
approvalQuery := fmt.Sprintf(
`EXISTS (
SELECT 1
FROM approvals a
WHERE a.approvable_type = ?
AND a.approvable_id = purchases.id
AND a.id = (
SELECT a2.id
FROM approvals a2
WHERE a2.approvable_type = ?
AND a2.approvable_id = purchases.id
ORDER BY a2.action_at DESC, a2.id DESC
LIMIT 1
)
AND (%s)
)`,
approvalClause,
)
db = db.Where(approvalQuery, approvalArgs...)
}
}
if search != "" {
like := "%" + search + "%"
db = db.Where(
`(
LOWER(COALESCE(purchases.pr_number, '')) LIKE ?
OR LOWER(COALESCE(purchases.po_number, '')) LIKE ?
OR EXISTS (
SELECT 1
FROM suppliers s
WHERE s.id = purchases.supplier_id
AND LOWER(COALESCE(s.name, '')) LIKE ?
)
OR EXISTS (
SELECT 1
FROM users u
WHERE u.id = purchases.created_by
AND LOWER(COALESCE(u.name, '')) LIKE ?
)
OR EXISTS (
SELECT 1
FROM purchase_items pi
JOIN products p ON p.id = pi.product_id
WHERE pi.purchase_id = purchases.id
AND LOWER(COALESCE(p.name, '')) LIKE ?
)
OR EXISTS (
SELECT 1
FROM purchase_items pi
JOIN warehouses w ON w.id = pi.warehouse_id
JOIN locations l ON l.id = w.location_id
WHERE pi.purchase_id = purchases.id
AND LOWER(COALESCE(l.name, '')) LIKE ?
)
OR EXISTS (
SELECT 1
FROM purchase_items pi
JOIN expense_nonstocks en ON en.id = pi.expense_nonstock_id
JOIN expenses e ON e.id = en.expense_id
WHERE pi.purchase_id = purchases.id
AND LOWER(COALESCE(e.reference_number, '')) LIKE ?
)
)`,
like,
like,
like,
like,
like,
like,
like,
)
}
return db.Order("created_at DESC").Order("purchases.id DESC") return db.Order("created_at DESC").Order("purchases.id DESC")
}) })
@@ -2361,6 +2187,155 @@ func parsePoDateRangeForQuery(fromStr, toStr string) (*time.Time, *time.Time, er
return fromPtr, toPtr, nil return fromPtr, toPtr, nil
} }
func applyPurchaseProjectFlockFilter(db *gorm.DB, projectFlockID, projectFlockKandangID uint) *gorm.DB {
if projectFlockID > 0 {
db = db.Where(
`EXISTS (
SELECT 1
FROM purchase_items pi
LEFT JOIN project_flock_kandangs pfk_explicit ON pfk_explicit.id = pi.project_flock_kandang_id
LEFT JOIN warehouses w ON w.id = pi.warehouse_id
LEFT JOIN project_flock_kandangs pfk_active ON pfk_active.kandang_id = w.kandang_id AND pfk_active.closed_at IS NULL
WHERE pi.purchase_id = purchases.id
AND COALESCE(pfk_explicit.project_flock_id, pfk_active.project_flock_id) = ?
)`,
projectFlockID,
)
}
if projectFlockKandangID > 0 {
db = db.Where(
`EXISTS (
SELECT 1
FROM purchase_items pi
LEFT JOIN warehouses w ON w.id = pi.warehouse_id
LEFT JOIN project_flock_kandangs pfk_active ON pfk_active.kandang_id = w.kandang_id AND pfk_active.closed_at IS NULL
WHERE pi.purchase_id = purchases.id
AND COALESCE(pi.project_flock_kandang_id, pfk_active.id) = ?
)`,
projectFlockKandangID,
)
}
return db
}
func applyPurchaseApprovalStatusFilter(db *gorm.DB, approvalStatuses []string) *gorm.DB {
if len(approvalStatuses) == 0 {
return db
}
approvalConditions := make([]string, 0, len(approvalStatuses))
approvalArgs := make([]any, 0, 2+(len(approvalStatuses)*3))
approvalArgs = append(approvalArgs, utils.ApprovalWorkflowPurchase.String(), utils.ApprovalWorkflowPurchase.String())
for _, status := range approvalStatuses {
if status == "" {
continue
}
like := "%" + status + "%"
approvalConditions = append(approvalConditions, `(LOWER(COALESCE(a.step_name, '')) LIKE ? OR LOWER(COALESCE(CAST(a.action AS TEXT), '')) LIKE ? OR CAST(a.step_number AS TEXT) = ?)`)
approvalArgs = append(approvalArgs, like, like, status)
}
if len(approvalConditions) == 0 {
return db
}
approvalClause := strings.Join(approvalConditions, " OR ")
approvalQuery := fmt.Sprintf(
`EXISTS (
SELECT 1
FROM approvals a
WHERE a.approvable_type = ?
AND a.approvable_id = purchases.id
AND a.id = (
SELECT a2.id
FROM approvals a2
WHERE a2.approvable_type = ?
AND a2.approvable_id = purchases.id
ORDER BY a2.action_at DESC, a2.id DESC
LIMIT 1
)
AND (%s)
)`,
approvalClause,
)
return db.Where(approvalQuery, approvalArgs...)
}
func applyPurchaseSearchFilter(db *gorm.DB, search string) *gorm.DB {
if search == "" {
return db
}
like := "%" + search + "%"
return db.Where(
`(
LOWER(COALESCE(purchases.pr_number, '')) LIKE ?
OR LOWER(COALESCE(purchases.po_number, '')) LIKE ?
OR EXISTS (
SELECT 1
FROM suppliers s
WHERE s.id = purchases.supplier_id
AND LOWER(COALESCE(s.name, '')) LIKE ?
)
OR EXISTS (
SELECT 1
FROM users u
WHERE u.id = purchases.created_by
AND LOWER(COALESCE(u.name, '')) LIKE ?
)
OR EXISTS (
SELECT 1
FROM purchase_items pi
JOIN products p ON p.id = pi.product_id
WHERE pi.purchase_id = purchases.id
AND LOWER(COALESCE(p.name, '')) LIKE ?
)
OR EXISTS (
SELECT 1
FROM purchase_items pi
JOIN warehouses w ON w.id = pi.warehouse_id
JOIN locations l ON l.id = w.location_id
WHERE pi.purchase_id = purchases.id
AND LOWER(COALESCE(l.name, '')) LIKE ?
)
OR EXISTS (
SELECT 1
FROM purchase_items pi
LEFT JOIN project_flock_kandangs pfk_explicit ON pfk_explicit.id = pi.project_flock_kandang_id
LEFT JOIN warehouses w ON w.id = pi.warehouse_id
LEFT JOIN project_flock_kandangs pfk_active ON pfk_active.kandang_id = w.kandang_id AND pfk_active.closed_at IS NULL
LEFT JOIN project_flocks pf ON pf.id = COALESCE(pfk_explicit.project_flock_id, pfk_active.project_flock_id)
LEFT JOIN kandangs k ON k.id = COALESCE(pfk_explicit.kandang_id, pfk_active.kandang_id, w.kandang_id)
WHERE pi.purchase_id = purchases.id
AND (
LOWER(COALESCE(pf.flock_name, '')) LIKE ? OR
LOWER(COALESCE(k.name, '')) LIKE ?
)
)
OR EXISTS (
SELECT 1
FROM purchase_items pi
JOIN expense_nonstocks en ON en.id = pi.expense_nonstock_id
JOIN expenses e ON e.id = en.expense_id
WHERE pi.purchase_id = purchases.id
AND LOWER(COALESCE(e.reference_number, '')) LIKE ?
)
)`,
like,
like,
like,
like,
like,
like,
like,
like,
like,
)
}
func normalizeApprovalStatusFilter(raw string) string { func normalizeApprovalStatusFilter(raw string) string {
value := strings.ToLower(strings.TrimSpace(raw)) value := strings.ToLower(strings.TrimSpace(raw))
switch value { switch value {
@@ -61,17 +61,19 @@ type DeletePurchaseItemsRequest struct {
} }
type Query struct { type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1"` Page int `query:"page" validate:"omitempty,number,min=1"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"`
SupplierID uint `query:"supplier_id" validate:"omitempty,gt=0"` SupplierID uint `query:"supplier_id" validate:"omitempty,gt=0"`
AreaID uint `query:"area_id" validate:"omitempty,gt=0"` AreaID uint `query:"area_id" validate:"omitempty,gt=0"`
LocationID uint `query:"location_id" validate:"omitempty,gt=0"` LocationID uint `query:"location_id" validate:"omitempty,gt=0"`
ProductCategoryID string `query:"product_category_id" validate:"omitempty,max=500"` ProjectFlockID uint `query:"project_flock_id" validate:"omitempty,gt=0"`
ApprovalStatus string `query:"approval_status" validate:"omitempty,max=500"` ProjectFlockKandangID uint `query:"project_flock_kandang_id" validate:"omitempty,gt=0"`
PoDate string `query:"po_date" validate:"omitempty,datetime=2006-01-02"` ProductCategoryID string `query:"product_category_id" validate:"omitempty,max=500"`
PoDateFrom string `query:"po_date_from" validate:"omitempty,datetime=2006-01-02"` ApprovalStatus string `query:"approval_status" validate:"omitempty,max=500"`
PoDateTo string `query:"po_date_to" validate:"omitempty,datetime=2006-01-02"` PoDate string `query:"po_date" validate:"omitempty,datetime=2006-01-02"`
Search string `query:"search" validate:"omitempty,max=100"` PoDateFrom string `query:"po_date_from" validate:"omitempty,datetime=2006-01-02"`
CreatedFrom string `query:"created_from" validate:"omitempty,datetime=2006-01-02"` PoDateTo string `query:"po_date_to" validate:"omitempty,datetime=2006-01-02"`
CreatedTo string `query:"created_to" validate:"omitempty,datetime=2006-01-02"` Search string `query:"search" validate:"omitempty,max=100"`
CreatedFrom string `query:"created_from" validate:"omitempty,datetime=2006-01-02"`
CreatedTo string `query:"created_to" validate:"omitempty,datetime=2006-01-02"`
} }
@@ -30,6 +30,8 @@ type RepportController struct {
RepportService service.RepportService RepportService service.RepportService
} }
const expenseReportExcelExportFetchLimit = 100
func NewRepportController(repportService service.RepportService) *RepportController { func NewRepportController(repportService service.RepportService) *RepportController {
return &RepportController{ return &RepportController{
RepportService: repportService, RepportService: repportService,
@@ -66,6 +68,14 @@ func (c *RepportController) GetExpense(ctx *fiber.Ctx) error {
query.AllowedAreaIDs = toInt64Slice(areaScope.IDs) query.AllowedAreaIDs = toInt64Slice(areaScope.IDs)
} }
if isAllExpenseExcelExportRequest(ctx) {
allResults, err := c.getAllExpenseRowsForExcel(ctx, query)
if err != nil {
return err
}
return exportExpenseReportListExcel(ctx, allResults)
}
if query.Page < 1 || query.Limit < 1 { if query.Page < 1 || query.Limit < 1 {
return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0")
} }
@@ -90,6 +100,33 @@ func (c *RepportController) GetExpense(ctx *fiber.Ctx) error {
}) })
} }
func (c *RepportController) getAllExpenseRowsForExcel(ctx *fiber.Ctx, baseQuery *validation.ExpenseQuery) ([]dto.RepportExpenseListDTO, error) {
query := *baseQuery
query.Page = 1
query.Limit = expenseReportExcelExportFetchLimit
results := make([]dto.RepportExpenseListDTO, 0)
for {
pageResults, total, err := c.RepportService.GetExpense(ctx, &query)
if err != nil {
return nil, err
}
if len(pageResults) == 0 || total == 0 {
break
}
results = append(results, pageResults...)
if int64(len(results)) >= total {
break
}
query.Page++
}
return results, nil
}
func (c *RepportController) GetExpenseDepreciation(ctx *fiber.Ctx) error { func (c *RepportController) GetExpenseDepreciation(ctx *fiber.Ctx) error {
rows, meta, err := c.RepportService.GetExpenseDepreciation(ctx) rows, meta, err := c.RepportService.GetExpenseDepreciation(ctx)
if err != nil { if err != nil {
@@ -0,0 +1,202 @@
package controller
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"reflect"
"testing"
"time"
"github.com/gofiber/fiber/v2"
"github.com/xuri/excelize/v2"
"gorm.io/gorm"
approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto"
kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto"
locationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto"
nonstockDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/dto"
supplierDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/dto"
"gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/services"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/validations"
)
type repportServiceStub struct {
service.RepportService
getExpenseCalls []validation.ExpenseQuery
}
func (s *repportServiceStub) GetExpense(_ *fiber.Ctx, params *validation.ExpenseQuery) ([]dto.RepportExpenseListDTO, int64, error) {
callCopy := *params
callCopy.AllowedAreaIDs = append([]int64(nil), params.AllowedAreaIDs...)
callCopy.AllowedLocationIDs = append([]int64(nil), params.AllowedLocationIDs...)
s.getExpenseCalls = append(s.getExpenseCalls, callCopy)
switch params.Page {
case 1:
return []dto.RepportExpenseListDTO{
buildExpenseListForControllerTest("REF-00001", "TRANSPORT 2"),
buildExpenseListForControllerTest("REF-00002", "TRANSPORT"),
}, 3, nil
case 2:
return []dto.RepportExpenseListDTO{
buildExpenseListForControllerTest("REF-00003", "TRANSPORT"),
}, 3, nil
default:
return []dto.RepportExpenseListDTO{}, 3, nil
}
}
func (s *repportServiceStub) DB() *gorm.DB {
return nil
}
func TestRepportControllerGetExpenseExportAllIgnoresRequestLimit(t *testing.T) {
app := fiber.New()
stub := &repportServiceStub{}
ctrl := NewRepportController(stub)
app.Get("/reports/expense", ctrl.GetExpense)
req := httptest.NewRequest(
http.MethodGet,
"/reports/expense?export=excel&type=all&page=9&limit=1&search=operasional&category=BOP&supplier_id=7&kandang_id=4&project_flock_kandang_id=2&nonstock_id=5&area_id=3&location_id=9&realization_date=2026-04-22",
nil,
)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("unexpected app.Test error: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != fiber.StatusOK {
t.Fatalf("expected status 200, got %d", resp.StatusCode)
}
contentType := resp.Header.Get("Content-Type")
if contentType != "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" {
t.Fatalf("unexpected content-type: %s", contentType)
}
disposition := resp.Header.Get("Content-Disposition")
if !bytes.Contains([]byte(disposition), []byte("reports_expense_all_")) {
t.Fatalf("unexpected content-disposition: %s", disposition)
}
if len(stub.getExpenseCalls) != 2 {
t.Fatalf("expected 2 GetExpense calls, got %d", len(stub.getExpenseCalls))
}
firstCall := stub.getExpenseCalls[0]
secondCall := stub.getExpenseCalls[1]
if firstCall.Page != 1 || secondCall.Page != 2 {
t.Fatalf("expected internal pages 1 and 2, got %d and %d", firstCall.Page, secondCall.Page)
}
if firstCall.Limit != expenseReportExcelExportFetchLimit || secondCall.Limit != expenseReportExcelExportFetchLimit {
t.Fatalf("expected internal limit %d, got %d and %d", expenseReportExcelExportFetchLimit, firstCall.Limit, secondCall.Limit)
}
if firstCall.Search != "operasional" ||
firstCall.Category != "BOP" ||
firstCall.SupplierId != 7 ||
firstCall.KandangId != 4 ||
firstCall.ProjectFlockKandangId != 2 ||
firstCall.NonstockId != 5 ||
firstCall.AreaId != 3 ||
firstCall.LocationId != 9 ||
firstCall.RealizationDate != "2026-04-22" {
t.Fatalf("unexpected forwarded filters: %+v", firstCall)
}
payload, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("failed to read excel payload: %v", err)
}
file, err := excelize.OpenReader(bytes.NewReader(payload))
if err != nil {
t.Fatalf("failed to parse excel payload: %v", err)
}
defer file.Close()
sheetList := file.GetSheetList()
expectedSheets := []string{"EKSPEDISI ADE", "EKSPEDISI LTI"}
if !reflect.DeepEqual(sheetList, expectedSheets) {
t.Fatalf("unexpected sheet list: got %v, expected %v", sheetList, expectedSheets)
}
if got, _ := file.GetCellValue("EKSPEDISI ADE", "A1"); got != "No" {
t.Fatalf("expected EKSPEDISI ADE A1 to be No, got %q", got)
}
if got, _ := file.GetCellValue("EKSPEDISI ADE", "G2"); got != "TRANSPORT 2" {
t.Fatalf("expected EKSPEDISI ADE G2 to be TRANSPORT 2, got %q", got)
}
if got, _ := file.GetCellValue("EKSPEDISI LTI", "G2"); got != "TRANSPORT" {
t.Fatalf("expected EKSPEDISI LTI G2 to be TRANSPORT, got %q", got)
}
if got, _ := file.GetCellValue("EKSPEDISI LTI", "A4"); got != "Total" {
t.Fatalf("expected EKSPEDISI LTI A4 to be Total, got %q", got)
}
}
func TestRepportControllerGetExpenseKeepsPaginationValidationForNonExportAll(t *testing.T) {
app := fiber.New()
stub := &repportServiceStub{}
ctrl := NewRepportController(stub)
app.Get("/reports/expense", ctrl.GetExpense)
req := httptest.NewRequest(http.MethodGet, "/reports/expense?page=1&limit=0", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("unexpected app.Test error: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != fiber.StatusBadRequest {
t.Fatalf("expected status 400, got %d", resp.StatusCode)
}
}
func buildExpenseListForControllerTest(reference, product string) dto.RepportExpenseListDTO {
realizationDate := time.Date(2026, time.April, 22, 0, 0, 0, 0, time.UTC)
return dto.RepportExpenseListDTO{
RepportExpenseBaseDTO: dto.RepportExpenseBaseDTO{
ReferenceNumber: reference,
PoNumber: "PO-001",
Category: "BOP",
Notes: "catatan expense",
TransactionDate: time.Date(2026, time.April, 22, 0, 0, 0, 0, time.UTC),
RealizationDate: &realizationDate,
Supplier: &supplierDTO.SupplierRelationDTO{
Name: "Supplier A",
},
},
Kandang: &kandangDTO.KandangRelationDTO{
Name: "Kandang A",
Location: &locationDTO.LocationRelationDTO{
Name: "Darawati",
},
},
Pengajuan: dto.RepportExpensePengajuanDTO{
Qty: 1,
Price: 50000,
Notes: "catatan pengajuan",
Nonstock: &nonstockDTO.NonstockRelationDTO{
Name: product,
},
},
Realisasi: dto.RepportExpenseRealisasiDTO{
Qty: 1,
Price: 50000,
Notes: "catatan realisasi",
Nonstock: &nonstockDTO.NonstockRelationDTO{
Name: product,
},
},
TotalPengajuan: 50000,
TotalRealisasi: 50000,
LatestApproval: &approvalDTO.ApprovalRelationDTO{
StepName: "Realisasi",
},
}
}
@@ -0,0 +1,424 @@
package controller
import (
"fmt"
"sort"
"strconv"
"strings"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto"
"github.com/gofiber/fiber/v2"
"github.com/xuri/excelize/v2"
)
const expenseReportExportSheetName = "Expense Reports"
var expenseReportTemplateSheetOrder = []string{
"UANG MAKAN",
"UPAH",
"EKSPEDISI ADE",
"GALON",
"GAS",
"KEBUTUHAN",
"EKSPEDISI LTI",
"KONTRIBUSI",
"PRODUKSI",
"KOMPENSASI",
"LAIN-LAIN",
"PERBAIKAN",
"LISTRIK",
"PAJAK",
"SOLAR",
}
var expenseReportSheetAliasMap = map[string]string{
"TRANSPORT 2": "EKSPEDISI ADE",
"TRANSPORT": "EKSPEDISI LTI",
"GAS BROODING": "GAS",
}
func isAllExpenseExcelExportRequest(c *fiber.Ctx) bool {
return strings.EqualFold(strings.TrimSpace(c.Query("export")), "excel") &&
strings.EqualFold(strings.TrimSpace(c.Query("type")), "all")
}
func exportExpenseReportListExcel(c *fiber.Ctx, items []dto.RepportExpenseListDTO) error {
content, err := buildExpenseReportExportWorkbook(items)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to generate excel file")
}
filename := fmt.Sprintf("reports_expense_all_%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)
}
func buildExpenseReportExportWorkbook(items []dto.RepportExpenseListDTO) ([]byte, error) {
file := excelize.NewFile()
defer file.Close()
defaultSheet := file.GetSheetName(file.GetActiveSheetIndex())
groups := groupExpenseReportRowsBySheet(items)
orderedSheetNames := orderExpenseReportSheetNames(groups)
if len(orderedSheetNames) == 0 {
if defaultSheet != expenseReportExportSheetName {
if err := file.SetSheetName(defaultSheet, expenseReportExportSheetName); err != nil {
return nil, err
}
}
if err := writeExpenseReportSheet(file, expenseReportExportSheetName, []dto.RepportExpenseListDTO{}); err != nil {
return nil, err
}
} else {
for idx, sheetName := range orderedSheetNames {
if idx == 0 {
if defaultSheet != sheetName {
if err := file.SetSheetName(defaultSheet, sheetName); err != nil {
return nil, err
}
}
} else {
if _, err := file.NewSheet(sheetName); err != nil {
return nil, err
}
}
if err := writeExpenseReportSheet(file, sheetName, groups[sheetName]); err != nil {
return nil, err
}
}
}
buffer, err := file.WriteToBuffer()
if err != nil {
return nil, err
}
return buffer.Bytes(), nil
}
func groupExpenseReportRowsBySheet(items []dto.RepportExpenseListDTO) map[string][]dto.RepportExpenseListDTO {
groups := make(map[string][]dto.RepportExpenseListDTO)
for _, item := range items {
product := resolveExpenseReportProduct(item)
sheetName := resolveExpenseReportSheetName(product)
groups[sheetName] = append(groups[sheetName], item)
}
return groups
}
func orderExpenseReportSheetNames(groups map[string][]dto.RepportExpenseListDTO) []string {
if len(groups) == 0 {
return nil
}
templateSet := make(map[string]struct{}, len(expenseReportTemplateSheetOrder))
ordered := make([]string, 0, len(groups))
for _, sheet := range expenseReportTemplateSheetOrder {
templateSet[sheet] = struct{}{}
if _, ok := groups[sheet]; ok {
ordered = append(ordered, sheet)
}
}
extras := make([]string, 0)
for sheet := range groups {
if _, ok := templateSet[sheet]; !ok {
extras = append(extras, sheet)
}
}
sort.Slice(extras, func(i, j int) bool {
return strings.ToUpper(extras[i]) < strings.ToUpper(extras[j])
})
ordered = append(ordered, extras...)
return ordered
}
func resolveExpenseReportSheetName(product string) string {
normalizedProduct := strings.ToUpper(strings.TrimSpace(product))
if alias, exists := expenseReportSheetAliasMap[normalizedProduct]; exists {
return alias
}
if normalizedProduct == "" {
normalizedProduct = "-"
}
return sanitizeExpenseReportSheetName(normalizedProduct)
}
func sanitizeExpenseReportSheetName(name string) string {
replacer := strings.NewReplacer(
":", " ",
"\\", " ",
"/", " ",
"?", " ",
"*", " ",
"[", " ",
"]", " ",
)
sanitized := strings.TrimSpace(replacer.Replace(name))
if sanitized == "" {
sanitized = "Sheet"
}
runes := []rune(sanitized)
if len(runes) > 31 {
sanitized = string(runes[:31])
}
return sanitized
}
func writeExpenseReportSheet(file *excelize.File, sheet string, items []dto.RepportExpenseListDTO) error {
if err := setExpenseReportTemplateColumns(file, sheet); err != nil {
return err
}
if err := setExpenseReportTemplateHeaders(file, sheet); err != nil {
return err
}
return setExpenseReportTemplateRows(file, sheet, items)
}
func setExpenseReportTemplateColumns(file *excelize.File, sheet string) error {
columnWidths := map[string]float64{
"A": 5.83203125,
"B": 20.83203125,
"C": 20.83203125,
"D": 15.83203125,
"E": 15.83203125,
"F": 15.83203125,
"G": 30.83203125,
"H": 20.83203125,
"I": 15.83203125,
"J": 15.83203125,
"K": 15.83203125,
"L": 20.83203125,
"M": 15.83203125,
"N": 20.83203125,
}
for col, width := range columnWidths {
if err := file.SetColWidth(sheet, col, col, width); err != nil {
return err
}
}
return nil
}
func setExpenseReportTemplateHeaders(file *excelize.File, sheet string) error {
headers := []string{
"No",
"No. PO",
"No. Referensi",
"Tanggal Realisasi",
"Tanggal Transaksi",
"Kategori",
"Produk",
"Lokasi",
"Kandang",
"Qty Pengajuan",
"Harga Pengajuan",
"Total Pengajuan",
"Qty Realisasi",
"Harga Realisasi",
"Total Realisasi",
"Status Pencairan",
}
for i, header := range headers {
columnName, err := excelize.ColumnNumberToName(i + 1)
if err != nil {
return err
}
if err := file.SetCellValue(sheet, columnName+"1", header); err != nil {
return err
}
}
return nil
}
func setExpenseReportTemplateRows(file *excelize.File, sheet string, items []dto.RepportExpenseListDTO) error {
totalQtyPengajuan := 0.0
totalPengajuan := 0.0
totalQtyRealisasi := 0.0
totalRealisasi := 0.0
for idx, item := range items {
row := idx + 2
rowString := strconv.Itoa(row)
produk := resolveExpenseReportProduct(item)
status := formatExpenseReportStatus(item)
lokasi := resolveExpenseReportLocation(item)
kandang := resolveExpenseReportKandang(item)
if err := file.SetCellValue(sheet, "A"+rowString, idx+1); err != nil {
return err
}
if err := file.SetCellValue(sheet, "B"+rowString, safeExpenseReportExportText(item.PoNumber)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "C"+rowString, safeExpenseReportExportText(item.ReferenceNumber)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "D"+rowString, formatExpenseReportOptionalDate(item.RealizationDate)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "E"+rowString, formatExpenseReportDate(item.TransactionDate)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "F"+rowString, safeExpenseReportExportText(item.Category)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "G"+rowString, produk); err != nil {
return err
}
if err := file.SetCellValue(sheet, "H"+rowString, lokasi); err != nil {
return err
}
if err := file.SetCellValue(sheet, "I"+rowString, kandang); err != nil {
return err
}
if err := file.SetCellValue(sheet, "J"+rowString, item.Pengajuan.Qty); err != nil {
return err
}
if err := file.SetCellValue(sheet, "K"+rowString, item.Pengajuan.Price); err != nil {
return err
}
if err := file.SetCellValue(sheet, "L"+rowString, item.TotalPengajuan); err != nil {
return err
}
if err := file.SetCellValue(sheet, "M"+rowString, item.Realisasi.Qty); err != nil {
return err
}
if err := file.SetCellValue(sheet, "N"+rowString, item.Realisasi.Price); err != nil {
return err
}
if err := file.SetCellValue(sheet, "O"+rowString, item.TotalRealisasi); err != nil {
return err
}
if err := file.SetCellValue(sheet, "P"+rowString, status); err != nil {
return err
}
totalQtyPengajuan += item.Pengajuan.Qty
totalPengajuan += item.TotalPengajuan
totalQtyRealisasi += item.Realisasi.Qty
totalRealisasi += item.TotalRealisasi
}
totalRow := strconv.Itoa(len(items) + 2)
if err := file.SetCellValue(sheet, "A"+totalRow, "Total"); err != nil {
return err
}
if err := file.SetCellValue(sheet, "J"+totalRow, totalQtyPengajuan); err != nil {
return err
}
if err := file.SetCellValue(sheet, "K"+totalRow, 0); err != nil {
return err
}
if err := file.SetCellValue(sheet, "L"+totalRow, totalPengajuan); err != nil {
return err
}
if err := file.SetCellValue(sheet, "M"+totalRow, totalQtyRealisasi); err != nil {
return err
}
if err := file.SetCellValue(sheet, "N"+totalRow, 0); err != nil {
return err
}
if err := file.SetCellValue(sheet, "O"+totalRow, totalRealisasi); err != nil {
return err
}
return nil
}
func resolveExpenseReportProduct(item dto.RepportExpenseListDTO) string {
if item.Realisasi.Nonstock != nil {
name := strings.TrimSpace(item.Realisasi.Nonstock.Name)
if name != "" {
return name
}
}
if item.Pengajuan.Nonstock != nil {
name := strings.TrimSpace(item.Pengajuan.Nonstock.Name)
if name != "" {
return name
}
}
return "-"
}
func resolveExpenseReportLocation(item dto.RepportExpenseListDTO) string {
if item.Kandang != nil && item.Kandang.Location != nil {
name := strings.TrimSpace(item.Kandang.Location.Name)
if name != "" {
return name
}
}
return "-"
}
func resolveExpenseReportKandang(item dto.RepportExpenseListDTO) string {
if item.Kandang != nil {
name := strings.TrimSpace(item.Kandang.Name)
if name != "" {
return name
}
}
return "-"
}
func formatExpenseReportStatus(item dto.RepportExpenseListDTO) string {
if item.LatestApproval == nil {
return "-"
}
if item.LatestApproval.Action != nil &&
strings.EqualFold(strings.TrimSpace(*item.LatestApproval.Action), string(entity.ApprovalActionRejected)) {
return "Ditolak"
}
stepName := strings.TrimSpace(item.LatestApproval.StepName)
if stepName == "" {
return "-"
}
return stepName
}
func formatExpenseReportDate(value time.Time) string {
if value.IsZero() {
return "-"
}
location, err := time.LoadLocation("Asia/Jakarta")
if err == nil {
value = value.In(location)
}
return value.Format("02 Jan 2006")
}
func formatExpenseReportOptionalDate(value *time.Time) string {
if value == nil || value.IsZero() {
return "-"
}
return formatExpenseReportDate(*value)
}
func safeExpenseReportExportText(value string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return "-"
}
return trimmed
}
@@ -0,0 +1,281 @@
package controller
import (
"bytes"
"reflect"
"strings"
"testing"
"time"
approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto"
kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto"
locationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto"
nonstockDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/dto"
supplierDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/dto"
"gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto"
"github.com/xuri/excelize/v2"
)
func TestBuildExpenseReportExportWorkbookHeadersAndRows(t *testing.T) {
realizationDate := time.Date(2026, time.April, 23, 0, 0, 0, 0, time.UTC)
items := []dto.RepportExpenseListDTO{
buildExpenseExportTestItem(
"REF-0001",
"PO-0001",
"BOP",
"UPAH",
"Darawati",
"Darawati C1",
time.Date(2026, time.April, 22, 0, 0, 0, 0, time.UTC),
&realizationDate,
2,
10000,
20000,
2,
9000,
18000,
"Realisasi",
nil,
),
buildExpenseExportTestItem(
"REF-0002",
"PO-0002",
"BOP",
"TRANSPORT 2",
"",
"",
time.Date(2026, time.April, 22, 0, 0, 0, 0, time.UTC),
&realizationDate,
1,
50000,
50000,
1,
50000,
50000,
"Pengajuan",
strPtr("REJECTED"),
),
buildExpenseExportTestItem(
"REF-0003",
"PO-0003",
"BOP",
"TRANSPORT",
"Jamali",
"Jamali 1",
time.Date(2026, time.April, 22, 0, 0, 0, 0, time.UTC),
&realizationDate,
3,
12000,
36000,
2,
11000,
22000,
"Selesai",
nil,
),
buildExpenseExportTestItem(
"REF-0004",
"PO-0004",
"BOP",
"TRANSPORT",
"Jamali",
"Jamali 2",
time.Date(2026, time.April, 22, 0, 0, 0, 0, time.UTC),
&realizationDate,
1,
8000,
8000,
1,
8000,
8000,
"Realisasi",
nil,
),
buildExpenseExportTestItem(
"REF-0005",
"PO-0005",
"BOP",
"ZZZ CUSTOM",
"",
"",
time.Date(2026, time.April, 22, 0, 0, 0, 0, time.UTC),
nil,
1,
7000,
7000,
1,
7000,
7000,
"",
nil,
),
}
content, err := buildExpenseReportExportWorkbook(items)
if err != nil {
t.Fatalf("buildExpenseReportExportWorkbook returned error: %v", err)
}
file, err := excelize.OpenReader(bytes.NewReader(content))
if err != nil {
t.Fatalf("failed to open workbook bytes: %v", err)
}
defer file.Close()
expectedSheetOrder := []string{"UPAH", "EKSPEDISI ADE", "EKSPEDISI LTI", "ZZZ CUSTOM"}
if got := file.GetSheetList(); !reflect.DeepEqual(got, expectedSheetOrder) {
t.Fatalf("unexpected sheet order: got %v expected %v", got, expectedSheetOrder)
}
expectedHeaders := map[string]string{
"A1": "No",
"B1": "No. PO",
"C1": "No. Referensi",
"D1": "Tanggal Realisasi",
"E1": "Tanggal Transaksi",
"F1": "Kategori",
"G1": "Produk",
"H1": "Lokasi",
"I1": "Kandang",
"J1": "Qty Pengajuan",
"K1": "Harga Pengajuan",
"L1": "Total Pengajuan",
"M1": "Qty Realisasi",
"N1": "Harga Realisasi",
"O1": "Total Realisasi",
"P1": "Status Pencairan",
}
for cell, expected := range expectedHeaders {
assertExpenseSheetCellEquals(t, file, "UPAH", cell, expected)
}
assertExpenseSheetCellEquals(t, file, "UPAH", "A2", "1")
assertExpenseSheetCellEquals(t, file, "UPAH", "B2", "PO-0001")
assertExpenseSheetCellEquals(t, file, "UPAH", "C2", "REF-0001")
assertExpenseSheetCellEquals(t, file, "UPAH", "D2", "23 Apr 2026")
assertExpenseSheetCellEquals(t, file, "UPAH", "E2", "22 Apr 2026")
assertExpenseSheetCellEquals(t, file, "UPAH", "F2", "BOP")
assertExpenseSheetCellEquals(t, file, "UPAH", "G2", "UPAH")
assertExpenseSheetCellEquals(t, file, "UPAH", "H2", "Darawati")
assertExpenseSheetCellEquals(t, file, "UPAH", "I2", "Darawati C1")
assertExpenseSheetCellEquals(t, file, "UPAH", "J2", "2")
assertExpenseSheetCellEquals(t, file, "UPAH", "K2", "10000")
assertExpenseSheetCellEquals(t, file, "UPAH", "L2", "20000")
assertExpenseSheetCellEquals(t, file, "UPAH", "M2", "2")
assertExpenseSheetCellEquals(t, file, "UPAH", "N2", "9000")
assertExpenseSheetCellEquals(t, file, "UPAH", "O2", "18000")
assertExpenseSheetCellEquals(t, file, "UPAH", "P2", "Realisasi")
assertExpenseSheetCellEquals(t, file, "UPAH", "A3", "Total")
assertExpenseSheetCellEquals(t, file, "UPAH", "J3", "2")
assertExpenseSheetCellEquals(t, file, "UPAH", "K3", "0")
assertExpenseSheetCellEquals(t, file, "UPAH", "L3", "20000")
assertExpenseSheetCellEquals(t, file, "UPAH", "M3", "2")
assertExpenseSheetCellEquals(t, file, "UPAH", "N3", "0")
assertExpenseSheetCellEquals(t, file, "UPAH", "O3", "18000")
assertExpenseSheetCellEquals(t, file, "EKSPEDISI ADE", "G2", "TRANSPORT 2")
assertExpenseSheetCellEquals(t, file, "EKSPEDISI ADE", "P2", "Ditolak")
assertExpenseSheetCellEquals(t, file, "EKSPEDISI ADE", "A3", "Total")
assertExpenseSheetCellEquals(t, file, "EKSPEDISI LTI", "A2", "1")
assertExpenseSheetCellEquals(t, file, "EKSPEDISI LTI", "A3", "2")
assertExpenseSheetCellEquals(t, file, "EKSPEDISI LTI", "G2", "TRANSPORT")
assertExpenseSheetCellEquals(t, file, "EKSPEDISI LTI", "G3", "TRANSPORT")
assertExpenseSheetCellEquals(t, file, "EKSPEDISI LTI", "A4", "Total")
assertExpenseSheetCellEquals(t, file, "EKSPEDISI LTI", "J4", "4")
assertExpenseSheetCellEquals(t, file, "EKSPEDISI LTI", "L4", "44000")
assertExpenseSheetCellEquals(t, file, "EKSPEDISI LTI", "M4", "3")
assertExpenseSheetCellEquals(t, file, "EKSPEDISI LTI", "O4", "30000")
assertExpenseSheetCellEquals(t, file, "ZZZ CUSTOM", "H2", "-")
assertExpenseSheetCellEquals(t, file, "ZZZ CUSTOM", "I2", "-")
assertExpenseSheetCellEquals(t, file, "ZZZ CUSTOM", "D2", "-")
assertExpenseSheetCellEquals(t, file, "ZZZ CUSTOM", "P2", "-")
for _, cell := range []string{"K2", "L2", "N2", "O2"} {
val, err := file.GetCellValue("UPAH", cell)
if err != nil {
t.Fatalf("GetCellValue(UPAH,%s) failed: %v", cell, err)
}
if strings.Contains(val, "Rp") {
t.Fatalf("expected numeric plain value in %s, got %q", cell, val)
}
}
}
func assertExpenseSheetCellEquals(t *testing.T, file *excelize.File, sheet, cell, expected string) {
t.Helper()
got, err := file.GetCellValue(sheet, cell)
if err != nil {
t.Fatalf("GetCellValue(%s,%s) failed: %v", sheet, cell, err)
}
if got != expected {
t.Fatalf("expected %s!%s=%q, got %q", sheet, cell, expected, got)
}
}
func buildExpenseExportTestItem(
reference,
poNumber,
category,
product,
location,
kandang string,
transactionDate time.Time,
realizationDate *time.Time,
qtyPengajuan,
hargaPengajuan,
totalPengajuan,
qtyRealisasi,
hargaRealisasi,
totalRealisasi float64,
stepName string,
action *string,
) dto.RepportExpenseListDTO {
item := dto.RepportExpenseListDTO{
RepportExpenseBaseDTO: dto.RepportExpenseBaseDTO{
ReferenceNumber: reference,
PoNumber: poNumber,
Category: category,
TransactionDate: transactionDate,
RealizationDate: realizationDate,
Supplier: &supplierDTO.SupplierRelationDTO{
Name: "Supplier A",
},
},
Pengajuan: dto.RepportExpensePengajuanDTO{
Qty: qtyPengajuan,
Price: hargaPengajuan,
Nonstock: &nonstockDTO.NonstockRelationDTO{
Name: product,
},
},
Realisasi: dto.RepportExpenseRealisasiDTO{
Qty: qtyRealisasi,
Price: hargaRealisasi,
Nonstock: &nonstockDTO.NonstockRelationDTO{
Name: product,
},
},
TotalPengajuan: totalPengajuan,
TotalRealisasi: totalRealisasi,
LatestApproval: &approvalDTO.ApprovalRelationDTO{
StepName: stepName,
Action: action,
},
}
if kandang != "" {
item.Kandang = &kandangDTO.KandangRelationDTO{Name: kandang}
if location != "" {
item.Kandang.Location = &locationDTO.LocationRelationDTO{Name: location}
}
}
return item
}
func strPtr(value string) *string {
return &value
}
@@ -17,6 +17,7 @@ type RepportExpenseBaseDTO struct {
ReferenceNumber string `json:"reference_number"` ReferenceNumber string `json:"reference_number"`
PoNumber string `json:"po_number"` PoNumber string `json:"po_number"`
Category string `json:"category"` Category string `json:"category"`
Notes string `json:"notes"`
Supplier *supplierDTO.SupplierRelationDTO `json:"supplier,omitempty"` Supplier *supplierDTO.SupplierRelationDTO `json:"supplier,omitempty"`
RealizationDate *time.Time `json:"realization_date,omitempty"` RealizationDate *time.Time `json:"realization_date,omitempty"`
TransactionDate time.Time `json:"transaction_date"` TransactionDate time.Time `json:"transaction_date"`
@@ -74,6 +75,7 @@ func ToRepportExpenseBaseDTO(e *entity.Expense) RepportExpenseBaseDTO {
ReferenceNumber: e.ReferenceNumber, ReferenceNumber: e.ReferenceNumber,
PoNumber: e.PoNumber, PoNumber: e.PoNumber,
Category: e.Category, Category: e.Category,
Notes: e.Notes,
Supplier: supplier, Supplier: supplier,
RealizationDate: realizationDate, RealizationDate: realizationDate,
TransactionDate: e.TransactionDate, TransactionDate: e.TransactionDate,