diff --git a/Dockerfile b/Dockerfile index 76240b0..c2037cf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -63,11 +63,18 @@ ENTRYPOINT ["migrate"] # Production Stage FROM debian:bullseye-slim AS production -# Install minimal runtime dependencies +# Install minimal runtime dependencies + Chrome, Chromium, and wkhtmltopdf for PDF generation RUN apt-get update && apt-get install -y \ ca-certificates \ tzdata \ curl \ + fontconfig \ + wget \ + gnupg \ + && wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | apt-key add - \ + && echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google-chrome.list \ + && apt-get update \ + && apt-get install -y google-chrome-stable chromium wkhtmltopdf \ && rm -rf /var/lib/apt/lists/* # Create non-root user for security diff --git a/internal/app/app.go b/internal/app/app.go index 9493673..3343e0b 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -74,6 +74,7 @@ func (a *App) Initialize(cfg *config.Config) error { services.paymentMethodService, validators.paymentMethodValidator, services.analyticsService, + services.reportService, services.tableService, validators.tableValidator, services.unitService, @@ -187,6 +188,7 @@ type processors struct { tableProcessor *processor.TableProcessor unitProcessor *processor.UnitProcessorImpl ingredientProcessor *processor.IngredientProcessorImpl + fileClient processor.FileClient } func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processors { @@ -209,6 +211,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor tableProcessor: processor.NewTableProcessor(repos.tableRepo, repos.orderRepo), unitProcessor: processor.NewUnitProcessor(repos.unitRepo), ingredientProcessor: processor.NewIngredientProcessor(repos.ingredientRepo, repos.unitRepo), + fileClient: fileClient, } } @@ -227,6 +230,7 @@ type services struct { fileService service.FileService customerService service.CustomerService analyticsService *service.AnalyticsServiceImpl + reportService service.ReportService tableService *service.TableServiceImpl unitService *service.UnitServiceImpl ingredientService *service.IngredientServiceImpl @@ -248,6 +252,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con fileService := service.NewFileServiceImpl(processors.fileProcessor) var customerService service.CustomerService = service.NewCustomerService(processors.customerProcessor) analyticsService := service.NewAnalyticsServiceImpl(processors.analyticsProcessor) + reportService := service.NewReportService(analyticsService, repos.organizationRepo, repos.outletRepo, processors.fileClient) tableService := service.NewTableService(processors.tableProcessor, transformer.NewTableTransformer()) unitService := service.NewUnitService(processors.unitProcessor) ingredientService := service.NewIngredientService(processors.ingredientProcessor) @@ -267,6 +272,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con fileService: fileService, customerService: customerService, analyticsService: analyticsService, + reportService: reportService, tableService: tableService, unitService: unitService, ingredientService: ingredientService, diff --git a/internal/contract/user_contract.go b/internal/contract/user_contract.go index 127c94b..617f5e7 100644 --- a/internal/contract/user_contract.go +++ b/internal/contract/user_contract.go @@ -7,7 +7,7 @@ import ( ) type CreateUserRequest struct { - OrganizationID uuid.UUID `json:"organization_id" validate:"required"` + OrganizationID uuid.UUID `json:"organization_id"` OutletID *uuid.UUID `json:"outlet_id,omitempty"` Name string `json:"name" validate:"required,min=1,max=255"` Email string `json:"email" validate:"required,email"` diff --git a/internal/handler/report_handler.go b/internal/handler/report_handler.go new file mode 100644 index 0000000..f84e88f --- /dev/null +++ b/internal/handler/report_handler.go @@ -0,0 +1,55 @@ +package handler + +import ( + "apskel-pos-be/internal/appcontext" + "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/service" + "apskel-pos-be/internal/util" + "time" + + "github.com/gin-gonic/gin" +) + +type ReportHandler struct { + reportService service.ReportService + userService UserService +} + +func NewReportHandler(reportService service.ReportService, userService UserService) *ReportHandler { + return &ReportHandler{reportService: reportService, userService: userService} +} + +// GET /api/v1/outlets/:outlet_id/reports/daily-transaction.pdf?date=YYYY-MM-DD +func (h *ReportHandler) GetDailyTransactionReportPDF(c *gin.Context) { + ctx := c.Request.Context() + ci := appcontext.FromGinContext(ctx) + + outletID := c.Param("outlet_id") + var dayPtr *time.Time + if d := c.Query("date"); d != "" { + if t, err := time.Parse("2006-01-02", d); err == nil { + dayPtr = &t + } + } + + // Get user name for "Dicetak Oleh" + user, err := h.userService.GetUserByID(ctx, ci.UserID) + var genBy string + if err != nil { + // Fallback to user ID if name cannot be retrieved + genBy = ci.UserID.String() + } else { + genBy = user.Name + } + + publicURL, fileName, err := h.reportService.GenerateDailyTransactionPDF(ctx, ci.OrganizationID.String(), outletID, dayPtr, genBy) + if err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "ReportHandler::GetDailyTransactionReportPDF", err.Error())}), "ReportHandler::GetDailyTransactionReportPDF") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(map[string]string{ + "url": publicURL, + "file_name": fileName, + }), "ReportHandler::GetDailyTransactionReportPDF") +} diff --git a/internal/handler/user_handler.go b/internal/handler/user_handler.go index 18165a6..fedc5e7 100644 --- a/internal/handler/user_handler.go +++ b/internal/handler/user_handler.go @@ -27,12 +27,16 @@ func NewUserHandler(userService UserService, userValidator UserValidator) *UserH } func (h *UserHandler) CreateUser(c *gin.Context) { + ctx := c.Request.Context() + contextInfo := appcontext.FromGinContext(ctx) + var req contract.CreateUserRequest if err := c.ShouldBindJSON(&req); err != nil { logger.FromContext(c).WithError(err).Error("UserHandler::CreateUser -> request binding failed") h.sendValidationErrorResponse(c, "Invalid request body", constants.MissingFieldErrorCode) return } + req.OrganizationID = contextInfo.OrganizationID validationError, validationErrorCode := h.userValidator.ValidateCreateUserRequest(&req) if validationError != nil { diff --git a/internal/middleware/auth_middleware.go b/internal/middleware/auth_middleware.go index 812a605..6bca070 100644 --- a/internal/middleware/auth_middleware.go +++ b/internal/middleware/auth_middleware.go @@ -44,7 +44,10 @@ func (m *AuthMiddleware) RequireAuth() gin.HandlerFunc { setKeyInContext(c, appcontext.UserRoleKey, userResponse.Role) setKeyInContext(c, appcontext.OrganizationIDKey, userResponse.OrganizationID.String()) setKeyInContext(c, appcontext.UserIDKey, userResponse.ID.String()) - setKeyInContext(c, appcontext.OutletIDKey, userResponse.OutletID.String()) + + if (userResponse.Role != "superadmin") { + setKeyInContext(c, appcontext.OutletIDKey, userResponse.OutletID.String()) + } logger.FromContext(c.Request.Context()).Infof("AuthMiddleware::RequireAuth -> User authenticated: %s", userResponse.Email) c.Next() diff --git a/internal/router/router.go b/internal/router/router.go index 14e7c38..7205ed8 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -28,6 +28,7 @@ type Router struct { customerHandler *handler.CustomerHandler paymentMethodHandler *handler.PaymentMethodHandler analyticsHandler *handler.AnalyticsHandler + reportHandler *handler.ReportHandler tableHandler *handler.TableHandler unitHandler *handler.UnitHandler ingredientHandler *handler.IngredientHandler @@ -62,6 +63,7 @@ func NewRouter(cfg *config.Config, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, + reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, @@ -83,6 +85,7 @@ func NewRouter(cfg *config.Config, customerHandler: handler.NewCustomerHandler(customerService, customerValidator), paymentMethodHandler: handler.NewPaymentMethodHandler(paymentMethodService, paymentMethodValidator), analyticsHandler: handler.NewAnalyticsHandler(analyticsService, transformer.NewTransformer()), + reportHandler: handler.NewReportHandler(reportService, userService), tableHandler: handler.NewTableHandler(tableService, tableValidator), unitHandler: handler.NewUnitHandler(unitService), ingredientHandler: handler.NewIngredientHandler(ingredientService), @@ -296,6 +299,8 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { outlets.PUT("/printer-setting/:outlet_id", r.outletSettingHandler.UpdatePrinterSettings) outlets.GET("/:outlet_id/tables/available", r.tableHandler.GetAvailableTables) outlets.GET("/:outlet_id/tables/occupied", r.tableHandler.GetOccupiedTables) + // Reports + outlets.GET("/:outlet_id/reports/daily-transaction.pdf", r.reportHandler.GetDailyTransactionReportPDF) } } } diff --git a/internal/service/report_pdf_util.go b/internal/service/report_pdf_util.go new file mode 100644 index 0000000..f3688c9 --- /dev/null +++ b/internal/service/report_pdf_util.go @@ -0,0 +1,133 @@ +package service + +import ( + "bytes" + "fmt" + "html/template" + "os" + "os/exec" + "path/filepath" +) + +func renderTemplateToPDF(templatePath string, data interface{}) ([]byte, error) { + // Create template with custom functions + funcMap := template.FuncMap{ + "add": func(a, b int) int { + return a + b + }, + } + + // Parse and execute HTML template + tmpl, err := template.New(filepath.Base(templatePath)).Funcs(funcMap).ParseFiles(templatePath) + if err != nil { + return nil, fmt.Errorf("parse template: %w", err) + } + var htmlBuf bytes.Buffer + if err := tmpl.Execute(&htmlBuf, data); err != nil { + return nil, fmt.Errorf("execute template: %w", err) + } + + // Write HTML to a temp file + tmpDir, err := os.MkdirTemp("", "daily_report_") + if err != nil { + return nil, fmt.Errorf("tmp dir: %w", err) + } + defer os.RemoveAll(tmpDir) + + htmlPath := filepath.Join(tmpDir, "report.html") + pdfPath := filepath.Join(tmpDir, "report.pdf") + + if err := os.WriteFile(htmlPath, htmlBuf.Bytes(), 0644); err != nil { + return nil, fmt.Errorf("write html: %w", err) + } + + // Use Chrome headless for better CSS rendering + chromeArgs := []string{ + "--headless", + "--disable-gpu", + "--no-sandbox", + "--disable-dev-shm-usage", + "--print-to-pdf=" + pdfPath, + "--print-to-pdf-no-header", + "--print-to-pdf-no-footer", + "--print-to-pdf-margin-top=0", + "--print-to-pdf-margin-bottom=0", + "--print-to-pdf-margin-left=0", + "--print-to-pdf-margin-right=0", + "--print-to-pdf-paper-width=210mm", + "--print-to-pdf-paper-height=297mm", + htmlPath, + } + + // Try Google Chrome first, then Chromium, then wkhtmltopdf as fallback + chromeCmd := exec.Command("google-chrome", chromeArgs...) + if out, err := chromeCmd.CombinedOutput(); err != nil { + // Fallback to Chromium + chromiumCmd := exec.Command("chromium", chromeArgs...) + if chromiumOut, err := chromiumCmd.CombinedOutput(); err != nil { + // Final fallback to wkhtmltopdf + wkhtmlArgs := []string{ + "--enable-local-file-access", + "--dpi", "300", + "--page-size", "A4", + "--orientation", "Portrait", + "--margin-top", "0", + "--margin-bottom", "0", + "--margin-left", "0", + "--margin-right", "0", + htmlPath, + pdfPath, + } + wkhtmlCmd := exec.Command("wkhtmltopdf", wkhtmlArgs...) + if wkhtmlOut, err := wkhtmlCmd.CombinedOutput(); err != nil { + return nil, fmt.Errorf("all PDF generators failed. Chrome error: %v, Chromium error: %v, wkhtmltopdf error: %v, chrome output: %s, chromium output: %s, wkhtmltopdf output: %s", chromeCmd.Err, chromiumCmd.Err, err, string(out), string(chromiumOut), string(wkhtmlOut)) + } + } + } + + // Read PDF bytes + pdfBytes, err := os.ReadFile(pdfPath) + if err != nil { + return nil, fmt.Errorf("read pdf: %w", err) + } + return pdfBytes, nil +} + +func formatCurrency(amount float64) string { + // Simple currency formatting: Rp with thousands separators + // Note: For exact locale formatting, integrate a locale library later + s := fmt.Sprintf("%.0f", amount) // Remove decimal places for cleaner display + intPart := addThousandsSep(s) + return "Rp " + intPart +} + +func splitAmount(s string) (string, string) { + for i := len(s) - 1; i >= 0; i-- { + if s[i] == '.' { + return s[:i], s[i+1:] + } + } + return s, "00" +} + +func addThousandsSep(s string) string { + n := len(s) + if n <= 3 { + return s + } + var out bytes.Buffer + pre := n % 3 + if pre > 0 { + out.WriteString(s[:pre]) + if n > pre { + out.WriteByte('.') + } + } + for i := pre; i < n; i += 3 { + out.WriteString(s[i : i+3]) + if i+3 < n { + out.WriteByte('.') + } + } + return out.String() +} diff --git a/internal/service/report_service.go b/internal/service/report_service.go new file mode 100644 index 0000000..6a497cb --- /dev/null +++ b/internal/service/report_service.go @@ -0,0 +1,198 @@ +package service + +import ( + "apskel-pos-be/internal/repository" + "context" + "fmt" + "path/filepath" + "strings" + "time" + + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/processor" + + "github.com/google/uuid" +) + +type ReportService interface { + // Returns (publicURL, fileName, error) + GenerateDailyTransactionPDF(ctx context.Context, organizationID string, outletID string, reportDate *time.Time, generatedBy string) (string, string, error) +} + +type ReportServiceImpl struct { + analyticsService AnalyticsService + organizationRepo *repository.OrganizationRepositoryImpl + outletRepo *repository.OutletRepositoryImpl + fileClient processor.FileClient +} + +func NewReportService(analyticsService *AnalyticsServiceImpl, organizationRepo *repository.OrganizationRepositoryImpl, outletRepo *repository.OutletRepositoryImpl, fileClient processor.FileClient) *ReportServiceImpl { + return &ReportServiceImpl{ + analyticsService: analyticsService, + organizationRepo: organizationRepo, + outletRepo: outletRepo, + fileClient: fileClient, + } +} + +// reportTemplateData holds the data passed to the HTML template +type reportTemplateData struct { + OrganizationName string + OutletName string + ReportDate string + StartDate string + EndDate string + GeneratedBy string + PrintTime string + Summary reportSummary + Items []reportItem +} + +type reportSummary struct { + TotalTransactions int64 + TotalItems int64 + GrossSales string + Discount string + Tax string + NetSales string + COGS string + GrossProfit string + GrossMarginPercent string +} + +type reportItem struct { + Name string + Quantity int64 + GrossSales string + Discount string + NetSales string + COGS string + GrossProfit string +} + +func (s *ReportServiceImpl) GenerateDailyTransactionPDF(ctx context.Context, organizationID string, outletID string, reportDate *time.Time, generatedBy string) (string, string, error) { + // Parse IDs + orgID, err := uuid.Parse(organizationID) + if err != nil { + return "", "", fmt.Errorf("invalid organization id: %w", err) + } + outID, err := uuid.Parse(outletID) + if err != nil { + return "", "", fmt.Errorf("invalid outlet id: %w", err) + } + + // Resolve organization and outlet names + org, err := s.organizationRepo.GetByID(ctx, orgID) + if err != nil { + return "", "", fmt.Errorf("organization not found: %w", err) + } + outlet, err := s.outletRepo.GetByID(ctx, outID) + if err != nil { + return "", "", fmt.Errorf("outlet not found: %w", err) + } + + // Determine timezone (fallback to system local if not available) + tzName := "Asia/Jakarta" + if outlet.Timezone != nil && *outlet.Timezone != "" { + tzName = *outlet.Timezone + } + loc, locErr := time.LoadLocation(tzName) + if locErr != nil || loc == nil { + loc = time.Local + } + + // Compute day range in the chosen location + var day time.Time + if reportDate != nil { + t := reportDate.UTC() + day = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc) + } else { + now := time.Now().In(loc) + day = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc) + } + start := day + end := day.Add(24*time.Hour - time.Nanosecond) + + // Build requests + salesReq := &models.SalesAnalyticsRequest{OrganizationID: orgID, OutletID: &outID, DateFrom: start, DateTo: end, GroupBy: "day"} + plReq := &models.ProfitLossAnalyticsRequest{OrganizationID: orgID, OutletID: &outID, DateFrom: start, DateTo: end, GroupBy: "day"} + + // Call services + sales, err := s.analyticsService.GetSalesAnalytics(ctx, salesReq) + if err != nil { + return "", "", fmt.Errorf("get sales analytics: %w", err) + } + pl, err := s.analyticsService.GetProfitLossAnalytics(ctx, plReq) + if err != nil { + return "", "", fmt.Errorf("get profit/loss analytics: %w", err) + } + + // Compose template data + data := reportTemplateData{ + OrganizationName: org.Name, + OutletName: outlet.Name, + ReportDate: day.Format("02/01/2006"), + StartDate: start.Format("02/01/2006 15:04"), + EndDate: end.Format("02/01/2006 15:04"), + GeneratedBy: generatedBy, + PrintTime: time.Now().Format("02/01/2006 15:04:05"), + Summary: reportSummary{ + TotalTransactions: pl.Summary.TotalOrders, + TotalItems: sales.Summary.TotalItems, + GrossSales: formatCurrency(pl.Summary.TotalRevenue), + Discount: formatCurrency(pl.Summary.TotalDiscount), + Tax: formatCurrency(pl.Summary.TotalTax), + NetSales: formatCurrency(sales.Summary.NetSales), + COGS: formatCurrency(pl.Summary.TotalCost), + GrossProfit: formatCurrency(pl.Summary.GrossProfit), + GrossMarginPercent: fmt.Sprintf("%.2f", pl.Summary.GrossProfitMargin), + }, + } + + // Items by product + items := make([]reportItem, 0, len(pl.ProductData)) + for _, p := range pl.ProductData { + items = append(items, reportItem{ + Name: p.ProductName, + Quantity: p.QuantitySold, + GrossSales: formatCurrency(p.Revenue), + Discount: formatCurrency(0), + NetSales: formatCurrency(p.Revenue), + COGS: formatCurrency(p.Cost), + GrossProfit: formatCurrency(p.GrossProfit), + }) + } + data.Items = items + + // Render to PDF + templatePath := filepath.Join("templates", "daily_transaction.html") + pdfBytes, err := renderTemplateToPDF(templatePath, data) + if err != nil { + return "", "", fmt.Errorf("render pdf: %w", err) + } + + // Upload to bucket + safeOutlet := outID.String() + safeOrg := orgID.String() + + // Clean outlet name for filename (remove spaces and special characters) + cleanOutletName := strings.ReplaceAll(outlet.Name, " ", "-") + cleanOutletName = strings.ReplaceAll(cleanOutletName, "/", "-") + cleanOutletName = strings.ReplaceAll(cleanOutletName, "\\", "-") + cleanOutletName = strings.ReplaceAll(cleanOutletName, ":", "-") + cleanOutletName = strings.ReplaceAll(cleanOutletName, "*", "-") + cleanOutletName = strings.ReplaceAll(cleanOutletName, "?", "-") + cleanOutletName = strings.ReplaceAll(cleanOutletName, "\"", "-") + cleanOutletName = strings.ReplaceAll(cleanOutletName, "<", "-") + cleanOutletName = strings.ReplaceAll(cleanOutletName, ">", "-") + cleanOutletName = strings.ReplaceAll(cleanOutletName, "|", "-") + + fileName := fmt.Sprintf("laporan-transaksi-harian-%s-%s-%s.pdf", cleanOutletName, day.Format("2006-01-02"), time.Now().Format("20060102-150405")) + objectKey := fmt.Sprintf("/reports/%s/%s/%s", safeOrg, safeOutlet, fileName) + publicURL, err := s.fileClient.UploadFile(ctx, objectKey, pdfBytes) + if err != nil { + return "", "", fmt.Errorf("upload pdf: %w", err) + } + + return publicURL, fileName, nil +} diff --git a/templates/daily_transaction.html b/templates/daily_transaction.html new file mode 100644 index 0000000..b9e788e --- /dev/null +++ b/templates/daily_transaction.html @@ -0,0 +1,605 @@ + + + + + + Laporan Transaksi Harian - APSKEL + + + +
+ +
+
+
+
+ + + + + + + + + + + + + + + + + + +
+
+

APSKEL

+
+ {{.OrganizationName}} + {{if .OutletName}}– {{.OutletName}}{{end}} +
+
+
+
+
Laporan Transaksi Harian
+
Daily Transaction Report
+
+
+
+
+
+
Tanggal Laporan
+
{{.ReportDate}}
+
+
+
Periode
+
{{.StartDate}} – {{.EndDate}}
+
+
+
Dicetak Oleh
+
{{.GeneratedBy}}
+
+
+
Waktu Cetak
+
{{.PrintTime}}
+
+
+
+
+ + +
+
+
01. Ringkasan Kinerja
+
Metrik Kinerja Harian
+
+
+
+

Ringkasan Keuangan & Operasional

+
+
+
+
Total Transaksi
+
{{.Summary.TotalTransactions}}
+
Jumlah transaksi yang diproses
+
+
+
Item Terjual
+
{{.Summary.TotalItems}}
+
Total unit produk yang terjual
+
+
+
Pendapatan Kotor
+
{{.Summary.GrossSales}}
+
Total penjualan sebelum potongan
+
+
+
Total Diskon
+
{{.Summary.Discount}}
+
Potongan harga yang diberikan
+
+
+
Pajak
+
{{.Summary.Tax}}
+
Total pajak yang dikumpulkan
+
+
+
Pendapatan Bersih
+
{{.Summary.NetSales}}
+
Pendapatan setelah diskon & pajak
+
+
+
HPP (COGS)
+
{{.Summary.COGS}}
+
Harga Pokok Penjualan
+
+
+
Laba Kotor
+
{{.Summary.GrossProfit}}
+
Keuntungan sebelum biaya operasional
+
+
+
Margin Laba Kotor
+
{{.Summary.GrossMarginPercent}}%
+
Persentase keuntungan terhadap penjualan
+
+
+
+
+ + +
+
+
02. Rincian Transaksi
+
Detail Penjualan Per Item
+
+
+
+
Breakdown Penjualan Harian
+
Rincian transaksi berdasarkan item produk yang terjual
+
+ + + + + + + + + + + + + + + {{range $i, $item := .Items}} + + + + + + + + + + + {{end}} + +
No.Nama ItemQtyPenjualan KotorDiskonPenjualan BersihHPPLaba Kotor
{{add $i 1}}{{$item.Name}}{{$item.Quantity}}{{$item.GrossSales}}{{$item.Discount}}{{$item.NetSales}}{{$item.COGS}}{{$item.GrossProfit}}
+
+
+ + +
+
+
03. Ringkasan Finansial
+
Perhitungan Laba Rugi
+
+
+
+

Laporan Laba Kotor Harian

+
+
+
+ Pendapatan Kotor + {{.Summary.GrossSales}} +
+
+ Dikurangi: Diskon + ({{.Summary.Discount}}) +
+
+ Ditambah: Pajak + {{.Summary.Tax}} +
+
+ Pendapatan Bersih + {{.Summary.NetSales}} +
+
+ Dikurangi: HPP (COGS) + ({{.Summary.COGS}}) +
+
+ Laba Kotor + {{.Summary.GrossProfit}} +
+
+
+
+ + + + +
+ +