From 3e34da7385fcd686a71adff11c48d83668d7622a Mon Sep 17 00:00:00 2001 From: giovanni Date: Sat, 23 May 2026 11:06:33 +0700 Subject: [PATCH 1/2] add migration for normalize wrong location pullet cikaum --- ...nsolidate_pullet_cikaum_locations.down.sql | 31 +++++++++++++++++ ...consolidate_pullet_cikaum_locations.up.sql | 34 +++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 internal/database/migrations/20260523035647_consolidate_pullet_cikaum_locations.down.sql create mode 100644 internal/database/migrations/20260523035647_consolidate_pullet_cikaum_locations.up.sql diff --git a/internal/database/migrations/20260523035647_consolidate_pullet_cikaum_locations.down.sql b/internal/database/migrations/20260523035647_consolidate_pullet_cikaum_locations.down.sql new file mode 100644 index 00000000..88c69f9c --- /dev/null +++ b/internal/database/migrations/20260523035647_consolidate_pullet_cikaum_locations.down.sql @@ -0,0 +1,31 @@ +BEGIN; + +-- Rollback konsolidasi: kembalikan data ke loc 18 / 25 sesuai snapshot pre-migration. +-- Order: un-soft-delete locations dulu agar FK tidak gagal saat UPDATE child. + +-- 1. Un-soft-delete locations +UPDATE locations SET deleted_at = NULL WHERE id IN (18, 25); + +-- 2. project_flocks: PF 30 -> 18, PF 25 & 31 -> 25 +UPDATE project_flocks SET location_id = 18, updated_at = NOW() WHERE id = 30; +UPDATE project_flocks SET location_id = 25, updated_at = NOW() WHERE id IN (25, 31); + +-- 3. kandangs: K9, K72, K117 -> 18; K10, K73, K116 -> 25 +UPDATE kandangs SET location_id = 18, updated_at = NOW() WHERE id IN (9, 72, 117); +UPDATE kandangs SET location_id = 25, updated_at = NOW() WHERE id IN (10, 73, 116); + +-- 4. kandang_groups: KG 26, 68 -> 18; KG 27, 67 -> 25 +UPDATE kandang_groups SET location_id = 18, updated_at = NOW() WHERE id IN (26, 68); +UPDATE kandang_groups SET location_id = 25, updated_at = NOW() WHERE id IN (27, 67); + +-- 5. warehouses: W27, W145, W152 -> 18; W3, W146, W153 -> 25 +UPDATE warehouses SET location_id = 18, updated_at = NOW() WHERE id IN (27, 145, 152); +UPDATE warehouses SET location_id = 25, updated_at = NOW() WHERE id IN (3, 146, 153); + +-- 6. expenses: list eksplisit per location +UPDATE expenses SET location_id = 18, updated_at = NOW() +WHERE id IN (36, 345, 500, 501, 502, 503, 504, 505, 506, 507, 508); +UPDATE expenses SET location_id = 25, updated_at = NOW() +WHERE id IN (9, 37, 509, 510, 511, 512, 513, 514, 515, 516, 517, 518); + +COMMIT; diff --git a/internal/database/migrations/20260523035647_consolidate_pullet_cikaum_locations.up.sql b/internal/database/migrations/20260523035647_consolidate_pullet_cikaum_locations.up.sql new file mode 100644 index 00000000..3480b7cc --- /dev/null +++ b/internal/database/migrations/20260523035647_consolidate_pullet_cikaum_locations.up.sql @@ -0,0 +1,34 @@ +BEGIN; + +-- Konsolidasi 3 lokasi "Pullet Cikaum" jadi 1. +-- Pindahkan semua data di loc 18 (Pullet Cikaum 1) & 25 (Pullet Cikaum 2) ke loc 2 (Pullet Cikaum). +-- Urutan wajib: semua UPDATE child harus selesai SEBELUM soft-delete locations, +-- karena trigger trg_soft_delete_fk_locations akan RAISE EXCEPTION untuk FK +-- RESTRICT (project_flocks, kandangs, kandang_groups, expenses) atau SET NULL +-- untuk warehouses kalau masih ada child yang reference. + +-- 1. project_flocks (PF 25, 30, 31) +UPDATE project_flocks SET location_id = 2, updated_at = NOW() +WHERE location_id IN (18, 25); + +-- 2. kandangs (K9, K72, K117, K10, K73, K116) +UPDATE kandangs SET location_id = 2, updated_at = NOW() +WHERE location_id IN (18, 25); + +-- 3. kandang_groups (KG 26, 68, 27, 67) +UPDATE kandang_groups SET location_id = 2, updated_at = NOW() +WHERE location_id IN (18, 25); + +-- 4. warehouses (W3, W27, W145, W146, W152, W153) +UPDATE warehouses SET location_id = 2, updated_at = NOW() +WHERE location_id IN (18, 25); + +-- 5. expenses (23 row BOP) +UPDATE expenses SET location_id = 2, updated_at = NOW() +WHERE location_id IN (18, 25); + +-- 6. Soft-delete locations 18 & 25 (kosong, aman karena semua child sudah pindah) +UPDATE locations SET deleted_at = NOW() +WHERE id IN (18, 25) AND deleted_at IS NULL; + +COMMIT; From c107f0f6832557057fbcbd06656b2d5b1ad46677 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Mon, 25 May 2026 14:18:47 +0700 Subject: [PATCH 2/2] feat(reports): add Excel export to balance monitoring endpoint Add ?export=excel support to GetBalanceMonitoring. Creates a new repport.balance_monitoring.export.go with a 2-row merged header layout matching the UI (Penjualan Ayam and Penjualan Telur grouped columns), a totals row, red styling for negative Saldo Akhir, and frozen panes below the header rows. Exported data reflects all active query filters. Co-Authored-By: Claude Sonnet 4.6 --- .../repport.balance_monitoring.export.go | 286 ++++++++++++++++++ .../controllers/repport.controller.go | 4 + 2 files changed, 290 insertions(+) create mode 100644 internal/modules/repports/controllers/repport.balance_monitoring.export.go diff --git a/internal/modules/repports/controllers/repport.balance_monitoring.export.go b/internal/modules/repports/controllers/repport.balance_monitoring.export.go new file mode 100644 index 00000000..5802743c --- /dev/null +++ b/internal/modules/repports/controllers/repport.balance_monitoring.export.go @@ -0,0 +1,286 @@ +package controller + +import ( + "fmt" + "strconv" + "strings" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/xuri/excelize/v2" + "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto" +) + +func isBalanceMonitoringExcelExportRequest(c *fiber.Ctx) bool { + return strings.EqualFold(strings.TrimSpace(c.Query("export")), "excel") +} + +func exportBalanceMonitoringExcel(c *fiber.Ctx, items []dto.BalanceMonitoringRowDTO, totals dto.BalanceMonitoringTotalsDTO) error { + content, err := buildBalanceMonitoringWorkbook(items, totals) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "failed to generate excel file") + } + + filename := fmt.Sprintf("laporan-balance-monitoring-%s.xlsx", time.Now().Format("2006-01-02-1504")) + 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 buildBalanceMonitoringWorkbook(items []dto.BalanceMonitoringRowDTO, totals dto.BalanceMonitoringTotalsDTO) ([]byte, error) { + file := excelize.NewFile() + defer file.Close() + + const sheet = "Balance Monitoring" + defaultSheet := file.GetSheetName(file.GetActiveSheetIndex()) + if defaultSheet != sheet { + if err := file.SetSheetName(defaultSheet, sheet); err != nil { + return nil, err + } + } + + if err := setBalanceMonitoringColumns(file, sheet); err != nil { + return nil, err + } + if err := setBalanceMonitoringHeaders(file, sheet); err != nil { + return nil, err + } + if err := writeBalanceMonitoringRows(file, sheet, items, totals); err != nil { + return nil, err + } + if err := file.SetPanes(sheet, &excelize.Panes{ + Freeze: true, + YSplit: 2, + TopLeftCell: "A3", + ActivePane: "bottomLeft", + }); err != nil { + return nil, err + } + + buf, err := file.WriteToBuffer() + if err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +var bmColumnWidths = map[string]float64{ + "A": 5, + "B": 28, + "C": 18, + "D": 12, + "E": 12, + "F": 20, + "G": 12, + "H": 12, + "I": 20, + "J": 20, + "K": 18, + "L": 12, + "M": 16, + "N": 20, +} + +func setBalanceMonitoringColumns(file *excelize.File, sheet string) error { + for col, width := range bmColumnWidths { + if err := file.SetColWidth(sheet, col, col, width); err != nil { + return err + } + } + if err := file.SetRowHeight(sheet, 1, 24); err != nil { + return err + } + return file.SetRowHeight(sheet, 2, 24) +} + +func setBalanceMonitoringHeaders(file *excelize.File, sheet string) error { + borderStyle := []excelize.Border{ + {Type: "left", Color: "000000", Style: 1}, + {Type: "top", Color: "000000", Style: 1}, + {Type: "bottom", Color: "000000", Style: 1}, + {Type: "right", Color: "000000", Style: 1}, + } + headerStyle, err := file.NewStyle(&excelize.Style{ + Font: &excelize.Font{Bold: true, Color: "FFFFFF", Family: "Arial", Size: 10}, + Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"4472C4"}}, + Alignment: &excelize.Alignment{ + Horizontal: "center", + Vertical: "center", + WrapText: true, + }, + Border: borderStyle, + }) + if err != nil { + return err + } + + // Single-column headers: merge rows 1 and 2 vertically + singleColHeaders := map[string]string{ + "A": "No", + "B": "Customer", + "C": "Saldo Awal", + "J": "Penjualan Trading", + "K": "Pembayaran", + "L": "Aging", + "M": "Aging Rata-Rata", + "N": "Saldo Akhir", + } + for col, header := range singleColHeaders { + if err := file.SetCellValue(sheet, col+"1", header); err != nil { + return err + } + if err := file.MergeCell(sheet, col+"1", col+"2"); err != nil { + return err + } + } + + // Group headers: merge columns horizontally in row 1 + if err := file.SetCellValue(sheet, "D1", "Penjualan Ayam"); err != nil { + return err + } + if err := file.MergeCell(sheet, "D1", "F1"); err != nil { + return err + } + if err := file.SetCellValue(sheet, "G1", "Penjualan Telur"); err != nil { + return err + } + if err := file.MergeCell(sheet, "G1", "I1"); err != nil { + return err + } + + // Sub-column headers in row 2 + subHeaders := map[string]string{ + "D": "Ekor", + "E": "Kg", + "F": "Nominal", + "G": "Butir", + "H": "Kg", + "I": "Nominal", + } + for col, header := range subHeaders { + if err := file.SetCellValue(sheet, col+"2", header); err != nil { + return err + } + } + + return file.SetCellStyle(sheet, "A1", "N2", headerStyle) +} + +func writeBalanceMonitoringRows(file *excelize.File, sheet string, items []dto.BalanceMonitoringRowDTO, totals dto.BalanceMonitoringTotalsDTO) error { + borderStyle := []excelize.Border{ + {Type: "left", Color: "000000", Style: 1}, + {Type: "top", Color: "000000", Style: 1}, + {Type: "bottom", Color: "000000", Style: 1}, + {Type: "right", Color: "000000", Style: 1}, + } + + dataStyle, err := file.NewStyle(&excelize.Style{ + Font: &excelize.Font{Color: "000000", Family: "Arial", Size: 10}, + Alignment: &excelize.Alignment{Vertical: "center", WrapText: true}, + Border: borderStyle, + }) + if err != nil { + return err + } + + totalStyle, err := file.NewStyle(&excelize.Style{ + Font: &excelize.Font{Bold: true, Color: "000000", Family: "Arial", Size: 10}, + Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"E2EFDA"}}, + Alignment: &excelize.Alignment{Vertical: "center", WrapText: true}, + Border: borderStyle, + }) + if err != nil { + return err + } + + redDataStyle, err := file.NewStyle(&excelize.Style{ + Font: &excelize.Font{Color: "FF0000", Family: "Arial", Size: 10}, + Alignment: &excelize.Alignment{Vertical: "center", WrapText: true}, + Border: borderStyle, + }) + if err != nil { + return err + } + + redTotalStyle, err := file.NewStyle(&excelize.Style{ + Font: &excelize.Font{Bold: true, Color: "FF0000", Family: "Arial", Size: 10}, + Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"E2EFDA"}}, + Alignment: &excelize.Alignment{Vertical: "center", WrapText: true}, + Border: borderStyle, + }) + if err != nil { + return err + } + + for i, row := range items { + rowNum := i + 3 + rowStr := strconv.Itoa(rowNum) + + cells := map[string]interface{}{ + "A": i + 1, + "B": row.Customer.Name, + "C": row.SaldoAwal, + "D": row.PenjualanAyam.Ekor, + "E": row.PenjualanAyam.Kg, + "F": row.PenjualanAyam.Nominal, + "G": row.PenjualanTelur.Butir, + "H": row.PenjualanTelur.Kg, + "I": row.PenjualanTelur.Nominal, + "J": row.PenjualanTrading.Nominal, + "K": row.Pembayaran, + "L": fmt.Sprintf("%d hari", row.Aging), + "M": formatBMAging(row.AgingRataRata), + "N": row.SaldoAkhir, + } + for col, val := range cells { + if err := file.SetCellValue(sheet, col+rowStr, val); err != nil { + return err + } + } + if err := file.SetCellStyle(sheet, "A"+rowStr, "N"+rowStr, dataStyle); err != nil { + return err + } + if row.SaldoAkhir < 0 { + if err := file.SetCellStyle(sheet, "N"+rowStr, "N"+rowStr, redDataStyle); err != nil { + return err + } + } + } + + // Totals row + totalRowStr := strconv.Itoa(len(items) + 3) + totalCells := map[string]interface{}{ + "A": "Total", + "C": totals.SaldoAwal, + "D": totals.PenjualanAyam.Ekor, + "E": totals.PenjualanAyam.Kg, + "F": totals.PenjualanAyam.Nominal, + "G": totals.PenjualanTelur.Butir, + "H": totals.PenjualanTelur.Kg, + "I": totals.PenjualanTelur.Nominal, + "J": totals.PenjualanTrading.Nominal, + "K": totals.Pembayaran, + "N": totals.SaldoAkhir, + } + for col, val := range totalCells { + if err := file.SetCellValue(sheet, col+totalRowStr, val); err != nil { + return err + } + } + if err := file.SetCellStyle(sheet, "A"+totalRowStr, "N"+totalRowStr, totalStyle); err != nil { + return err + } + if totals.SaldoAkhir < 0 { + if err := file.SetCellStyle(sheet, "N"+totalRowStr, "N"+totalRowStr, redTotalStyle); err != nil { + return err + } + } + + return nil +} + +func formatBMAging(v float64) string { + s := strconv.FormatFloat(v, 'f', 2, 64) + s = strings.ReplaceAll(s, ".", ",") + return s + " hari" +} diff --git a/internal/modules/repports/controllers/repport.controller.go b/internal/modules/repports/controllers/repport.controller.go index 7a66f247..38fe7052 100644 --- a/internal/modules/repports/controllers/repport.controller.go +++ b/internal/modules/repports/controllers/repport.controller.go @@ -555,6 +555,10 @@ func (c *RepportController) GetBalanceMonitoring(ctx *fiber.Ctx) error { return err } + if isBalanceMonitoringExcelExportRequest(ctx) { + return exportBalanceMonitoringExcel(ctx, result, totals) + } + limit := query.Limit if limit < 1 { limit = 10