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 +}