Merge branch 'feat/excel-po-mrk' into 'development'

[FEAT][BE]: add export excel from api Expense

See merge request mbugroup/lti-api!440
This commit is contained in:
Giovanni Gabriel Septriadi
2026-04-22 15:51:19 +00:00
5 changed files with 946 additions and 0 deletions
@@ -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 {
@@ -0,0 +1,202 @@
package controller
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"reflect"
"testing"
"time"
"github.com/gofiber/fiber/v2"
"github.com/xuri/excelize/v2"
"gorm.io/gorm"
approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto"
kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto"
locationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto"
nonstockDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/dto"
supplierDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/dto"
"gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/services"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/validations"
)
type repportServiceStub struct {
service.RepportService
getExpenseCalls []validation.ExpenseQuery
}
func (s *repportServiceStub) GetExpense(_ *fiber.Ctx, params *validation.ExpenseQuery) ([]dto.RepportExpenseListDTO, int64, error) {
callCopy := *params
callCopy.AllowedAreaIDs = append([]int64(nil), params.AllowedAreaIDs...)
callCopy.AllowedLocationIDs = append([]int64(nil), params.AllowedLocationIDs...)
s.getExpenseCalls = append(s.getExpenseCalls, callCopy)
switch params.Page {
case 1:
return []dto.RepportExpenseListDTO{
buildExpenseListForControllerTest("REF-00001", "TRANSPORT 2"),
buildExpenseListForControllerTest("REF-00002", "TRANSPORT"),
}, 3, nil
case 2:
return []dto.RepportExpenseListDTO{
buildExpenseListForControllerTest("REF-00003", "TRANSPORT"),
}, 3, nil
default:
return []dto.RepportExpenseListDTO{}, 3, nil
}
}
func (s *repportServiceStub) DB() *gorm.DB {
return nil
}
func TestRepportControllerGetExpenseExportAllIgnoresRequestLimit(t *testing.T) {
app := fiber.New()
stub := &repportServiceStub{}
ctrl := NewRepportController(stub)
app.Get("/reports/expense", ctrl.GetExpense)
req := httptest.NewRequest(
http.MethodGet,
"/reports/expense?export=excel&type=all&page=9&limit=1&search=operasional&category=BOP&supplier_id=7&kandang_id=4&project_flock_kandang_id=2&nonstock_id=5&area_id=3&location_id=9&realization_date=2026-04-22",
nil,
)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("unexpected app.Test error: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != fiber.StatusOK {
t.Fatalf("expected status 200, got %d", resp.StatusCode)
}
contentType := resp.Header.Get("Content-Type")
if contentType != "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" {
t.Fatalf("unexpected content-type: %s", contentType)
}
disposition := resp.Header.Get("Content-Disposition")
if !bytes.Contains([]byte(disposition), []byte("reports_expense_all_")) {
t.Fatalf("unexpected content-disposition: %s", disposition)
}
if len(stub.getExpenseCalls) != 2 {
t.Fatalf("expected 2 GetExpense calls, got %d", len(stub.getExpenseCalls))
}
firstCall := stub.getExpenseCalls[0]
secondCall := stub.getExpenseCalls[1]
if firstCall.Page != 1 || secondCall.Page != 2 {
t.Fatalf("expected internal pages 1 and 2, got %d and %d", firstCall.Page, secondCall.Page)
}
if firstCall.Limit != expenseReportExcelExportFetchLimit || secondCall.Limit != expenseReportExcelExportFetchLimit {
t.Fatalf("expected internal limit %d, got %d and %d", expenseReportExcelExportFetchLimit, firstCall.Limit, secondCall.Limit)
}
if firstCall.Search != "operasional" ||
firstCall.Category != "BOP" ||
firstCall.SupplierId != 7 ||
firstCall.KandangId != 4 ||
firstCall.ProjectFlockKandangId != 2 ||
firstCall.NonstockId != 5 ||
firstCall.AreaId != 3 ||
firstCall.LocationId != 9 ||
firstCall.RealizationDate != "2026-04-22" {
t.Fatalf("unexpected forwarded filters: %+v", firstCall)
}
payload, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("failed to read excel payload: %v", err)
}
file, err := excelize.OpenReader(bytes.NewReader(payload))
if err != nil {
t.Fatalf("failed to parse excel payload: %v", err)
}
defer file.Close()
sheetList := file.GetSheetList()
expectedSheets := []string{"EKSPEDISI ADE", "EKSPEDISI LTI"}
if !reflect.DeepEqual(sheetList, expectedSheets) {
t.Fatalf("unexpected sheet list: got %v, expected %v", sheetList, expectedSheets)
}
if got, _ := file.GetCellValue("EKSPEDISI ADE", "A1"); got != "No" {
t.Fatalf("expected EKSPEDISI ADE A1 to be No, got %q", got)
}
if got, _ := file.GetCellValue("EKSPEDISI ADE", "G2"); got != "TRANSPORT 2" {
t.Fatalf("expected EKSPEDISI ADE G2 to be TRANSPORT 2, got %q", got)
}
if got, _ := file.GetCellValue("EKSPEDISI LTI", "G2"); got != "TRANSPORT" {
t.Fatalf("expected EKSPEDISI LTI G2 to be TRANSPORT, got %q", got)
}
if got, _ := file.GetCellValue("EKSPEDISI LTI", "A4"); got != "Total" {
t.Fatalf("expected EKSPEDISI LTI A4 to be Total, got %q", got)
}
}
func TestRepportControllerGetExpenseKeepsPaginationValidationForNonExportAll(t *testing.T) {
app := fiber.New()
stub := &repportServiceStub{}
ctrl := NewRepportController(stub)
app.Get("/reports/expense", ctrl.GetExpense)
req := httptest.NewRequest(http.MethodGet, "/reports/expense?page=1&limit=0", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("unexpected app.Test error: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != fiber.StatusBadRequest {
t.Fatalf("expected status 400, got %d", resp.StatusCode)
}
}
func buildExpenseListForControllerTest(reference, product string) dto.RepportExpenseListDTO {
realizationDate := time.Date(2026, time.April, 22, 0, 0, 0, 0, time.UTC)
return dto.RepportExpenseListDTO{
RepportExpenseBaseDTO: dto.RepportExpenseBaseDTO{
ReferenceNumber: reference,
PoNumber: "PO-001",
Category: "BOP",
Notes: "catatan expense",
TransactionDate: time.Date(2026, time.April, 22, 0, 0, 0, 0, time.UTC),
RealizationDate: &realizationDate,
Supplier: &supplierDTO.SupplierRelationDTO{
Name: "Supplier A",
},
},
Kandang: &kandangDTO.KandangRelationDTO{
Name: "Kandang A",
Location: &locationDTO.LocationRelationDTO{
Name: "Darawati",
},
},
Pengajuan: dto.RepportExpensePengajuanDTO{
Qty: 1,
Price: 50000,
Notes: "catatan pengajuan",
Nonstock: &nonstockDTO.NonstockRelationDTO{
Name: product,
},
},
Realisasi: dto.RepportExpenseRealisasiDTO{
Qty: 1,
Price: 50000,
Notes: "catatan realisasi",
Nonstock: &nonstockDTO.NonstockRelationDTO{
Name: product,
},
},
TotalPengajuan: 50000,
TotalRealisasi: 50000,
LatestApproval: &approvalDTO.ApprovalRelationDTO{
StepName: "Realisasi",
},
}
}
@@ -0,0 +1,424 @@
package controller
import (
"fmt"
"sort"
"strconv"
"strings"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto"
"github.com/gofiber/fiber/v2"
"github.com/xuri/excelize/v2"
)
const expenseReportExportSheetName = "Expense Reports"
var expenseReportTemplateSheetOrder = []string{
"UANG MAKAN",
"UPAH",
"EKSPEDISI ADE",
"GALON",
"GAS",
"KEBUTUHAN",
"EKSPEDISI LTI",
"KONTRIBUSI",
"PRODUKSI",
"KOMPENSASI",
"LAIN-LAIN",
"PERBAIKAN",
"LISTRIK",
"PAJAK",
"SOLAR",
}
var expenseReportSheetAliasMap = map[string]string{
"TRANSPORT 2": "EKSPEDISI ADE",
"TRANSPORT": "EKSPEDISI LTI",
"GAS BROODING": "GAS",
}
func isAllExpenseExcelExportRequest(c *fiber.Ctx) bool {
return strings.EqualFold(strings.TrimSpace(c.Query("export")), "excel") &&
strings.EqualFold(strings.TrimSpace(c.Query("type")), "all")
}
func exportExpenseReportListExcel(c *fiber.Ctx, items []dto.RepportExpenseListDTO) error {
content, err := buildExpenseReportExportWorkbook(items)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to generate excel file")
}
filename := fmt.Sprintf("reports_expense_all_%s.xlsx", time.Now().Format("20060102_150405"))
c.Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
return c.Status(fiber.StatusOK).Send(content)
}
func buildExpenseReportExportWorkbook(items []dto.RepportExpenseListDTO) ([]byte, error) {
file := excelize.NewFile()
defer file.Close()
defaultSheet := file.GetSheetName(file.GetActiveSheetIndex())
groups := groupExpenseReportRowsBySheet(items)
orderedSheetNames := orderExpenseReportSheetNames(groups)
if len(orderedSheetNames) == 0 {
if defaultSheet != expenseReportExportSheetName {
if err := file.SetSheetName(defaultSheet, expenseReportExportSheetName); err != nil {
return nil, err
}
}
if err := writeExpenseReportSheet(file, expenseReportExportSheetName, []dto.RepportExpenseListDTO{}); err != nil {
return nil, err
}
} else {
for idx, sheetName := range orderedSheetNames {
if idx == 0 {
if defaultSheet != sheetName {
if err := file.SetSheetName(defaultSheet, sheetName); err != nil {
return nil, err
}
}
} else {
if _, err := file.NewSheet(sheetName); err != nil {
return nil, err
}
}
if err := writeExpenseReportSheet(file, sheetName, groups[sheetName]); err != nil {
return nil, err
}
}
}
buffer, err := file.WriteToBuffer()
if err != nil {
return nil, err
}
return buffer.Bytes(), nil
}
func groupExpenseReportRowsBySheet(items []dto.RepportExpenseListDTO) map[string][]dto.RepportExpenseListDTO {
groups := make(map[string][]dto.RepportExpenseListDTO)
for _, item := range items {
product := resolveExpenseReportProduct(item)
sheetName := resolveExpenseReportSheetName(product)
groups[sheetName] = append(groups[sheetName], item)
}
return groups
}
func orderExpenseReportSheetNames(groups map[string][]dto.RepportExpenseListDTO) []string {
if len(groups) == 0 {
return nil
}
templateSet := make(map[string]struct{}, len(expenseReportTemplateSheetOrder))
ordered := make([]string, 0, len(groups))
for _, sheet := range expenseReportTemplateSheetOrder {
templateSet[sheet] = struct{}{}
if _, ok := groups[sheet]; ok {
ordered = append(ordered, sheet)
}
}
extras := make([]string, 0)
for sheet := range groups {
if _, ok := templateSet[sheet]; !ok {
extras = append(extras, sheet)
}
}
sort.Slice(extras, func(i, j int) bool {
return strings.ToUpper(extras[i]) < strings.ToUpper(extras[j])
})
ordered = append(ordered, extras...)
return ordered
}
func resolveExpenseReportSheetName(product string) string {
normalizedProduct := strings.ToUpper(strings.TrimSpace(product))
if alias, exists := expenseReportSheetAliasMap[normalizedProduct]; exists {
return alias
}
if normalizedProduct == "" {
normalizedProduct = "-"
}
return sanitizeExpenseReportSheetName(normalizedProduct)
}
func sanitizeExpenseReportSheetName(name string) string {
replacer := strings.NewReplacer(
":", " ",
"\\", " ",
"/", " ",
"?", " ",
"*", " ",
"[", " ",
"]", " ",
)
sanitized := strings.TrimSpace(replacer.Replace(name))
if sanitized == "" {
sanitized = "Sheet"
}
runes := []rune(sanitized)
if len(runes) > 31 {
sanitized = string(runes[:31])
}
return sanitized
}
func writeExpenseReportSheet(file *excelize.File, sheet string, items []dto.RepportExpenseListDTO) error {
if err := setExpenseReportTemplateColumns(file, sheet); err != nil {
return err
}
if err := setExpenseReportTemplateHeaders(file, sheet); err != nil {
return err
}
return setExpenseReportTemplateRows(file, sheet, items)
}
func setExpenseReportTemplateColumns(file *excelize.File, sheet string) error {
columnWidths := map[string]float64{
"A": 5.83203125,
"B": 20.83203125,
"C": 20.83203125,
"D": 15.83203125,
"E": 15.83203125,
"F": 15.83203125,
"G": 30.83203125,
"H": 20.83203125,
"I": 15.83203125,
"J": 15.83203125,
"K": 15.83203125,
"L": 20.83203125,
"M": 15.83203125,
"N": 20.83203125,
}
for col, width := range columnWidths {
if err := file.SetColWidth(sheet, col, col, width); err != nil {
return err
}
}
return nil
}
func setExpenseReportTemplateHeaders(file *excelize.File, sheet string) error {
headers := []string{
"No",
"No. PO",
"No. Referensi",
"Tanggal Realisasi",
"Tanggal Transaksi",
"Kategori",
"Produk",
"Lokasi",
"Kandang",
"Qty Pengajuan",
"Harga Pengajuan",
"Total Pengajuan",
"Qty Realisasi",
"Harga Realisasi",
"Total Realisasi",
"Status Pencairan",
}
for i, header := range headers {
columnName, err := excelize.ColumnNumberToName(i + 1)
if err != nil {
return err
}
if err := file.SetCellValue(sheet, columnName+"1", header); err != nil {
return err
}
}
return nil
}
func setExpenseReportTemplateRows(file *excelize.File, sheet string, items []dto.RepportExpenseListDTO) error {
totalQtyPengajuan := 0.0
totalPengajuan := 0.0
totalQtyRealisasi := 0.0
totalRealisasi := 0.0
for idx, item := range items {
row := idx + 2
rowString := strconv.Itoa(row)
produk := resolveExpenseReportProduct(item)
status := formatExpenseReportStatus(item)
lokasi := resolveExpenseReportLocation(item)
kandang := resolveExpenseReportKandang(item)
if err := file.SetCellValue(sheet, "A"+rowString, idx+1); err != nil {
return err
}
if err := file.SetCellValue(sheet, "B"+rowString, safeExpenseReportExportText(item.PoNumber)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "C"+rowString, safeExpenseReportExportText(item.ReferenceNumber)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "D"+rowString, formatExpenseReportOptionalDate(item.RealizationDate)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "E"+rowString, formatExpenseReportDate(item.TransactionDate)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "F"+rowString, safeExpenseReportExportText(item.Category)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "G"+rowString, produk); err != nil {
return err
}
if err := file.SetCellValue(sheet, "H"+rowString, lokasi); err != nil {
return err
}
if err := file.SetCellValue(sheet, "I"+rowString, kandang); err != nil {
return err
}
if err := file.SetCellValue(sheet, "J"+rowString, item.Pengajuan.Qty); err != nil {
return err
}
if err := file.SetCellValue(sheet, "K"+rowString, item.Pengajuan.Price); err != nil {
return err
}
if err := file.SetCellValue(sheet, "L"+rowString, item.TotalPengajuan); err != nil {
return err
}
if err := file.SetCellValue(sheet, "M"+rowString, item.Realisasi.Qty); err != nil {
return err
}
if err := file.SetCellValue(sheet, "N"+rowString, item.Realisasi.Price); err != nil {
return err
}
if err := file.SetCellValue(sheet, "O"+rowString, item.TotalRealisasi); err != nil {
return err
}
if err := file.SetCellValue(sheet, "P"+rowString, status); err != nil {
return err
}
totalQtyPengajuan += item.Pengajuan.Qty
totalPengajuan += item.TotalPengajuan
totalQtyRealisasi += item.Realisasi.Qty
totalRealisasi += item.TotalRealisasi
}
totalRow := strconv.Itoa(len(items) + 2)
if err := file.SetCellValue(sheet, "A"+totalRow, "Total"); err != nil {
return err
}
if err := file.SetCellValue(sheet, "J"+totalRow, totalQtyPengajuan); err != nil {
return err
}
if err := file.SetCellValue(sheet, "K"+totalRow, 0); err != nil {
return err
}
if err := file.SetCellValue(sheet, "L"+totalRow, totalPengajuan); err != nil {
return err
}
if err := file.SetCellValue(sheet, "M"+totalRow, totalQtyRealisasi); err != nil {
return err
}
if err := file.SetCellValue(sheet, "N"+totalRow, 0); err != nil {
return err
}
if err := file.SetCellValue(sheet, "O"+totalRow, totalRealisasi); err != nil {
return err
}
return nil
}
func resolveExpenseReportProduct(item dto.RepportExpenseListDTO) string {
if item.Realisasi.Nonstock != nil {
name := strings.TrimSpace(item.Realisasi.Nonstock.Name)
if name != "" {
return name
}
}
if item.Pengajuan.Nonstock != nil {
name := strings.TrimSpace(item.Pengajuan.Nonstock.Name)
if name != "" {
return name
}
}
return "-"
}
func resolveExpenseReportLocation(item dto.RepportExpenseListDTO) string {
if item.Kandang != nil && item.Kandang.Location != nil {
name := strings.TrimSpace(item.Kandang.Location.Name)
if name != "" {
return name
}
}
return "-"
}
func resolveExpenseReportKandang(item dto.RepportExpenseListDTO) string {
if item.Kandang != nil {
name := strings.TrimSpace(item.Kandang.Name)
if name != "" {
return name
}
}
return "-"
}
func formatExpenseReportStatus(item dto.RepportExpenseListDTO) string {
if item.LatestApproval == nil {
return "-"
}
if item.LatestApproval.Action != nil &&
strings.EqualFold(strings.TrimSpace(*item.LatestApproval.Action), string(entity.ApprovalActionRejected)) {
return "Ditolak"
}
stepName := strings.TrimSpace(item.LatestApproval.StepName)
if stepName == "" {
return "-"
}
return stepName
}
func formatExpenseReportDate(value time.Time) string {
if value.IsZero() {
return "-"
}
location, err := time.LoadLocation("Asia/Jakarta")
if err == nil {
value = value.In(location)
}
return value.Format("02 Jan 2006")
}
func formatExpenseReportOptionalDate(value *time.Time) string {
if value == nil || value.IsZero() {
return "-"
}
return formatExpenseReportDate(*value)
}
func safeExpenseReportExportText(value string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return "-"
}
return trimmed
}
@@ -0,0 +1,281 @@
package controller
import (
"bytes"
"reflect"
"strings"
"testing"
"time"
approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto"
kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto"
locationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto"
nonstockDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/dto"
supplierDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/dto"
"gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto"
"github.com/xuri/excelize/v2"
)
func TestBuildExpenseReportExportWorkbookHeadersAndRows(t *testing.T) {
realizationDate := time.Date(2026, time.April, 23, 0, 0, 0, 0, time.UTC)
items := []dto.RepportExpenseListDTO{
buildExpenseExportTestItem(
"REF-0001",
"PO-0001",
"BOP",
"UPAH",
"Darawati",
"Darawati C1",
time.Date(2026, time.April, 22, 0, 0, 0, 0, time.UTC),
&realizationDate,
2,
10000,
20000,
2,
9000,
18000,
"Realisasi",
nil,
),
buildExpenseExportTestItem(
"REF-0002",
"PO-0002",
"BOP",
"TRANSPORT 2",
"",
"",
time.Date(2026, time.April, 22, 0, 0, 0, 0, time.UTC),
&realizationDate,
1,
50000,
50000,
1,
50000,
50000,
"Pengajuan",
strPtr("REJECTED"),
),
buildExpenseExportTestItem(
"REF-0003",
"PO-0003",
"BOP",
"TRANSPORT",
"Jamali",
"Jamali 1",
time.Date(2026, time.April, 22, 0, 0, 0, 0, time.UTC),
&realizationDate,
3,
12000,
36000,
2,
11000,
22000,
"Selesai",
nil,
),
buildExpenseExportTestItem(
"REF-0004",
"PO-0004",
"BOP",
"TRANSPORT",
"Jamali",
"Jamali 2",
time.Date(2026, time.April, 22, 0, 0, 0, 0, time.UTC),
&realizationDate,
1,
8000,
8000,
1,
8000,
8000,
"Realisasi",
nil,
),
buildExpenseExportTestItem(
"REF-0005",
"PO-0005",
"BOP",
"ZZZ CUSTOM",
"",
"",
time.Date(2026, time.April, 22, 0, 0, 0, 0, time.UTC),
nil,
1,
7000,
7000,
1,
7000,
7000,
"",
nil,
),
}
content, err := buildExpenseReportExportWorkbook(items)
if err != nil {
t.Fatalf("buildExpenseReportExportWorkbook returned error: %v", err)
}
file, err := excelize.OpenReader(bytes.NewReader(content))
if err != nil {
t.Fatalf("failed to open workbook bytes: %v", err)
}
defer file.Close()
expectedSheetOrder := []string{"UPAH", "EKSPEDISI ADE", "EKSPEDISI LTI", "ZZZ CUSTOM"}
if got := file.GetSheetList(); !reflect.DeepEqual(got, expectedSheetOrder) {
t.Fatalf("unexpected sheet order: got %v expected %v", got, expectedSheetOrder)
}
expectedHeaders := map[string]string{
"A1": "No",
"B1": "No. PO",
"C1": "No. Referensi",
"D1": "Tanggal Realisasi",
"E1": "Tanggal Transaksi",
"F1": "Kategori",
"G1": "Produk",
"H1": "Lokasi",
"I1": "Kandang",
"J1": "Qty Pengajuan",
"K1": "Harga Pengajuan",
"L1": "Total Pengajuan",
"M1": "Qty Realisasi",
"N1": "Harga Realisasi",
"O1": "Total Realisasi",
"P1": "Status Pencairan",
}
for cell, expected := range expectedHeaders {
assertExpenseSheetCellEquals(t, file, "UPAH", cell, expected)
}
assertExpenseSheetCellEquals(t, file, "UPAH", "A2", "1")
assertExpenseSheetCellEquals(t, file, "UPAH", "B2", "PO-0001")
assertExpenseSheetCellEquals(t, file, "UPAH", "C2", "REF-0001")
assertExpenseSheetCellEquals(t, file, "UPAH", "D2", "23 Apr 2026")
assertExpenseSheetCellEquals(t, file, "UPAH", "E2", "22 Apr 2026")
assertExpenseSheetCellEquals(t, file, "UPAH", "F2", "BOP")
assertExpenseSheetCellEquals(t, file, "UPAH", "G2", "UPAH")
assertExpenseSheetCellEquals(t, file, "UPAH", "H2", "Darawati")
assertExpenseSheetCellEquals(t, file, "UPAH", "I2", "Darawati C1")
assertExpenseSheetCellEquals(t, file, "UPAH", "J2", "2")
assertExpenseSheetCellEquals(t, file, "UPAH", "K2", "10000")
assertExpenseSheetCellEquals(t, file, "UPAH", "L2", "20000")
assertExpenseSheetCellEquals(t, file, "UPAH", "M2", "2")
assertExpenseSheetCellEquals(t, file, "UPAH", "N2", "9000")
assertExpenseSheetCellEquals(t, file, "UPAH", "O2", "18000")
assertExpenseSheetCellEquals(t, file, "UPAH", "P2", "Realisasi")
assertExpenseSheetCellEquals(t, file, "UPAH", "A3", "Total")
assertExpenseSheetCellEquals(t, file, "UPAH", "J3", "2")
assertExpenseSheetCellEquals(t, file, "UPAH", "K3", "0")
assertExpenseSheetCellEquals(t, file, "UPAH", "L3", "20000")
assertExpenseSheetCellEquals(t, file, "UPAH", "M3", "2")
assertExpenseSheetCellEquals(t, file, "UPAH", "N3", "0")
assertExpenseSheetCellEquals(t, file, "UPAH", "O3", "18000")
assertExpenseSheetCellEquals(t, file, "EKSPEDISI ADE", "G2", "TRANSPORT 2")
assertExpenseSheetCellEquals(t, file, "EKSPEDISI ADE", "P2", "Ditolak")
assertExpenseSheetCellEquals(t, file, "EKSPEDISI ADE", "A3", "Total")
assertExpenseSheetCellEquals(t, file, "EKSPEDISI LTI", "A2", "1")
assertExpenseSheetCellEquals(t, file, "EKSPEDISI LTI", "A3", "2")
assertExpenseSheetCellEquals(t, file, "EKSPEDISI LTI", "G2", "TRANSPORT")
assertExpenseSheetCellEquals(t, file, "EKSPEDISI LTI", "G3", "TRANSPORT")
assertExpenseSheetCellEquals(t, file, "EKSPEDISI LTI", "A4", "Total")
assertExpenseSheetCellEquals(t, file, "EKSPEDISI LTI", "J4", "4")
assertExpenseSheetCellEquals(t, file, "EKSPEDISI LTI", "L4", "44000")
assertExpenseSheetCellEquals(t, file, "EKSPEDISI LTI", "M4", "3")
assertExpenseSheetCellEquals(t, file, "EKSPEDISI LTI", "O4", "30000")
assertExpenseSheetCellEquals(t, file, "ZZZ CUSTOM", "H2", "-")
assertExpenseSheetCellEquals(t, file, "ZZZ CUSTOM", "I2", "-")
assertExpenseSheetCellEquals(t, file, "ZZZ CUSTOM", "D2", "-")
assertExpenseSheetCellEquals(t, file, "ZZZ CUSTOM", "P2", "-")
for _, cell := range []string{"K2", "L2", "N2", "O2"} {
val, err := file.GetCellValue("UPAH", cell)
if err != nil {
t.Fatalf("GetCellValue(UPAH,%s) failed: %v", cell, err)
}
if strings.Contains(val, "Rp") {
t.Fatalf("expected numeric plain value in %s, got %q", cell, val)
}
}
}
func assertExpenseSheetCellEquals(t *testing.T, file *excelize.File, sheet, cell, expected string) {
t.Helper()
got, err := file.GetCellValue(sheet, cell)
if err != nil {
t.Fatalf("GetCellValue(%s,%s) failed: %v", sheet, cell, err)
}
if got != expected {
t.Fatalf("expected %s!%s=%q, got %q", sheet, cell, expected, got)
}
}
func buildExpenseExportTestItem(
reference,
poNumber,
category,
product,
location,
kandang string,
transactionDate time.Time,
realizationDate *time.Time,
qtyPengajuan,
hargaPengajuan,
totalPengajuan,
qtyRealisasi,
hargaRealisasi,
totalRealisasi float64,
stepName string,
action *string,
) dto.RepportExpenseListDTO {
item := dto.RepportExpenseListDTO{
RepportExpenseBaseDTO: dto.RepportExpenseBaseDTO{
ReferenceNumber: reference,
PoNumber: poNumber,
Category: category,
TransactionDate: transactionDate,
RealizationDate: realizationDate,
Supplier: &supplierDTO.SupplierRelationDTO{
Name: "Supplier A",
},
},
Pengajuan: dto.RepportExpensePengajuanDTO{
Qty: qtyPengajuan,
Price: hargaPengajuan,
Nonstock: &nonstockDTO.NonstockRelationDTO{
Name: product,
},
},
Realisasi: dto.RepportExpenseRealisasiDTO{
Qty: qtyRealisasi,
Price: hargaRealisasi,
Nonstock: &nonstockDTO.NonstockRelationDTO{
Name: product,
},
},
TotalPengajuan: totalPengajuan,
TotalRealisasi: totalRealisasi,
LatestApproval: &approvalDTO.ApprovalRelationDTO{
StepName: stepName,
Action: action,
},
}
if kandang != "" {
item.Kandang = &kandangDTO.KandangRelationDTO{Name: kandang}
if location != "" {
item.Kandang.Location = &locationDTO.LocationRelationDTO{Name: location}
}
}
return item
}
func strPtr(value string) *string {
return &value
}
@@ -17,6 +17,7 @@ type RepportExpenseBaseDTO struct {
ReferenceNumber string `json:"reference_number"`
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,