mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-05-20 21:41:55 +00:00
add export excel from api
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user