From ded8be198a1f89bd850d3516796012c775b83022 Mon Sep 17 00:00:00 2001 From: giovanni Date: Tue, 21 Apr 2026 19:30:03 +0700 Subject: [PATCH 1/6] fix perhitunga sapronak --- .../closings/dto/closingSapronak.dto.go | 27 +++- .../closings/dto/closingSapronak.dto_test.go | 124 ++++++++++++++++++ .../repositories/closing.repository.go | 37 ++++-- .../repositories/closing.repository_test.go | 77 ++++++++++- .../closings/services/closing.service.go | 60 +++++---- .../closings/services/closing.service_test.go | 94 +++++++++++++ .../closings/services/sapronak.service.go | 4 +- .../services/sapronak.service_test.go | 45 +++++++ 8 files changed, 433 insertions(+), 35 deletions(-) create mode 100644 internal/modules/closings/dto/closingSapronak.dto_test.go create mode 100644 internal/modules/closings/services/closing.service_test.go create mode 100644 internal/modules/closings/services/sapronak.service_test.go diff --git a/internal/modules/closings/dto/closingSapronak.dto.go b/internal/modules/closings/dto/closingSapronak.dto.go index 4c5db68d..a169a816 100644 --- a/internal/modules/closings/dto/closingSapronak.dto.go +++ b/internal/modules/closings/dto/closingSapronak.dto.go @@ -153,6 +153,20 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin } 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) byFlag := map[string]**SapronakCategoryDTO{} @@ -258,6 +272,7 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin UnitPrice: item.Harga, Notes: "-", } + isCutOver := containsCutOver(baseRow.ProductCategory, baseRow.Description, item.ProductName) row := getOrCreateRow(productKey, baseRow) @@ -289,11 +304,21 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin } row.QtyUsed += item.QtyKeluar row.TotalAmount += item.QtyKeluar * price - case "adjustment keluar", "mutasi keluar", "penjualan": + case "adjustment keluar": price := row.UnitPrice if price == 0 { 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 if strings.ToLower(item.JenisTransaksi) == "mutasi keluar" { ref := strings.ToUpper(strings.TrimSpace(item.NoReferensi)) diff --git a/internal/modules/closings/dto/closingSapronak.dto_test.go b/internal/modules/closings/dto/closingSapronak.dto_test.go new file mode 100644 index 00000000..0aabe5a3 --- /dev/null +++ b/internal/modules/closings/dto/closingSapronak.dto_test.go @@ -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) + } +} diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go index b6d90a63..79047411 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -1576,11 +1576,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) { poByWarehouse := r.DB(). - Table("purchase_items pi"). - Select("DISTINCT ON (pi.product_warehouse_id) pi.product_warehouse_id, po.po_number, pi.received_date"). - Joins("JOIN purchases po ON po.id = pi.purchase_id"). - Where("pi.received_date IS NOT NULL"). - Order("pi.product_warehouse_id, pi.received_date ASC") + Table("(?) AS ranked_po", + r.DB(). + Table("purchase_items pi"). + Select(` + 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). Table("adjustment_stocks AS ast"). @@ -1589,10 +1598,10 @@ func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, ka p.name AS product_name, f.name AS flag, 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, 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 warehouses w ON w.id = pw.warehouse_id"). @@ -1615,10 +1624,18 @@ func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, ka p.name AS product_name, 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(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, 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("LEFT JOIN purchase_items pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyPurchaseItems.String()). @@ -1639,7 +1656,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, ka Where("w.kandang_id = ?", kandangID). Where("f.name IN ?", sapronakFlagsAll). 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 = applyDateRange(outgoingQuery, dateExpr, start, end) outgoing, err := scanAndGroupDetails(outgoingQuery) diff --git a/internal/modules/closings/repositories/closing.repository_test.go b/internal/modules/closings/repositories/closing.repository_test.go index 362dbef2..b3710475 100644 --- a/internal/modules/closings/repositories/closing.repository_test.go +++ b/internal/modules/closings/repositories/closing.repository_test.go @@ -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 { t.Helper() @@ -134,6 +191,7 @@ func setupClosingRepositoryTestDB(t *testing.T) *gorm.DB { purchase_id INTEGER NOT NULL, product_id INTEGER NOT NULL, warehouse_id INTEGER NOT NULL, + product_warehouse_id INTEGER NULL, project_flock_kandang_id INTEGER NULL, total_qty NUMERIC(15,3) NOT NULL DEFAULT 0, price NUMERIC(15,3) NOT NULL DEFAULT 0, @@ -152,7 +210,13 @@ func setupClosingRepositoryTestDB(t *testing.T) *gorm.DB { )`, `CREATE TABLE project_chickins ( 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 ( id INTEGER PRIMARY KEY, @@ -179,6 +243,16 @@ func setupClosingRepositoryTestDB(t *testing.T) *gorm.DB { movement_number 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 ( id INTEGER PRIMARY KEY, stock_transfer_id INTEGER NOT NULL, @@ -193,6 +267,7 @@ func setupClosingRepositoryTestDB(t *testing.T) *gorm.DB { product_warehouse_id INTEGER NOT NULL, total_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, created_at TIMESTAMP NULL )`, diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index 360a39f9..91a8c7d4 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -573,17 +573,21 @@ func (s closingService) getSapronakDateRange(ctx context.Context, projectFlockID 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"). - Select("MIN(chick_in_date)"). - Where("project_flock_kandang_id = ?", pfk.Id). - Scan(&minChickin).Error; err != nil { + Select("chick_in_date"). + Where("project_flock_kandang_id = ? AND chick_in_date IS NOT NULL", pfk.Id). + Order("chick_in_date ASC"). + Limit(1). + Scan(&firstChickin).Error; err != nil { return nil, nil, err } start := pfk.CreatedAt - if minChickin != nil && !minChickin.IsZero() { - start = *minChickin + if !firstChickin.ChickInDate.IsZero() { + start = firstChickin.ChickInDate } startDate := dateOnlyUTC(start) @@ -596,26 +600,34 @@ func (s closingService) getSapronakDateRange(ctx context.Context, projectFlockID return &startDate, endDate, nil } - var minCreated time.Time + var firstPFK entity.ProjectFlockKandang if err := db.Model(&entity.ProjectFlockKandang{}). - Select("MIN(created_at)"). + Select("created_at"). 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 } - var minChickin *time.Time + var firstChickin struct { + ChickInDate time.Time `gorm:"column:chick_in_date"` + } 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"). - Where("pfk.project_flock_id = ?", projectFlockID). - Scan(&minChickin).Error; err != nil { + Where("pfk.project_flock_id = ? AND pc.chick_in_date IS NOT NULL", projectFlockID). + Order("pc.chick_in_date ASC"). + Limit(1). + Scan(&firstChickin).Error; err != nil { return nil, nil, err } - start := minCreated - if minChickin != nil && !minChickin.IsZero() { - start = *minChickin + start := firstPFK.CreatedAt + if !firstChickin.ChickInDate.IsZero() { + start = firstChickin.ChickInDate } startDate := dateOnlyUTC(start) @@ -627,15 +639,19 @@ func (s closingService) getSapronakDateRange(ctx context.Context, projectFlockID return nil, nil, err } if openCount == 0 { - var maxClosed *time.Time + var latestClosed entity.ProjectFlockKandang if err := db.Model(&entity.ProjectFlockKandang{}). - Select("MAX(closed_at)"). - Where("project_flock_id = ?", projectFlockID). - Scan(&maxClosed).Error; err != nil { + Select("closed_at"). + Where("project_flock_id = ? AND closed_at IS NOT NULL", projectFlockID). + Order("closed_at DESC"). + First(&latestClosed).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return &startDate, nil, nil + } return nil, nil, err } - if maxClosed != nil && !maxClosed.IsZero() { - d := dateOnlyUTC(*maxClosed) + if latestClosed.ClosedAt != nil && !latestClosed.ClosedAt.IsZero() { + d := dateOnlyUTC(*latestClosed.ClosedAt) endDate = &d } } diff --git a/internal/modules/closings/services/closing.service_test.go b/internal/modules/closings/services/closing.service_test.go new file mode 100644 index 00000000..bf05b08e --- /dev/null +++ b/internal/modules/closings/services/closing.service_test.go @@ -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 +} diff --git a/internal/modules/closings/services/sapronak.service.go b/internal/modules/closings/services/sapronak.service.go index f548820a..6eb8a015 100644 --- a/internal/modules/closings/services/sapronak.service.go +++ b/internal/modules/closings/services/sapronak.service.go @@ -371,7 +371,9 @@ func buildSapronakDetails( addRows(result.Incoming, incomingRows, "Pembelian", true) addRows(result.Usage, usageRows, "Pemakaian", false) 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.TransferOut, transferOutRows, "Mutasi Keluar", false) addRows(result.SalesOut, salesOutRows, "Penjualan", false) diff --git a/internal/modules/closings/services/sapronak.service_test.go b/internal/modules/closings/services/sapronak.service_test.go new file mode 100644 index 00000000..ce348658 --- /dev/null +++ b/internal/modules/closings/services/sapronak.service_test.go @@ -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) + } +} From 91d51bf1b893343520183fc68a1a42238b8414f9 Mon Sep 17 00:00:00 2001 From: giovanni Date: Wed, 22 Apr 2026 16:24:31 +0700 Subject: [PATCH 2/6] adjust softdelete daily checklist; add empty kandang --- ...d_soft_delete_to_daily_checklists.down.sql | 21 ++ ...add_soft_delete_to_daily_checklists.up.sql | 27 +++ ...dd_empty_kandang_to_category_code.down.sql | 41 ++++ ..._add_empty_kandang_to_category_code.up.sql | 12 + internal/entities/daily-checklist.go | 13 +- .../daily-checklist.repository.go | 3 +- .../services/daily-checklist.service.go | 223 +++++++++++++----- .../validations/daily-checklist.validation.go | 10 +- 8 files changed, 287 insertions(+), 63 deletions(-) create mode 100644 internal/database/migrations/20260422091116_add_soft_delete_to_daily_checklists.down.sql create mode 100644 internal/database/migrations/20260422091116_add_soft_delete_to_daily_checklists.up.sql create mode 100644 internal/database/migrations/20260422091206_add_empty_kandang_to_category_code.down.sql create mode 100644 internal/database/migrations/20260422091206_add_empty_kandang_to_category_code.up.sql diff --git a/internal/database/migrations/20260422091116_add_soft_delete_to_daily_checklists.down.sql b/internal/database/migrations/20260422091116_add_soft_delete_to_daily_checklists.down.sql new file mode 100644 index 00000000..ce49c3f2 --- /dev/null +++ b/internal/database/migrations/20260422091116_add_soft_delete_to_daily_checklists.down.sql @@ -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; diff --git a/internal/database/migrations/20260422091116_add_soft_delete_to_daily_checklists.up.sql b/internal/database/migrations/20260422091116_add_soft_delete_to_daily_checklists.up.sql new file mode 100644 index 00000000..d3f000f0 --- /dev/null +++ b/internal/database/migrations/20260422091116_add_soft_delete_to_daily_checklists.up.sql @@ -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; diff --git a/internal/database/migrations/20260422091206_add_empty_kandang_to_category_code.down.sql b/internal/database/migrations/20260422091206_add_empty_kandang_to_category_code.down.sql new file mode 100644 index 00000000..07702864 --- /dev/null +++ b/internal/database/migrations/20260422091206_add_empty_kandang_to_category_code.down.sql @@ -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; diff --git a/internal/database/migrations/20260422091206_add_empty_kandang_to_category_code.up.sql b/internal/database/migrations/20260422091206_add_empty_kandang_to_category_code.up.sql new file mode 100644 index 00000000..34c94833 --- /dev/null +++ b/internal/database/migrations/20260422091206_add_empty_kandang_to_category_code.up.sql @@ -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 $$; diff --git a/internal/entities/daily-checklist.go b/internal/entities/daily-checklist.go index 71513259..6c2106ae 100644 --- a/internal/entities/daily-checklist.go +++ b/internal/entities/daily-checklist.go @@ -1,6 +1,10 @@ package entities -import "time" +import ( + "time" + + "gorm.io/gorm" +) type DailyChecklist struct { Id uint `gorm:"primaryKey"` @@ -14,12 +18,15 @@ type DailyChecklist struct { DocumentPath *string RejectReason *string CreatedBy *uint - CreatedAt time.Time `gorm:"autoCreateTime"` - UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedBy *uint + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` Kandang KandangGroup `gorm:"foreignKey:KandangId;references:Id"` Checklist *Checklist `gorm:"foreignKey:ChecklistId;references:Id"` Creator *User `gorm:"foreignKey:CreatedBy;references:Id"` + Deleter *User `gorm:"foreignKey:DeletedBy;references:Id"` Tasks []DailyChecklistTask `gorm:"foreignKey:DailyChecklistId;references:Id"` } diff --git a/internal/modules/daily-checklists/repositories/daily-checklist.repository.go b/internal/modules/daily-checklists/repositories/daily-checklist.repository.go index 8d664d31..1e232401 100644 --- a/internal/modules/daily-checklists/repositories/daily-checklist.repository.go +++ b/internal/modules/daily-checklists/repositories/daily-checklist.repository.go @@ -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 locations loc ON loc.id = k.location_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") if err != nil { diff --git a/internal/modules/daily-checklists/services/daily-checklist.service.go b/internal/modules/daily-checklists/services/daily-checklist.service.go index 5fc8c0bc..3ae7de26 100644 --- a/internal/modules/daily-checklists/services/daily-checklist.service.go +++ b/internal/modules/daily-checklists/services/daily-checklist.service.go @@ -122,6 +122,13 @@ type DailyChecklistReportCategory struct { 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 { return &dailyChecklistService{ 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 locations loc ON loc.id = k.location_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") if err != nil { @@ -196,7 +204,7 @@ func (s dailyChecklistService) ensureTaskAccess(c *fiber.Ctx, taskID uint) error db := s.Repository.DB().WithContext(c.Context()). 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 locations loc ON loc.id = k.location_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"). Joins("JOIN kandang_groups k ON k.id = dc.kandang_id"). Joins("JOIN locations loc ON loc.id = k.location_id"). - Joins("JOIN areas a ON a.id = loc.area_id") + Joins("JOIN areas a ON a.id = loc.area_id"). + Where("dc.deleted_at IS NULL") var scopeErr error 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 } - date, err := time.Parse("2006-01-02", req.Date) + date, err := time.Parse(dailyChecklistDateLayout, strings.TrimSpace(req.Date)) if err != nil { return nil, fiber.NewError(fiber.StatusBadRequest, "invalid date format, use YYYY-MM-DD") } status := req.Status 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) err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) 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 <> ?)", date, req.KandangId, category, "REJECTED"). - Take(existing).Error - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return err + if req.EmptyKandang { + return s.createBulkDailyChecklists(tx, req.KandangId, date, endDate, category, status, &targetID) } - 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 = ?", 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 + return s.createOrReuseSingleDailyChecklist(tx, req.KandangId, date, category, status, &targetID) }) if err != nil { 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) } +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) { if err := s.Validate.Struct(req); err != nil { 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 { 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) { 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, MAX(a.updated_at) AS last_activity`). 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 employees e ON e.id = a.employee_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()). Table("daily_checklist_activity_task_assignments AS dca"). 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 kandang_groups k ON k.id = dc.kandang_id"). Joins("JOIN locations loc ON loc.id = k.location_id"). diff --git a/internal/modules/daily-checklists/validations/daily-checklist.validation.go b/internal/modules/daily-checklists/validations/daily-checklist.validation.go index e3a5484c..3738a58d 100644 --- a/internal/modules/daily-checklists/validations/daily-checklist.validation.go +++ b/internal/modules/daily-checklists/validations/daily-checklist.validation.go @@ -5,10 +5,12 @@ import ( ) type Create struct { - Date string `json:"date" validate:"required"` - KandangId uint `json:"kandang_id" validate:"required"` - Category string `json:"category" validate:"required"` - Status string `json:"status" validate:"required"` + Date string `json:"date" validate:"required"` + KandangId uint `json:"kandang_id" validate:"required"` + Category string `json:"category" validate:"required"` + Status string `json:"status" validate:"required"` + EmptyKandang bool `json:"empty_kandang"` + EmptyKandangEndDate string `json:"empty_kandang_end_date"` } type Update struct { From ff630a1ed02e5335b21938d83daf8fe093606a40 Mon Sep 17 00:00:00 2001 From: giovanni Date: Wed, 22 Apr 2026 19:22:29 +0700 Subject: [PATCH 3/6] add export po and marketing --- .../controllers/deliveryorder.controller.go | 80 +++-- .../deliveryorder.controller_test.go | 181 ++++++++++ .../controllers/deliveryorder.export.go | 310 ++++++++++++++++ .../controllers/deliveryorder.export_test.go | 125 +++++++ .../controllers/purchase.controller.go | 70 +++- .../controllers/purchase.controller_test.go | 203 +++++++++++ .../purchases/controllers/purchase.export.go | 334 ++++++++++++++++++ .../controllers/purchase.export_test.go | 157 ++++++++ 8 files changed, 1426 insertions(+), 34 deletions(-) create mode 100644 internal/modules/marketing/controllers/deliveryorder.controller_test.go create mode 100644 internal/modules/marketing/controllers/deliveryorder.export.go create mode 100644 internal/modules/marketing/controllers/deliveryorder.export_test.go create mode 100644 internal/modules/purchases/controllers/purchase.controller_test.go create mode 100644 internal/modules/purchases/controllers/purchase.export.go create mode 100644 internal/modules/purchases/controllers/purchase.export_test.go diff --git a/internal/modules/marketing/controllers/deliveryorder.controller.go b/internal/modules/marketing/controllers/deliveryorder.controller.go index 04323bd9..39ab38eb 100644 --- a/internal/modules/marketing/controllers/deliveryorder.controller.go +++ b/internal/modules/marketing/controllers/deliveryorder.controller.go @@ -23,6 +23,8 @@ type DeliveryOrdersController struct { DeliveryOrdersService service.DeliveryOrdersService } +const marketingExcelExportFetchLimit = 100 + func NewDeliveryOrdersController(deliveryOrdersService service.DeliveryOrdersService) *DeliveryOrdersController { return &DeliveryOrdersController{ DeliveryOrdersService: deliveryOrdersService, @@ -49,26 +51,6 @@ func (u *DeliveryOrdersController) GetAll(c *fiber.Ctx) error { 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", "")) if err != nil { return fiber.NewError(fiber.StatusBadRequest, "Invalid product_ids") @@ -84,6 +66,14 @@ func (u *DeliveryOrdersController) GetAll(c *fiber.Ctx) error { MarketingId: uint(c.QueryInt("marketing_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 { return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") } @@ -108,6 +98,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 { param := c.Params("id") diff --git a/internal/modules/marketing/controllers/deliveryorder.controller_test.go b/internal/modules/marketing/controllers/deliveryorder.controller_test.go new file mode 100644 index 00000000..0ef40b12 --- /dev/null +++ b/internal/modules/marketing/controllers/deliveryorder.controller_test.go @@ -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", + }, + } +} diff --git a/internal/modules/marketing/controllers/deliveryorder.export.go b/internal/modules/marketing/controllers/deliveryorder.export.go new file mode 100644 index 00000000..48751929 --- /dev/null +++ b/internal/modules/marketing/controllers/deliveryorder.export.go @@ -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 +} diff --git a/internal/modules/marketing/controllers/deliveryorder.export_test.go b/internal/modules/marketing/controllers/deliveryorder.export_test.go new file mode 100644 index 00000000..d41a3f6e --- /dev/null +++ b/internal/modules/marketing/controllers/deliveryorder.export_test.go @@ -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 +} diff --git a/internal/modules/purchases/controllers/purchase.controller.go b/internal/modules/purchases/controllers/purchase.controller.go index c252e65e..1616acbf 100644 --- a/internal/modules/purchases/controllers/purchase.controller.go +++ b/internal/modules/purchases/controllers/purchase.controller.go @@ -10,6 +10,7 @@ import ( "time" "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" service "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/services" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/validations" @@ -24,6 +25,8 @@ type PurchaseController struct { service service.PurchaseService } +const purchaseExcelExportFetchLimit = 100 + func NewPurchaseController(s service.PurchaseService) *PurchaseController { return &PurchaseController{service: s} } @@ -48,20 +51,14 @@ func (ctrl *PurchaseController) GetAll(c *fiber.Ctx) error { return c.Status(fiber.StatusOK).Send(content) } - query := &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)), - ProductCategoryID: strings.TrimSpace(c.Query("product_category_id")), + query := buildPurchaseQuery(c) + + if isAllPurchaseExcelExportRequest(c) { + results, err := ctrl.getAllPurchasesForExcel(c, query) + if err != nil { + return err + } + return exportPurchaseListExcel(c, results) } if query.Page < 1 || query.Limit < 1 { @@ -88,6 +85,51 @@ 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)), + 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 { param := c.Params("id") diff --git a/internal/modules/purchases/controllers/purchase.controller_test.go b/internal/modules/purchases/controllers/purchase.controller_test.go new file mode 100644 index 00000000..26fce9c2 --- /dev/null +++ b/internal/modules/purchases/controllers/purchase.controller_test.go @@ -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: ¬es, + 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", + }, + }, + }, + } +} diff --git a/internal/modules/purchases/controllers/purchase.export.go b/internal/modules/purchases/controllers/purchase.export.go new file mode 100644 index 00000000..299df22b --- /dev/null +++ b/internal/modules/purchases/controllers/purchase.export.go @@ -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() +} diff --git a/internal/modules/purchases/controllers/purchase.export_test.go b/internal/modules/purchases/controllers/purchase.export_test.go new file mode 100644 index 00000000..c440c3e4 --- /dev/null +++ b/internal/modules/purchases/controllers/purchase.export_test.go @@ -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 = ¬es + } + + 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 +} From 3e99caf3a7dbb65cf81f12be5d9bc81b719b21ba Mon Sep 17 00:00:00 2001 From: giovanni Date: Wed, 22 Apr 2026 22:50:20 +0700 Subject: [PATCH 4/6] add export excel from api --- .../controllers/repport.controller.go | 37 ++ .../controllers/repport.controller_test.go | 202 +++++++++ .../repports/controllers/repport.export.go | 424 ++++++++++++++++++ .../controllers/repport.export_test.go | 281 ++++++++++++ .../repports/dto/repportExpense.dto.go | 2 + 5 files changed, 946 insertions(+) create mode 100644 internal/modules/repports/controllers/repport.controller_test.go create mode 100644 internal/modules/repports/controllers/repport.export.go create mode 100644 internal/modules/repports/controllers/repport.export_test.go diff --git a/internal/modules/repports/controllers/repport.controller.go b/internal/modules/repports/controllers/repport.controller.go index 691cafc0..a5de422f 100644 --- a/internal/modules/repports/controllers/repport.controller.go +++ b/internal/modules/repports/controllers/repport.controller.go @@ -30,6 +30,8 @@ type RepportController struct { RepportService service.RepportService } +const expenseReportExcelExportFetchLimit = 100 + func NewRepportController(repportService service.RepportService) *RepportController { return &RepportController{ RepportService: repportService, @@ -66,6 +68,14 @@ func (c *RepportController) GetExpense(ctx *fiber.Ctx) error { 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 { 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 { rows, meta, err := c.RepportService.GetExpenseDepreciation(ctx) if err != nil { diff --git a/internal/modules/repports/controllers/repport.controller_test.go b/internal/modules/repports/controllers/repport.controller_test.go new file mode 100644 index 00000000..a0b3c1c5 --- /dev/null +++ b/internal/modules/repports/controllers/repport.controller_test.go @@ -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", + }, + } +} diff --git a/internal/modules/repports/controllers/repport.export.go b/internal/modules/repports/controllers/repport.export.go new file mode 100644 index 00000000..a8cab8d0 --- /dev/null +++ b/internal/modules/repports/controllers/repport.export.go @@ -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 +} diff --git a/internal/modules/repports/controllers/repport.export_test.go b/internal/modules/repports/controllers/repport.export_test.go new file mode 100644 index 00000000..bc52b9e6 --- /dev/null +++ b/internal/modules/repports/controllers/repport.export_test.go @@ -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 +} diff --git a/internal/modules/repports/dto/repportExpense.dto.go b/internal/modules/repports/dto/repportExpense.dto.go index 3e71df2c..00935929 100644 --- a/internal/modules/repports/dto/repportExpense.dto.go +++ b/internal/modules/repports/dto/repportExpense.dto.go @@ -17,6 +17,7 @@ type RepportExpenseBaseDTO struct { ReferenceNumber string `json:"reference_number"` PoNumber string `json:"po_number"` Category string `json:"category"` + Notes string `json:"notes"` Supplier *supplierDTO.SupplierRelationDTO `json:"supplier,omitempty"` RealizationDate *time.Time `json:"realization_date,omitempty"` TransactionDate time.Time `json:"transaction_date"` @@ -74,6 +75,7 @@ func ToRepportExpenseBaseDTO(e *entity.Expense) RepportExpenseBaseDTO { ReferenceNumber: e.ReferenceNumber, PoNumber: e.PoNumber, Category: e.Category, + Notes: e.Notes, Supplier: supplier, RealizationDate: realizationDate, TransactionDate: e.TransactionDate, From c744043321a6e4763e0ba0287d2673285a717e75 Mon Sep 17 00:00:00 2001 From: giovanni Date: Wed, 22 Apr 2026 23:29:05 +0700 Subject: [PATCH 5/6] add export excel all expenses --- .../controllers/expense.controller.go | 37 +++ .../controllers/expense.controller_test.go | 225 +++++++++++++ .../expenses/controllers/expense.export.go | 295 ++++++++++++++++++ .../controllers/expense.export_test.go | 137 ++++++++ 4 files changed, 694 insertions(+) create mode 100644 internal/modules/expenses/controllers/expense.controller_test.go create mode 100644 internal/modules/expenses/controllers/expense.export.go create mode 100644 internal/modules/expenses/controllers/expense.export_test.go diff --git a/internal/modules/expenses/controllers/expense.controller.go b/internal/modules/expenses/controllers/expense.controller.go index 013b97c6..46385397 100644 --- a/internal/modules/expenses/controllers/expense.controller.go +++ b/internal/modules/expenses/controllers/expense.controller.go @@ -24,6 +24,8 @@ type ExpenseController struct { ExpenseService service.ExpenseService } +const expenseExcelExportFetchLimit = 100 + func NewExpenseController(expenseService service.ExpenseService) *ExpenseController { return &ExpenseController{ ExpenseService: expenseService, @@ -56,6 +58,14 @@ func (u *ExpenseController) GetAll(c *fiber.Ctx) error { Search: c.Query("search", ""), } + 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 { return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") } @@ -80,6 +90,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 { param := c.Params("id") diff --git a/internal/modules/expenses/controllers/expense.controller_test.go b/internal/modules/expenses/controllers/expense.controller_test.go new file mode 100644 index 00000000..99e0b357 --- /dev/null +++ b/internal/modules/expenses/controllers/expense.controller_test.go @@ -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, + }, + } +} diff --git a/internal/modules/expenses/controllers/expense.export.go b/internal/modules/expenses/controllers/expense.export.go new file mode 100644 index 00000000..432e6c9b --- /dev/null +++ b/internal/modules/expenses/controllers/expense.export.go @@ -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 +} diff --git a/internal/modules/expenses/controllers/expense.export_test.go b/internal/modules/expenses/controllers/expense.export_test.go new file mode 100644 index 00000000..1bc7c8f6 --- /dev/null +++ b/internal/modules/expenses/controllers/expense.export_test.go @@ -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 +} From e24e2ff123183cf6b5d0332459fb52fa5c95782e Mon Sep 17 00:00:00 2001 From: Adnan Zahir Date: Thu, 23 Apr 2026 00:17:24 +0700 Subject: [PATCH 6/6] feat: filter improvement --- .../controllers/expense.controller.go | 15 +- .../expenses/services/expense.service.go | 193 +++++++++- .../validations/expense.validation.go | 15 +- .../controllers/deliveryorder.controller.go | 16 +- .../services/deliveryorder.service.go | 134 +++++-- .../validations/deliveryorder.validation.go | 16 +- .../controllers/recording.controller.go | 20 +- .../repositories/recording.repository.go | 97 ++++- .../recordings/services/recording.service.go | 179 +++++----- .../validations/recording.validation.go | 8 +- .../controllers/purchase.controller.go | 28 +- .../purchases/services/purchase.service.go | 331 ++++++++---------- .../validations/purchase.validation.go | 28 +- 13 files changed, 702 insertions(+), 378 deletions(-) diff --git a/internal/modules/expenses/controllers/expense.controller.go b/internal/modules/expenses/controllers/expense.controller.go index 013b97c6..ac1e66ee 100644 --- a/internal/modules/expenses/controllers/expense.controller.go +++ b/internal/modules/expenses/controllers/expense.controller.go @@ -51,9 +51,18 @@ func (u *ExpenseController) GetAll(c *fiber.Ctx) error { } query := &validation.Query{ - Page: c.QueryInt("page", 1), - Limit: c.QueryInt("limit", 10), - Search: c.Query("search", ""), + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + 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 query.Page < 1 || query.Limit < 1 { diff --git a/internal/modules/expenses/services/expense.service.go b/internal/modules/expenses/services/expense.service.go index c410fbd0..860c9212 100644 --- a/internal/modules/expenses/services/expense.service.go +++ b/internal/modules/expenses/services/expense.service.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "strings" "time" "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) { if err := s.Validate.Struct(params); err != nil { 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 { db = s.withRelations(db) db, scopeErr = middleware.ApplyLocationScope(c, db, "expenses.location_id") - if params.Search != "" { - return db.Where("category ILIKE ?", "%"+params.Search+"%") + db = db.Where("expenses.deleted_at IS NULL") + + 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 { diff --git a/internal/modules/expenses/validations/expense.validation.go b/internal/modules/expenses/validations/expense.validation.go index d8107e7c..0046ce7f 100644 --- a/internal/modules/expenses/validations/expense.validation.go +++ b/internal/modules/expenses/validations/expense.validation.go @@ -42,9 +42,18 @@ type Update struct { } type Query struct { - Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` - Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` - Search string `query:"search" validate:"omitempty,max=50"` + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + 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 { diff --git a/internal/modules/marketing/controllers/deliveryorder.controller.go b/internal/modules/marketing/controllers/deliveryorder.controller.go index 39ab38eb..bb285294 100644 --- a/internal/modules/marketing/controllers/deliveryorder.controller.go +++ b/internal/modules/marketing/controllers/deliveryorder.controller.go @@ -57,13 +57,15 @@ func (u *DeliveryOrdersController) GetAll(c *fiber.Ctx) error { } query := &validation.DeliveryOrderQuery{ - Page: c.QueryInt("page", 1), - Limit: c.QueryInt("limit", 10), - Search: strings.TrimSpace(c.Query("search", "")), - ProductIDs: productIDs, - Status: strings.ReplaceAll(strings.TrimSpace(c.Query("status", "")), "_", " "), - CustomerId: uint(c.QueryInt("customer_id", 0)), - MarketingId: uint(c.QueryInt("marketing_id", 0)), + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: strings.TrimSpace(c.Query("search", "")), + ProductIDs: productIDs, + Status: strings.ReplaceAll(strings.TrimSpace(c.Query("status", "")), "_", " "), + CustomerId: uint(c.QueryInt("customer_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) { diff --git a/internal/modules/marketing/services/deliveryorder.service.go b/internal/modules/marketing/services/deliveryorder.service.go index 7a1c4cdc..c06ff3de 100644 --- a/internal/modules/marketing/services/deliveryorder.service.go +++ b/internal/modules/marketing/services/deliveryorder.service.go @@ -85,6 +85,102 @@ func (s deliveryOrdersService) withRelations(db *gorm.DB) *gorm.DB { 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) { if err := m.EnsureMarketingAccess(c, s.MarketingRepo.DB(), marketingId); err != nil { 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 { db = db.Where(`EXISTS ( 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 = s.applyMarketingProjectFlockFilter(c.Context(), db, params.ProjectFlockID, params.ProjectFlockKandangID) + db = s.applyMarketingSearchFilter(c.Context(), db, params.Search) + if scope.Restrict { if len(scope.IDs) == 0 { return db.Where("1 = 0") diff --git a/internal/modules/marketing/validations/deliveryorder.validation.go b/internal/modules/marketing/validations/deliveryorder.validation.go index 20719d55..5a53c174 100644 --- a/internal/modules/marketing/validations/deliveryorder.validation.go +++ b/internal/modules/marketing/validations/deliveryorder.validation.go @@ -22,13 +22,15 @@ type DeliveryOrderUpdate struct { } type DeliveryOrderQuery struct { - Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` - Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` - Search string `query:"search" validate:"omitempty,max=100"` - ProductIDs []uint `query:"product_ids" validate:"omitempty,dive,gt=0"` - Status string `query:"status" validate:"omitempty,max=50"` - CustomerId uint `query:"customer_id" validate:"omitempty,gt=0"` - MarketingId uint `query:"marketing_id" validate:"omitempty,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"` + Search string `query:"search" validate:"omitempty,max=100"` + ProductIDs []uint `query:"product_ids" validate:"omitempty,dive,gt=0"` + Status string `query:"status" validate:"omitempty,max=50"` + CustomerId uint `query:"customer_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 { diff --git a/internal/modules/production/recordings/controllers/recording.controller.go b/internal/modules/production/recordings/controllers/recording.controller.go index 795874da..3bf55546 100644 --- a/internal/modules/production/recordings/controllers/recording.controller.go +++ b/internal/modules/production/recordings/controllers/recording.controller.go @@ -27,7 +27,7 @@ func NewRecordingController(recordingService service.RecordingService) *Recordin } 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")) if exportprogress.IsProgressExportRequest(c) { @@ -54,13 +54,19 @@ func (u *RecordingController) GetAll(c *fiber.Ctx) error { offset := (page - 1) * limit query := &validation.Query{ - Page: page, - Limit: limit, - Offset: offset, - Search: c.Query("search"), + Page: page, + Limit: limit, + Offset: offset, + 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 { - query.ProjectFlockKandangId = uint(projectFlockID) + if projectFlockKandangID > 0 { + query.ProjectFlockKandangId = uint(projectFlockKandangID) } result, totalResults, err := u.RecordingService.GetAll(c, query) diff --git a/internal/modules/production/recordings/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go index bddddfda..8b58b8a3 100644 --- a/internal/modules/production/recordings/repositories/recording.repository.go +++ b/internal/modules/production/recordings/repositories/recording.repository.go @@ -11,6 +11,8 @@ import ( "gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress" "gitlab.com/mbugroup/lti-api.git/internal/common/repository" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" "gorm.io/gorm" ) @@ -19,10 +21,10 @@ type RecordingRepository interface { WithRelations(db *gorm.DB) *gorm.DB WithRelationsList(db *gorm.DB) *gorm.DB - ApplyListFilters(db *gorm.DB, search string, projectFlockKandangId uint) *gorm.DB - ApplyListCountFilters(db *gorm.DB, search string, projectFlockKandangId uint) *gorm.DB + ApplyListFilters(db *gorm.DB, params *validation.Query) *gorm.DB + ApplyListCountFilters(db *gorm.DB, params *validation.Query) *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) ListByProjectFlockKandangID(ctx context.Context, tx *gorm.DB, projectFlockKandangId uint, from *time.Time) ([]entity.Recording, 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") } -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 = db. 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") - if projectFlockKandangId != 0 { - db = db.Where("recordings.project_flock_kandangs_id = ?", projectFlockKandangId) - } + Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id"). + Joins("LEFT JOIN kandangs k ON k.id = pfk.kandang_id") + db = r.applyStructuredListFilters(db, params) db = r.ApplySearchFilters(db, search) 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. 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") - if projectFlockKandangId != 0 { - db = db.Where("recordings.project_flock_kandangs_id = ?", projectFlockKandangId) - } + Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id"). + Joins("LEFT JOIN kandangs k ON k.id = pfk.kandang_id") + db = r.applyStructuredListFilters(db, params) db = r.ApplySearchFilters(db, search) 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 ( records []entity.Recording 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 { countQ = modifier(countQ) } @@ -184,7 +239,7 @@ func (r *RecordingRepositoryImpl) GetAllWithFilters(ctx context.Context, offset, 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 { 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 wd ON wd.id = pwd.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(` LOWER(pf.flock_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(ws.name) LIKE ? OR LOWER(wd.name) LIKE ? - OR LOWER(we.name) LIKE ?`, - likeQuery, likeQuery, likeQuery, likeQuery, likeQuery, likeQuery, likeQuery, + OR LOWER(we.name) LIKE ? + 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) } diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 18bf2a01..55315a16 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -116,8 +116,7 @@ func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti c.Context(), params.Offset, params.Limit, - params.Search, - params.ProjectFlockKandangId, + params, func(db *gorm.DB) *gorm.DB { db, scopeErr = m.ApplyLocationScope(c, db, "pf.location_id") return db @@ -450,12 +449,12 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent return err } - mappedStocks := recordingutil.MapStocks(createdRecording.Id, stockOwnerProjectFlockKandangID, req.Stocks) - stockDesired := resetStockQuantitiesForFIFO(mappedStocks) - if err := s.Repository.CreateStocks(tx, mappedStocks); err != nil { - s.Log.Errorf("Failed to persist stocks: %+v", err) - return err - } + mappedStocks := recordingutil.MapStocks(createdRecording.Id, stockOwnerProjectFlockKandangID, req.Stocks) + stockDesired := resetStockQuantitiesForFIFO(mappedStocks) + if err := s.Repository.CreateStocks(tx, mappedStocks); err != nil { + s.Log.Errorf("Failed to persist stocks: %+v", err) + return err + } for i := range mappedStocks { if i >= len(stockDesired) { @@ -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) return err } - if err := s.recalculateFrom(ctx, tx, createdRecording.ProjectFlockKandangId, createdRecording.RecordDatetime); err != nil { - s.Log.Errorf("Failed to recalculate recordings after create: %+v", err) - return err - } - if err := s.syncFarmDepreciationManualInputFromRecordingStocks(ctx, tx, createdRecording.ProjectFlockKandangId, createdRecording.RecordDatetime); err != nil { - s.Log.Errorf("Failed to sync farm depreciation manual input after create: %+v", err) - return err - } + if err := s.recalculateFrom(ctx, tx, createdRecording.ProjectFlockKandangId, createdRecording.RecordDatetime); err != nil { + s.Log.Errorf("Failed to recalculate recordings after create: %+v", err) + return err + } + if err := s.syncFarmDepreciationManualInputFromRecordingStocks(ctx, tx, createdRecording.ProjectFlockKandangId, createdRecording.RecordDatetime); err != nil { + s.Log.Errorf("Failed to sync farm depreciation manual input after create: %+v", err) + return err + } action := entity.ApprovalActionCreated if err := s.createRecordingApproval(ctx, tx, createdRecording.Id, utils.RecordingStepPengajuan, action, createdRecording.CreatedBy, nil); err != nil { @@ -587,8 +586,8 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin return err } - recordingEntity = recording - pfkForRoute := recordingEntity.ProjectFlockKandang + recordingEntity = recording + pfkForRoute := recordingEntity.ProjectFlockKandang if pfkForRoute == nil || pfkForRoute.Id == 0 { fetchedPfk, fetchErr := s.ProjectFlockKandangRepo.GetByIDLight(ctx, recordingEntity.ProjectFlockKandangId) if fetchErr != nil { @@ -599,43 +598,43 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin return fetchErr } pfkForRoute = fetchedPfk - } - if err := s.tryAutoExecuteTransferForRecordingCreate(c, pfkForRoute, recordingEntity.RecordDatetime); err != nil { - return err - } - routePayload := buildRecordingRoutePayloadFromUpdate(req) - if err := s.enforceTransferRecordingRoute(ctx, pfkForRoute, recordingEntity.RecordDatetime, routePayload); err != nil { - return err - } + } + if err := s.tryAutoExecuteTransferForRecordingCreate(c, pfkForRoute, recordingEntity.RecordDatetime); err != nil { + return err + } + routePayload := buildRecordingRoutePayloadFromUpdate(req) + if err := s.enforceTransferRecordingRoute(ctx, pfkForRoute, recordingEntity.RecordDatetime, routePayload); err != nil { + return err + } hasStockChanges := req.Stocks != nil hasDepletionChanges := req.Depletions != nil hasEggChanges := req.Eggs != nil - var existingStocks []entity.RecordingStock - var existingDepletions []entity.RecordingDepletion - var existingEggs []entity.RecordingEgg - var mappedDepletions []entity.RecordingDepletion - var stockOwnerProjectFlockKandangID *uint + var existingStocks []entity.RecordingStock + var existingDepletions []entity.RecordingDepletion + var existingEggs []entity.RecordingEgg + var mappedDepletions []entity.RecordingDepletion + var stockOwnerProjectFlockKandangID *uint note := recordingutil.RecordingNote("Edit", recordingEntity.Id) - if hasStockChanges { - stockOwnerProjectFlockKandangID, err = s.resolveRecordingStockOwnerProjectFlockKandangID(ctx, pfkForRoute, recordingEntity.RecordDatetime) - if err != nil { - return err - } - existingStocks, err = s.Repository.ListStocks(tx, recordingEntity.Id) - if err != nil { - s.Log.Errorf("Failed to list existing stocks: %+v", err) - return err - } + if hasStockChanges { + stockOwnerProjectFlockKandangID, err = s.resolveRecordingStockOwnerProjectFlockKandangID(ctx, pfkForRoute, recordingEntity.RecordDatetime) + if err != nil { + return err + } + existingStocks, err = s.Repository.ListStocks(tx, recordingEntity.Id) + if err != nil { + s.Log.Errorf("Failed to list existing stocks: %+v", err) + return err + } existingUsage := recordingutil.StockUsageByWarehouse(existingStocks) incomingUsage := recordingutil.StockUsageByWarehouseReq(req.Stocks) - match := recordingutil.FloatMapsEqual(existingUsage, incomingUsage) && recordingStocksAllOwnedBy(existingStocks, stockOwnerProjectFlockKandangID) - if match { - hasStockChanges = false - } else { + match := recordingutil.FloatMapsEqual(existingUsage, incomingUsage) && recordingStocksAllOwnedBy(existingStocks, stockOwnerProjectFlockKandangID) + if match { + hasStockChanges = false + } else { if err := s.ensureProductWarehousesExist(c, req.Stocks, nil, nil); err != nil { return err } @@ -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 { return err } - if err := s.reflowSyncRecordingStocks(ctx, tx, recordingEntity.Id, existingStocks, req.Stocks, stockOwnerProjectFlockKandangID, note, actorID); err != nil { - return err - } + if err := s.reflowSyncRecordingStocks(ctx, tx, recordingEntity.Id, existingStocks, req.Stocks, stockOwnerProjectFlockKandangID, note, actorID); err != nil { + return err } } + } if hasDepletionChanges { existingDepletions, err = s.Repository.ListDepletions(tx, recordingEntity.Id) @@ -809,22 +808,22 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin return err } - if hasStockChanges || hasDepletionChanges || hasEggChanges { + if hasStockChanges || hasDepletionChanges || hasEggChanges { if err := s.computeAndUpdateMetrics(ctx, tx, recordingEntity); err != nil { s.Log.Errorf("Failed to recompute recording metrics: %+v", err) return err } - if err := s.recalculateFrom(ctx, tx, recordingEntity.ProjectFlockKandangId, recordingEntity.RecordDatetime); err != nil { - s.Log.Errorf("Failed to recalculate recordings after update: %+v", err) - return err - } + if err := s.recalculateFrom(ctx, tx, recordingEntity.ProjectFlockKandangId, recordingEntity.RecordDatetime); err != nil { + s.Log.Errorf("Failed to recalculate recordings after update: %+v", err) + return err } - if hasStockChanges { - if err := s.syncFarmDepreciationManualInputFromRecordingStocks(ctx, tx, recordingEntity.ProjectFlockKandangId, recordingEntity.RecordDatetime); err != nil { - s.Log.Errorf("Failed to sync farm depreciation manual input after update: %+v", err) - return err - } + } + if hasStockChanges { + if err := s.syncFarmDepreciationManualInputFromRecordingStocks(ctx, tx, recordingEntity.ProjectFlockKandangId, recordingEntity.RecordDatetime); err != nil { + s.Log.Errorf("Failed to sync farm depreciation manual input after update: %+v", err) + return err } + } action := entity.ApprovalActionUpdated actorID := recordingEntity.CreatedBy @@ -1082,15 +1081,15 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error { return err } - if err := s.recalculateFrom(ctx, tx, recording.ProjectFlockKandangId, recording.RecordDatetime); err != nil { - s.Log.Errorf("Failed to recalculate recordings after delete: %+v", err) - return err - } - if err := s.syncFarmDepreciationManualInputFromRecordingStocks(ctx, tx, recording.ProjectFlockKandangId, recording.RecordDatetime); err != nil { - s.Log.Errorf("Failed to sync farm depreciation manual input after delete: %+v", err) - return err - } - s.invalidateDepreciationSnapshots(ctx, tx, recording.ProjectFlockKandangId, recording.RecordDatetime) + if err := s.recalculateFrom(ctx, tx, recording.ProjectFlockKandangId, recording.RecordDatetime); err != nil { + s.Log.Errorf("Failed to recalculate recordings after delete: %+v", err) + return err + } + if err := s.syncFarmDepreciationManualInputFromRecordingStocks(ctx, tx, recording.ProjectFlockKandangId, recording.RecordDatetime); err != nil { + s.Log.Errorf("Failed to sync farm depreciation manual input after delete: %+v", err) + return err + } + s.invalidateDepreciationSnapshots(ctx, tx, recording.ProjectFlockKandangId, recording.RecordDatetime) return nil }) @@ -2905,32 +2904,32 @@ func (s *recordingService) reflowSyncRecordingStocks( if len(list) > 0 { stock = list[0] existingByWarehouse[item.ProductWarehouseId] = list[1:] - } else { - zero := 0.0 - stock = entity.RecordingStock{ - RecordingId: recordingID, - ProductWarehouseId: item.ProductWarehouseId, - ProjectFlockKandangId: ownerProjectFlockKandangID, - UsageQty: &zero, - PendingQty: &zero, - } - if err := s.Repository.CreateStock(tx, &stock); err != nil { - return err - } + } else { + zero := 0.0 + stock = entity.RecordingStock{ + RecordingId: recordingID, + ProductWarehouseId: item.ProductWarehouseId, + ProjectFlockKandangId: ownerProjectFlockKandangID, + UsageQty: &zero, + PendingQty: &zero, } - 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 - } + if err := s.Repository.CreateStock(tx, &stock); err != nil { + return err } + } + stock.ProjectFlockKandangId = ownerProjectFlockKandangID + if stock.Id != 0 { + if err := tx.Model(&entity.RecordingStock{}). + Where("id = ?", stock.Id). + Updates(map[string]any{ + "project_flock_kandang_id": ownerProjectFlockKandangID, + }).Error; err != nil { + return err + } + } - desired := item.Qty - stock.UsageQty = &desired + desired := item.Qty + stock.UsageQty = &desired zero := 0.0 stock.PendingQty = &zero stocksToApply = append(stocksToApply, stock) diff --git a/internal/modules/production/recordings/validations/recording.validation.go b/internal/modules/production/recordings/validations/recording.validation.go index da2ca38f..73d4fdef 100644 --- a/internal/modules/production/recordings/validations/recording.validation.go +++ b/internal/modules/production/recordings/validations/recording.validation.go @@ -39,8 +39,14 @@ type Query struct { Page int `query:"page" validate:"omitempty,number,min=1"` Limit int `query:"limit" validate:"omitempty,number,min=1"` 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"` - 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 { diff --git a/internal/modules/purchases/controllers/purchase.controller.go b/internal/modules/purchases/controllers/purchase.controller.go index 1616acbf..6c627cb2 100644 --- a/internal/modules/purchases/controllers/purchase.controller.go +++ b/internal/modules/purchases/controllers/purchase.controller.go @@ -87,19 +87,21 @@ 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)), - ProductCategoryID: strings.TrimSpace(c.Query("product_category_id")), + 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")), } } diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index 486144c5..5703cb8e 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -247,7 +247,7 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti if len(productCategoryIDs) > 0 { db = db.Where( `EXISTS ( - SELECT 1 + SELECT 1 FROM purchase_items pi JOIN products p ON p.id = pi.product_id 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 { - 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, - ) - } - - 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, - ) - } + db = applyPurchaseProjectFlockFilter(db, params.ProjectFlockID, params.ProjectFlockKandangID) + db = applyPurchaseApprovalStatusFilter(db, approvalStatuses) + db = applyPurchaseSearchFilter(db, search) 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 } +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 { value := strings.ToLower(strings.TrimSpace(raw)) switch value { diff --git a/internal/modules/purchases/validations/purchase.validation.go b/internal/modules/purchases/validations/purchase.validation.go index b643501c..a16390ef 100644 --- a/internal/modules/purchases/validations/purchase.validation.go +++ b/internal/modules/purchases/validations/purchase.validation.go @@ -61,17 +61,19 @@ type DeletePurchaseItemsRequest struct { } type Query struct { - Page int `query:"page" validate:"omitempty,number,min=1"` - Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` - SupplierID uint `query:"supplier_id" validate:"omitempty,gt=0"` - AreaID uint `query:"area_id" validate:"omitempty,gt=0"` - LocationID uint `query:"location_id" validate:"omitempty,gt=0"` - ProductCategoryID string `query:"product_category_id" validate:"omitempty,max=500"` - ApprovalStatus string `query:"approval_status" validate:"omitempty,max=500"` - PoDate string `query:"po_date" validate:"omitempty,datetime=2006-01-02"` - PoDateFrom string `query:"po_date_from" validate:"omitempty,datetime=2006-01-02"` - PoDateTo string `query:"po_date_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"` + Page int `query:"page" validate:"omitempty,number,min=1"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` + SupplierID uint `query:"supplier_id" validate:"omitempty,gt=0"` + AreaID uint `query:"area_id" validate:"omitempty,gt=0"` + LocationID uint `query:"location_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"` + ProductCategoryID string `query:"product_category_id" validate:"omitempty,max=500"` + ApprovalStatus string `query:"approval_status" validate:"omitempty,max=500"` + PoDate string `query:"po_date" validate:"omitempty,datetime=2006-01-02"` + PoDateFrom string `query:"po_date_from" validate:"omitempty,datetime=2006-01-02"` + PoDateTo string `query:"po_date_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"` }