add export po and marketing

This commit is contained in:
giovanni
2026-04-22 19:22:29 +07:00
parent 7d223c81ba
commit ff630a1ed0
8 changed files with 1426 additions and 34 deletions
@@ -23,6 +23,8 @@ type DeliveryOrdersController struct {
DeliveryOrdersService service.DeliveryOrdersService
}
const marketingExcelExportFetchLimit = 100
func NewDeliveryOrdersController(deliveryOrdersService service.DeliveryOrdersService) *DeliveryOrdersController {
return &DeliveryOrdersController{
DeliveryOrdersService: deliveryOrdersService,
@@ -49,26 +51,6 @@ func (u *DeliveryOrdersController) GetAll(c *fiber.Ctx) error {
return c.Status(fiber.StatusOK).Send(content)
}
parseUintListParam := func(param string) ([]uint, error) {
if param == "" {
return nil, nil
}
parts := strings.Split(param, ",")
ids := make([]uint, 0, len(parts))
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed == "" {
return nil, strconv.ErrSyntax
}
parsed, err := strconv.ParseUint(trimmed, 10, 64)
if err != nil {
return nil, err
}
ids = append(ids, uint(parsed))
}
return ids, nil
}
productIDs, err := parseUintListParam(c.Query("product_ids", ""))
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid product_ids")
@@ -84,6 +66,14 @@ func (u *DeliveryOrdersController) GetAll(c *fiber.Ctx) error {
MarketingId: uint(c.QueryInt("marketing_id", 0)),
}
if isAllExcelExportRequest(c) {
allResults, err := u.getAllMarketingRowsForExcel(c, query)
if err != nil {
return err
}
return exportMarketingListExcel(c, allResults)
}
if query.Page < 1 || query.Limit < 1 {
return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0")
}
@@ -108,6 +98,56 @@ func (u *DeliveryOrdersController) GetAll(c *fiber.Ctx) error {
})
}
func (u *DeliveryOrdersController) getAllMarketingRowsForExcel(c *fiber.Ctx, baseQuery *validation.DeliveryOrderQuery) ([]dto.MarketingListDTO, error) {
query := *baseQuery
query.Page = 1
query.Limit = marketingExcelExportFetchLimit
results := make([]dto.MarketingListDTO, 0)
for {
pageResults, total, err := u.DeliveryOrdersService.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 parseUintListParam(param string) ([]uint, error) {
if param == "" {
return nil, nil
}
parts := strings.Split(param, ",")
ids := make([]uint, 0, len(parts))
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed == "" {
return nil, strconv.ErrSyntax
}
parsed, err := strconv.ParseUint(trimmed, 10, 64)
if err != nil {
return nil, err
}
ids = append(ids, uint(parsed))
}
return ids, nil
}
func (u *DeliveryOrdersController) GetOne(c *fiber.Ctx) error {
param := c.Params("id")
@@ -0,0 +1,181 @@
package controller
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto"
"gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/dto"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/services"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/validations"
customerDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/dto"
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
"github.com/gofiber/fiber/v2"
"github.com/xuri/excelize/v2"
)
type deliveryOrdersServiceStub struct {
getAllCalls []validation.DeliveryOrderQuery
}
var _ service.DeliveryOrdersService = (*deliveryOrdersServiceStub)(nil)
func (s *deliveryOrdersServiceStub) GetAll(_ *fiber.Ctx, params *validation.DeliveryOrderQuery) ([]dto.MarketingListDTO, int64, error) {
callCopy := *params
callCopy.ProductIDs = append([]uint(nil), params.ProductIDs...)
s.getAllCalls = append(s.getAllCalls, callCopy)
switch params.Page {
case 1:
return []dto.MarketingListDTO{
buildMarketingListForControllerTest("SO-00001"),
buildMarketingListForControllerTest("SO-00002"),
}, 3, nil
case 2:
return []dto.MarketingListDTO{
buildMarketingListForControllerTest("SO-00003"),
}, 3, nil
default:
return []dto.MarketingListDTO{}, 3, nil
}
}
func (s *deliveryOrdersServiceStub) GetOne(_ *fiber.Ctx, _ uint) (*dto.MarketingDetailDTO, error) {
return nil, nil
}
func (s *deliveryOrdersServiceStub) CreateOne(_ *fiber.Ctx, _ *validation.DeliveryOrderCreate) (*dto.MarketingDetailDTO, error) {
return nil, nil
}
func (s *deliveryOrdersServiceStub) UpdateOne(_ *fiber.Ctx, _ *validation.DeliveryOrderUpdate, _ uint) (*dto.MarketingDetailDTO, error) {
return nil, nil
}
func (s *deliveryOrdersServiceStub) BulkApproveToStatus(_ *fiber.Ctx, _ *validation.BulkApprovalRequest, _ approvalutils.ApprovalStep) ([]dto.MarketingDetailDTO, error) {
return nil, nil
}
func (s *deliveryOrdersServiceStub) GetProgressRows(_ *fiber.Ctx, _ *exportprogress.Query) ([]exportprogress.Row, error) {
return nil, nil
}
func TestDeliveryOrdersControllerGetAllExportAllIgnoresRequestLimit(t *testing.T) {
app := fiber.New()
stub := &deliveryOrdersServiceStub{}
ctrl := NewDeliveryOrdersController(stub)
app.Get("/marketing", ctrl.GetAll)
req := httptest.NewRequest(
http.MethodGet,
"/marketing?export=excel&type=all&page=9&limit=1&search=delivery&status=delivery_order&product_ids=1,2&customer_id=7&marketing_id=99",
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, "marketings_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 to use pages 1 and 2, got %d and %d", firstCall.Page, secondCall.Page)
}
if firstCall.Limit != marketingExcelExportFetchLimit || secondCall.Limit != marketingExcelExportFetchLimit {
t.Fatalf("expected internal limit %d, got %d and %d", marketingExcelExportFetchLimit, firstCall.Limit, secondCall.Limit)
}
if firstCall.Status != "delivery order" {
t.Fatalf("expected status to normalize underscore to space, got %q", firstCall.Status)
}
if firstCall.Search != "delivery" {
t.Fatalf("expected search to be forwarded, got %q", firstCall.Search)
}
if !reflect.DeepEqual(firstCall.ProductIDs, []uint{1, 2}) {
t.Fatalf("unexpected product_ids: %+v", firstCall.ProductIDs)
}
if firstCall.CustomerId != 7 || firstCall.MarketingId != 99 {
t.Fatalf("expected customer_id=7 and marketing_id=99, got customer_id=%d marketing_id=%d", firstCall.CustomerId, firstCall.MarketingId)
}
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(marketingExportSheetName, "A1"); got != "No. Order" {
t.Fatalf("expected A1 header to be No. Order, got %q", got)
}
if got, _ := file.GetCellValue(marketingExportSheetName, "A2"); got != "SO-00001" {
t.Fatalf("expected first row order number SO-00001, got %q", got)
}
}
func TestDeliveryOrdersControllerGetAllKeepsPaginationValidationForNonExportAll(t *testing.T) {
app := fiber.New()
stub := &deliveryOrdersServiceStub{}
ctrl := NewDeliveryOrdersController(stub)
app.Get("/marketing", ctrl.GetAll)
req := httptest.NewRequest(http.MethodGet, "/marketing?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 buildMarketingListForControllerTest(orderNumber string) dto.MarketingListDTO {
return dto.MarketingListDTO{
MarketingRelationDTO: dto.MarketingRelationDTO{
SoNumber: orderNumber,
SoDate: time.Date(2026, time.April, 22, 0, 0, 0, 0, time.UTC),
Notes: "tes",
},
Customer: customerDTO.CustomerRelationDTO{
Name: "AJAT",
},
SalesOrder: []dto.DeliveryMarketingProductDTO{
{TotalPrice: 5206200000},
},
LatestApproval: approvalDTO.ApprovalRelationDTO{
StepName: "Pengajuan",
},
}
}
@@ -0,0 +1,310 @@
package controller
import (
"fmt"
"math"
"strconv"
"strings"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/dto"
"github.com/gofiber/fiber/v2"
"github.com/xuri/excelize/v2"
)
const marketingExportSheetName = "Marketings"
func isAllExcelExportRequest(c *fiber.Ctx) bool {
return strings.EqualFold(strings.TrimSpace(c.Query("export")), "excel") &&
strings.EqualFold(strings.TrimSpace(c.Query("type")), "all")
}
func exportMarketingListExcel(c *fiber.Ctx, items []dto.MarketingListDTO) error {
content, err := buildMarketingExportWorkbook(items)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to generate excel file")
}
filename := fmt.Sprintf("marketings_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 buildMarketingExportWorkbook(items []dto.MarketingListDTO) ([]byte, error) {
file := excelize.NewFile()
defer file.Close()
defaultSheet := file.GetSheetName(file.GetActiveSheetIndex())
if defaultSheet != marketingExportSheetName {
if err := file.SetSheetName(defaultSheet, marketingExportSheetName); err != nil {
return nil, err
}
}
if err := setMarketingExportColumns(file, marketingExportSheetName); err != nil {
return nil, err
}
if err := setMarketingExportHeaders(file, marketingExportSheetName); err != nil {
return nil, err
}
if err := setMarketingExportRows(file, marketingExportSheetName, items); err != nil {
return nil, err
}
if err := file.SetPanes(marketingExportSheetName, &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 setMarketingExportColumns(file *excelize.File, sheet string) error {
columnWidths := map[string]float64{
"A": 16,
"B": 14,
"C": 18,
"D": 20,
"E": 18,
"F": 60,
"G": 24,
}
for col, width := range columnWidths {
if err := file.SetColWidth(sheet, col, col, width); err != nil {
return err
}
}
if err := file.SetRowHeight(sheet, 1, 24); err != nil {
return err
}
return nil
}
func setMarketingExportHeaders(file *excelize.File, sheet string) error {
headers := []string{
"No. Order",
"Tanggal",
"Status",
"Customer",
"Grand Total",
"Products",
"Notes",
}
for i, header := range headers {
colName, err := excelize.ColumnNumberToName(i + 1)
if err != nil {
return err
}
cell := colName + "1"
if err := file.SetCellValue(sheet, cell, 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", "G1", headerStyle)
}
func setMarketingExportRows(file *excelize.File, sheet string, items []dto.MarketingListDTO) error {
if len(items) == 0 {
return nil
}
for i, item := range items {
rowNumber := i + 2
if err := file.SetCellValue(sheet, "A"+strconv.Itoa(rowNumber), safeMarketingExportText(item.SoNumber)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "B"+strconv.Itoa(rowNumber), formatMarketingExportDate(item.SoDate)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "C"+strconv.Itoa(rowNumber), formatMarketingExportStatus(item)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "D"+strconv.Itoa(rowNumber), safeMarketingExportText(item.Customer.Name)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "E"+strconv.Itoa(rowNumber), formatMarketingRupiah(sumMarketingGrandTotal(item.SalesOrder))); err != nil {
return err
}
if err := file.SetCellValue(sheet, "F"+strconv.Itoa(rowNumber), formatMarketingProducts(item.SalesOrder)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "G"+strconv.Itoa(rowNumber), safeMarketingExportText(item.Notes)); err != nil {
return err
}
}
lastRow := len(items) + 1
dataStyle, err := file.NewStyle(&excelize.Style{
Alignment: &excelize.Alignment{
Horizontal: "left",
Vertical: "center",
WrapText: true,
},
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", "G"+strconv.Itoa(lastRow), dataStyle); err != nil {
return err
}
moneyStyle, err := file.NewStyle(&excelize.Style{
Alignment: &excelize.Alignment{
Horizontal: "right",
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, "E2", "E"+strconv.Itoa(lastRow), moneyStyle)
}
func formatMarketingExportDate(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-01-2006")
}
func formatMarketingExportStatus(item dto.MarketingListDTO) string {
if item.LatestApproval.Action != nil && strings.EqualFold(strings.TrimSpace(*item.LatestApproval.Action), string(entity.ApprovalActionRejected)) {
return "Ditolak"
}
return safeMarketingExportText(item.LatestApproval.StepName)
}
func formatMarketingProducts(items []dto.DeliveryMarketingProductDTO) string {
if len(items) == 0 {
return "-"
}
seen := make(map[string]struct{})
names := make([]string, 0, len(items))
for _, item := range items {
if item.ProductWarehouse == nil || item.ProductWarehouse.Product == nil {
continue
}
name := strings.TrimSpace(item.ProductWarehouse.Product.Name)
if name == "" {
continue
}
if _, exists := seen[name]; exists {
continue
}
seen[name] = struct{}{}
names = append(names, name)
}
if len(names) == 0 {
return "-"
}
return strings.Join(names, ", ")
}
func sumMarketingGrandTotal(items []dto.DeliveryMarketingProductDTO) float64 {
total := 0.0
for _, item := range items {
total += item.TotalPrice
}
return total
}
func formatMarketingRupiah(value float64) string {
if math.IsNaN(value) || math.IsInf(value, 0) {
return "Rp 0"
}
rounded := int64(math.Round(value))
sign := ""
if rounded < 0 {
sign = "-"
rounded = -rounded
}
raw := strconv.FormatInt(rounded, 10)
if raw == "" {
raw = "0"
}
var grouped strings.Builder
rem := len(raw) % 3
if rem > 0 {
grouped.WriteString(raw[:rem])
if len(raw) > rem {
grouped.WriteString(".")
}
}
for i := rem; i < len(raw); i += 3 {
grouped.WriteString(raw[i : i+3])
if i+3 < len(raw) {
grouped.WriteString(".")
}
}
return "Rp " + sign + grouped.String()
}
func safeMarketingExportText(value string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return "-"
}
return trimmed
}
@@ -0,0 +1,125 @@
package controller
import (
"bytes"
"testing"
"time"
approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto"
productwarehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/dto"
"gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/dto"
customerDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/dto"
productDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/dto"
"github.com/xuri/excelize/v2"
)
func TestBuildMarketingExportWorkbookHeadersAndRows(t *testing.T) {
items := []dto.MarketingListDTO{
{
MarketingRelationDTO: dto.MarketingRelationDTO{
SoNumber: "SO-00762",
SoDate: time.Date(2026, time.April, 22, 0, 0, 0, 0, time.UTC),
Notes: "tes",
},
Customer: customerDTO.CustomerRelationDTO{
Name: "AJAT",
},
SalesOrder: []dto.DeliveryMarketingProductDTO{
buildMarketingProductForExportTest("PAKAN GROWING CRUMBLE 8603 MALINDO", 5206200000),
buildMarketingProductForExportTest("PAKAN GROWING CRUMBLE 8603 MALINDO", 0),
buildMarketingProductForExportTest("295 GOLD PELLET", 0),
},
LatestApproval: approvalDTO.ApprovalRelationDTO{
StepName: "Pengajuan",
},
},
{
MarketingRelationDTO: dto.MarketingRelationDTO{
SoNumber: "SO-00761",
SoDate: time.Date(2026, time.April, 22, 0, 0, 0, 0, time.UTC),
Notes: "",
},
Customer: customerDTO.CustomerRelationDTO{
Name: "DHENIS",
},
SalesOrder: []dto.DeliveryMarketingProductDTO{
buildMarketingProductForExportTest("HS30 FOAM @20 LITER", 75000),
},
LatestApproval: approvalDTO.ApprovalRelationDTO{
StepName: "Delivery Order",
Action: strPtr("REJECTED"),
},
},
}
content, err := buildMarketingExportWorkbook(items)
if err != nil {
t.Fatalf("buildMarketingExportWorkbook 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()
expectedHeaders := map[string]string{
"A1": "No. Order",
"B1": "Tanggal",
"C1": "Status",
"D1": "Customer",
"E1": "Grand Total",
"F1": "Products",
"G1": "Notes",
}
for cell, expected := range expectedHeaders {
got, err := file.GetCellValue(marketingExportSheetName, 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)
}
}
assertCellEquals(t, file, "A2", "SO-00762")
assertCellEquals(t, file, "B2", "22-04-2026")
assertCellEquals(t, file, "C2", "Pengajuan")
assertCellEquals(t, file, "D2", "AJAT")
assertCellEquals(t, file, "E2", "Rp 5.206.200.000")
assertCellEquals(t, file, "F2", "PAKAN GROWING CRUMBLE 8603 MALINDO, 295 GOLD PELLET")
assertCellEquals(t, file, "G2", "tes")
assertCellEquals(t, file, "A3", "SO-00761")
assertCellEquals(t, file, "C3", "Ditolak")
assertCellEquals(t, file, "E3", "Rp 75.000")
assertCellEquals(t, file, "F3", "HS30 FOAM @20 LITER")
assertCellEquals(t, file, "G3", "-")
}
func assertCellEquals(t *testing.T, file *excelize.File, cell, expected string) {
t.Helper()
got, err := file.GetCellValue(marketingExportSheetName, 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 buildMarketingProductForExportTest(name string, totalPrice float64) dto.DeliveryMarketingProductDTO {
return dto.DeliveryMarketingProductDTO{
TotalPrice: totalPrice,
ProductWarehouse: &productwarehouseDTO.ProductWarehousNestedDTO{
Product: &productDTO.ProductRelationDTO{
Name: name,
},
},
}
}
func strPtr(value string) *string {
return &value
}