mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-05-20 13:31:56 +00:00
Merge branch 'feat/excel-po-mrk' into 'development'
[FEAT][BE]: add export excel all expenses See merge request mbugroup/lti-api!441
This commit is contained in:
@@ -24,6 +24,8 @@ type ExpenseController struct {
|
|||||||
ExpenseService service.ExpenseService
|
ExpenseService service.ExpenseService
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const expenseExcelExportFetchLimit = 100
|
||||||
|
|
||||||
func NewExpenseController(expenseService service.ExpenseService) *ExpenseController {
|
func NewExpenseController(expenseService service.ExpenseService) *ExpenseController {
|
||||||
return &ExpenseController{
|
return &ExpenseController{
|
||||||
ExpenseService: expenseService,
|
ExpenseService: expenseService,
|
||||||
@@ -56,6 +58,14 @@ func (u *ExpenseController) GetAll(c *fiber.Ctx) error {
|
|||||||
Search: c.Query("search", ""),
|
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 {
|
if query.Page < 1 || query.Limit < 1 {
|
||||||
return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0")
|
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 {
|
func (u *ExpenseController) GetOne(c *fiber.Ctx) error {
|
||||||
param := c.Params("id")
|
param := c.Params("id")
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user