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,