diff --git a/internal/modules/marketing/controllers/deliveryorder.controller.go b/internal/modules/marketing/controllers/deliveryorder.controller.go index 04323bd9..39ab38eb 100644 --- a/internal/modules/marketing/controllers/deliveryorder.controller.go +++ b/internal/modules/marketing/controllers/deliveryorder.controller.go @@ -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") diff --git a/internal/modules/marketing/controllers/deliveryorder.controller_test.go b/internal/modules/marketing/controllers/deliveryorder.controller_test.go new file mode 100644 index 00000000..0ef40b12 --- /dev/null +++ b/internal/modules/marketing/controllers/deliveryorder.controller_test.go @@ -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", + }, + } +} diff --git a/internal/modules/marketing/controllers/deliveryorder.export.go b/internal/modules/marketing/controllers/deliveryorder.export.go new file mode 100644 index 00000000..48751929 --- /dev/null +++ b/internal/modules/marketing/controllers/deliveryorder.export.go @@ -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 +} diff --git a/internal/modules/marketing/controllers/deliveryorder.export_test.go b/internal/modules/marketing/controllers/deliveryorder.export_test.go new file mode 100644 index 00000000..d41a3f6e --- /dev/null +++ b/internal/modules/marketing/controllers/deliveryorder.export_test.go @@ -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 +} diff --git a/internal/modules/purchases/controllers/purchase.controller.go b/internal/modules/purchases/controllers/purchase.controller.go index c252e65e..1616acbf 100644 --- a/internal/modules/purchases/controllers/purchase.controller.go +++ b/internal/modules/purchases/controllers/purchase.controller.go @@ -10,6 +10,7 @@ import ( "time" "gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/dto" service "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/services" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/validations" @@ -24,6 +25,8 @@ type PurchaseController struct { service service.PurchaseService } +const purchaseExcelExportFetchLimit = 100 + func NewPurchaseController(s service.PurchaseService) *PurchaseController { return &PurchaseController{service: s} } @@ -48,20 +51,14 @@ func (ctrl *PurchaseController) GetAll(c *fiber.Ctx) error { return c.Status(fiber.StatusOK).Send(content) } - query := &validation.Query{ - Page: c.QueryInt("page", 1), - Limit: c.QueryInt("limit", 10), - Search: strings.TrimSpace(c.Query("search")), - ApprovalStatus: strings.TrimSpace(c.Query("approval_status")), - PoDate: strings.TrimSpace(c.Query("po_date")), - PoDateFrom: strings.TrimSpace(c.Query("po_date_from")), - PoDateTo: strings.TrimSpace(c.Query("po_date_to")), - CreatedFrom: strings.TrimSpace(c.Query("created_from")), - CreatedTo: strings.TrimSpace(c.Query("created_to")), - SupplierID: uint(c.QueryInt("supplier_id", 0)), - AreaID: uint(c.QueryInt("area_id", 0)), - LocationID: uint(c.QueryInt("location_id", 0)), - ProductCategoryID: strings.TrimSpace(c.Query("product_category_id")), + query := buildPurchaseQuery(c) + + if isAllPurchaseExcelExportRequest(c) { + results, err := ctrl.getAllPurchasesForExcel(c, query) + if err != nil { + return err + } + return exportPurchaseListExcel(c, results) } if query.Page < 1 || query.Limit < 1 { @@ -88,6 +85,51 @@ func (ctrl *PurchaseController) GetAll(c *fiber.Ctx) error { }) } +func buildPurchaseQuery(c *fiber.Ctx) *validation.Query { + return &validation.Query{ + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: strings.TrimSpace(c.Query("search")), + ApprovalStatus: strings.TrimSpace(c.Query("approval_status")), + PoDate: strings.TrimSpace(c.Query("po_date")), + PoDateFrom: strings.TrimSpace(c.Query("po_date_from")), + PoDateTo: strings.TrimSpace(c.Query("po_date_to")), + CreatedFrom: strings.TrimSpace(c.Query("created_from")), + CreatedTo: strings.TrimSpace(c.Query("created_to")), + SupplierID: uint(c.QueryInt("supplier_id", 0)), + AreaID: uint(c.QueryInt("area_id", 0)), + LocationID: uint(c.QueryInt("location_id", 0)), + ProductCategoryID: strings.TrimSpace(c.Query("product_category_id")), + } +} + +func (ctrl *PurchaseController) getAllPurchasesForExcel(c *fiber.Ctx, baseQuery *validation.Query) ([]entity.Purchase, error) { + query := *baseQuery + query.Page = 1 + query.Limit = purchaseExcelExportFetchLimit + + results := make([]entity.Purchase, 0) + for { + pageResults, total, err := ctrl.service.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 (ctrl *PurchaseController) GetOne(c *fiber.Ctx) error { param := c.Params("id") diff --git a/internal/modules/purchases/controllers/purchase.controller_test.go b/internal/modules/purchases/controllers/purchase.controller_test.go new file mode 100644 index 00000000..26fce9c2 --- /dev/null +++ b/internal/modules/purchases/controllers/purchase.controller_test.go @@ -0,0 +1,203 @@ +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" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/validations" + + "github.com/gofiber/fiber/v2" + "github.com/xuri/excelize/v2" +) + +type purchaseServiceStub struct { + getAllCalls []validation.Query +} + +var _ service.PurchaseService = (*purchaseServiceStub)(nil) + +func (s *purchaseServiceStub) GetAll(_ *fiber.Ctx, params *validation.Query) ([]entity.Purchase, int64, error) { + callCopy := *params + s.getAllCalls = append(s.getAllCalls, callCopy) + + switch params.Page { + case 1: + return []entity.Purchase{ + buildPurchaseForControllerTest(1, "PR-00001"), + buildPurchaseForControllerTest(2, "PR-00002"), + }, 3, nil + case 2: + return []entity.Purchase{ + buildPurchaseForControllerTest(3, "PR-00003"), + }, 3, nil + default: + return []entity.Purchase{}, 3, nil + } +} + +func (s *purchaseServiceStub) GetOne(_ *fiber.Ctx, _ uint) (*entity.Purchase, error) { + return &entity.Purchase{}, nil +} + +func (s *purchaseServiceStub) CreateOne(_ *fiber.Ctx, _ *validation.CreatePurchaseRequest) (*entity.Purchase, error) { + return &entity.Purchase{}, nil +} + +func (s *purchaseServiceStub) ApproveStaffPurchase(_ *fiber.Ctx, _ uint, _ *validation.ApproveStaffPurchaseRequest) (*entity.Purchase, error) { + return &entity.Purchase{}, nil +} + +func (s *purchaseServiceStub) ApproveManagerPurchase(_ *fiber.Ctx, _ uint, _ *validation.ApproveManagerPurchaseRequest) (*entity.Purchase, error) { + return &entity.Purchase{}, nil +} + +func (s *purchaseServiceStub) ReceiveProducts(_ *fiber.Ctx, _ uint, _ *validation.ReceivePurchaseRequest) (*entity.Purchase, error) { + return &entity.Purchase{}, nil +} + +func (s *purchaseServiceStub) DeleteItems(_ *fiber.Ctx, _ uint, _ *validation.DeletePurchaseItemsRequest) (*entity.Purchase, error) { + return &entity.Purchase{}, nil +} + +func (s *purchaseServiceStub) DeletePurchase(_ *fiber.Ctx, _ uint) error { + return nil +} + +func (s *purchaseServiceStub) GetProgressRows(_ *fiber.Ctx, _ *exportprogress.Query) ([]exportprogress.Row, error) { + return nil, nil +} + +func TestPurchaseControllerGetAllExportAllIgnoresRequestLimit(t *testing.T) { + app := fiber.New() + stub := &purchaseServiceStub{} + ctrl := NewPurchaseController(stub) + app.Get("/purchases", ctrl.GetAll) + + req := httptest.NewRequest( + http.MethodGet, + "/purchases?export=excel&type=all&page=9&limit=1&search=po&supplier_id=7&area_id=4&location_id=2&product_category_id=1,2&approval_status=pending&po_date_from=2026-01-01&po_date_to=2026-01-31&created_from=2026-02-01&created_to=2026-02-20", + 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, "purchases_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 != purchaseExcelExportFetchLimit || secondCall.Limit != purchaseExcelExportFetchLimit { + t.Fatalf("expected internal limit %d, got %d and %d", purchaseExcelExportFetchLimit, firstCall.Limit, secondCall.Limit) + } + + if firstCall.Search != "po" || + firstCall.SupplierID != 7 || + firstCall.AreaID != 4 || + firstCall.LocationID != 2 || + firstCall.ProductCategoryID != "1,2" || + firstCall.ApprovalStatus != "pending" || + firstCall.PoDateFrom != "2026-01-01" || + firstCall.PoDateTo != "2026-01-31" || + firstCall.CreatedFrom != "2026-02-01" || + firstCall.CreatedTo != "2026-02-20" { + 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() + + if got, _ := file.GetCellValue(purchaseExportSheetName, "A1"); got != "PR Number" { + t.Fatalf("expected A1 header to be PR Number, got %q", got) + } + if got, _ := file.GetCellValue(purchaseExportSheetName, "A2"); got != "PR-00001" { + t.Fatalf("expected first row PR-00001, got %q", got) + } +} + +func TestPurchaseControllerGetAllKeepsPaginationValidationForNonExportAll(t *testing.T) { + app := fiber.New() + stub := &purchaseServiceStub{} + ctrl := NewPurchaseController(stub) + app.Get("/purchases", ctrl.GetAll) + + req := httptest.NewRequest(http.MethodGet, "/purchases?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 buildPurchaseForControllerTest(id uint, prNumber string) entity.Purchase { + poNumber := "PO-" + strings.TrimPrefix(prNumber, "PR-") + poDate := time.Date(2026, time.April, 22, 0, 0, 0, 0, time.UTC) + notes := "catatan" + approvalAction := entity.ApprovalActionApproved + + return entity.Purchase{ + Id: id, + PrNumber: prNumber, + PoNumber: &poNumber, + PoDate: &poDate, + Notes: ¬es, + Supplier: entity.Supplier{ + Id: 10, + Name: "Supplier A", + }, + LatestApproval: &entity.Approval{ + Id: 1, + StepName: "Manager Purchase", + Action: &approvalAction, + }, + Items: []entity.PurchaseItem{ + { + Id: id*10 + 1, + TotalPrice: 1000000, + Product: &entity.Product{ + Id: id*100 + 1, + Name: "Pakan Starter", + }, + }, + }, + } +} diff --git a/internal/modules/purchases/controllers/purchase.export.go b/internal/modules/purchases/controllers/purchase.export.go new file mode 100644 index 00000000..299df22b --- /dev/null +++ b/internal/modules/purchases/controllers/purchase.export.go @@ -0,0 +1,334 @@ +package controller + +import ( + "fmt" + "math" + "sort" + "strconv" + "strings" + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/dto" + + "github.com/gofiber/fiber/v2" + "github.com/xuri/excelize/v2" +) + +const purchaseExportSheetName = "Purchases" + +func isAllPurchaseExcelExportRequest(c *fiber.Ctx) bool { + return strings.EqualFold(strings.TrimSpace(c.Query("export")), "excel") && + strings.EqualFold(strings.TrimSpace(c.Query("type")), "all") +} + +func exportPurchaseListExcel(c *fiber.Ctx, purchases []entity.Purchase) error { + content, err := buildPurchaseExportWorkbook(purchases) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "failed to generate excel file") + } + + filename := fmt.Sprintf("purchases_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 buildPurchaseExportWorkbook(purchases []entity.Purchase) ([]byte, error) { + file := excelize.NewFile() + defer file.Close() + + defaultSheet := file.GetSheetName(file.GetActiveSheetIndex()) + if defaultSheet != purchaseExportSheetName { + if err := file.SetSheetName(defaultSheet, purchaseExportSheetName); err != nil { + return nil, err + } + } + + listItems := dto.ToPurchaseListDTOs(purchases) + grandTotals := buildPurchaseGrandTotalMap(purchases) + + if err := setPurchaseExportColumns(file, purchaseExportSheetName); err != nil { + return nil, err + } + if err := setPurchaseExportHeaders(file, purchaseExportSheetName); err != nil { + return nil, err + } + if err := setPurchaseExportRows(file, purchaseExportSheetName, listItems, grandTotals); err != nil { + return nil, err + } + if err := file.SetPanes(purchaseExportSheetName, &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 setPurchaseExportColumns(file *excelize.File, sheet string) error { + columnWidths := map[string]float64{ + "A": 16, + "B": 16, + "C": 14, + "D": 22, + "E": 18, + "F": 18, + "G": 52, + "H": 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 setPurchaseExportHeaders(file *excelize.File, sheet string) error { + headers := []string{ + "PR Number", + "PO Number", + "Tanggal PO", + "Supplier", + "Status", + "Grand Total", + "Products", + "Notes", + } + + 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", "H1", headerStyle) +} + +func setPurchaseExportRows(file *excelize.File, sheet string, items []dto.PurchaseListDTO, grandTotals map[uint]float64) error { + if len(items) == 0 { + return nil + } + + for i, item := range items { + row := strconv.Itoa(i + 2) + if err := file.SetCellValue(sheet, "A"+row, safePurchaseExportText(item.PrNumber)); err != nil { + return err + } + if err := file.SetCellValue(sheet, "B"+row, safePurchaseExportPointerText(item.PoNumber)); err != nil { + return err + } + if err := file.SetCellValue(sheet, "C"+row, formatPurchaseExportDate(item.PoDate)); err != nil { + return err + } + if err := file.SetCellValue(sheet, "D"+row, safePurchaseSupplierName(item)); err != nil { + return err + } + if err := file.SetCellValue(sheet, "E"+row, formatPurchaseExportStatus(item)); err != nil { + return err + } + if err := file.SetCellValue(sheet, "F"+row, formatPurchaseRupiah(grandTotals[item.Id])); err != nil { + return err + } + if err := file.SetCellValue(sheet, "G"+row, formatPurchaseProducts(item)); err != nil { + return err + } + if err := file.SetCellValue(sheet, "H"+row, safePurchaseExportPointerText(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", "H"+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, "F2", "F"+strconv.Itoa(lastRow), moneyStyle) +} + +func buildPurchaseGrandTotalMap(items []entity.Purchase) map[uint]float64 { + result := make(map[uint]float64, len(items)) + for i := range items { + total := 0.0 + for j := range items[i].Items { + total += items[i].Items[j].TotalPrice + } + result[items[i].Id] = total + } + return result +} + +func safePurchaseSupplierName(item dto.PurchaseListDTO) string { + if item.Supplier == nil { + return "-" + } + return safePurchaseExportText(item.Supplier.Name) +} + +func formatPurchaseExportStatus(item dto.PurchaseListDTO) string { + if item.LatestApproval == nil { + return "-" + } + + if item.LatestApproval.Action != nil && + strings.EqualFold(strings.TrimSpace(*item.LatestApproval.Action), string(entity.ApprovalActionRejected)) { + return "Ditolak" + } + + return safePurchaseExportText(item.LatestApproval.StepName) +} + +func formatPurchaseExportDate(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 formatPurchaseProducts(item dto.PurchaseListDTO) string { + if len(item.Products) == 0 { + return "-" + } + + seen := make(map[string]struct{}) + names := make([]string, 0, len(item.Products)) + for i := range item.Products { + name := strings.TrimSpace(item.Products[i].Name) + if name == "" { + continue + } + if _, exists := seen[name]; exists { + continue + } + seen[name] = struct{}{} + names = append(names, name) + } + + if len(names) == 0 { + return "-" + } + + sort.Strings(names) + return strings.Join(names, ", ") +} + +func safePurchaseExportPointerText(value *string) string { + if value == nil { + return "-" + } + return safePurchaseExportText(*value) +} + +func safePurchaseExportText(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "-" + } + return trimmed +} + +func formatPurchaseRupiah(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() +} diff --git a/internal/modules/purchases/controllers/purchase.export_test.go b/internal/modules/purchases/controllers/purchase.export_test.go new file mode 100644 index 00000000..c440c3e4 --- /dev/null +++ b/internal/modules/purchases/controllers/purchase.export_test.go @@ -0,0 +1,157 @@ +package controller + +import ( + "bytes" + "testing" + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + + "github.com/xuri/excelize/v2" +) + +func TestBuildPurchaseExportWorkbookHeadersAndRows(t *testing.T) { + content, err := buildPurchaseExportWorkbook([]entity.Purchase{ + buildPurchaseForExportTest( + 1, + "PR-00011", + "PO-00011", + time.Date(2026, time.April, 22, 0, 0, 0, 0, time.UTC), + "Supplier A", + "Manager Purchase", + nil, + "catatan", + []entity.PurchaseItem{ + buildPurchaseItemForExportTest(11, "Pakan Starter", 1000000), + buildPurchaseItemForExportTest(12, "Vitamin A", 350000), + buildPurchaseItemForExportTest(11, "Pakan Starter", 0), + }, + ), + buildPurchaseForExportTest( + 2, + "PR-00012", + "", + time.Time{}, + "Supplier B", + "Manager Purchase", + ptrApprovalAction(entity.ApprovalActionRejected), + "", + []entity.PurchaseItem{ + buildPurchaseItemForExportTest(21, "Obat X", 75000), + }, + ), + }) + if err != nil { + t.Fatalf("buildPurchaseExportWorkbook 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": "PR Number", + "B1": "PO Number", + "C1": "Tanggal PO", + "D1": "Supplier", + "E1": "Status", + "F1": "Grand Total", + "G1": "Products", + "H1": "Notes", + } + for cell, expected := range expectedHeaders { + got, err := file.GetCellValue(purchaseExportSheetName, 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) + } + } + + assertPurchaseCellEquals(t, file, "A2", "PR-00011") + assertPurchaseCellEquals(t, file, "B2", "PO-00011") + assertPurchaseCellEquals(t, file, "C2", "22-04-2026") + assertPurchaseCellEquals(t, file, "D2", "Supplier A") + assertPurchaseCellEquals(t, file, "E2", "Manager Purchase") + assertPurchaseCellEquals(t, file, "F2", "Rp 1.350.000") + assertPurchaseCellEquals(t, file, "G2", "Pakan Starter, Vitamin A") + assertPurchaseCellEquals(t, file, "H2", "catatan") + + assertPurchaseCellEquals(t, file, "A3", "PR-00012") + assertPurchaseCellEquals(t, file, "B3", "-") + assertPurchaseCellEquals(t, file, "C3", "-") + assertPurchaseCellEquals(t, file, "E3", "Ditolak") + assertPurchaseCellEquals(t, file, "F3", "Rp 75.000") + assertPurchaseCellEquals(t, file, "G3", "Obat X") + assertPurchaseCellEquals(t, file, "H3", "-") +} + +func assertPurchaseCellEquals(t *testing.T, file *excelize.File, cell, expected string) { + t.Helper() + got, err := file.GetCellValue(purchaseExportSheetName, 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 buildPurchaseForExportTest( + id uint, + prNumber, poNumber string, + poDate time.Time, + supplierName, stepName string, + action *entity.ApprovalAction, + notes string, + items []entity.PurchaseItem, +) entity.Purchase { + var poNumberRef *string + if poNumber != "" { + poNumberRef = &poNumber + } + var poDateRef *time.Time + if !poDate.IsZero() { + poDateRef = &poDate + } + var notesRef *string + if notes != "" { + notesRef = ¬es + } + + return entity.Purchase{ + Id: id, + PrNumber: prNumber, + PoNumber: poNumberRef, + PoDate: poDateRef, + Notes: notesRef, + Supplier: entity.Supplier{ + Id: id + 100, + Name: supplierName, + }, + LatestApproval: &entity.Approval{ + Id: id + 1000, + StepName: stepName, + Action: action, + }, + Items: items, + } +} + +func buildPurchaseItemForExportTest(productID uint, productName string, totalPrice float64) entity.PurchaseItem { + return entity.PurchaseItem{ + ProductId: productID, + TotalPrice: totalPrice, + Product: &entity.Product{ + Id: productID, + Name: productName, + }, + } +} + +func ptrApprovalAction(value entity.ApprovalAction) *entity.ApprovalAction { + return &value +}