Update
This commit is contained in:
parent
dc23a318cd
commit
5a42523f0f
@ -63,11 +63,18 @@ ENTRYPOINT ["migrate"]
|
|||||||
# Production Stage
|
# Production Stage
|
||||||
FROM debian:bullseye-slim AS production
|
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 \
|
RUN apt-get update && apt-get install -y \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
tzdata \
|
tzdata \
|
||||||
curl \
|
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/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Create non-root user for security
|
# Create non-root user for security
|
||||||
|
|||||||
@ -74,6 +74,7 @@ func (a *App) Initialize(cfg *config.Config) error {
|
|||||||
services.paymentMethodService,
|
services.paymentMethodService,
|
||||||
validators.paymentMethodValidator,
|
validators.paymentMethodValidator,
|
||||||
services.analyticsService,
|
services.analyticsService,
|
||||||
|
services.reportService,
|
||||||
services.tableService,
|
services.tableService,
|
||||||
validators.tableValidator,
|
validators.tableValidator,
|
||||||
services.unitService,
|
services.unitService,
|
||||||
@ -187,6 +188,7 @@ type processors struct {
|
|||||||
tableProcessor *processor.TableProcessor
|
tableProcessor *processor.TableProcessor
|
||||||
unitProcessor *processor.UnitProcessorImpl
|
unitProcessor *processor.UnitProcessorImpl
|
||||||
ingredientProcessor *processor.IngredientProcessorImpl
|
ingredientProcessor *processor.IngredientProcessorImpl
|
||||||
|
fileClient processor.FileClient
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processors {
|
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),
|
tableProcessor: processor.NewTableProcessor(repos.tableRepo, repos.orderRepo),
|
||||||
unitProcessor: processor.NewUnitProcessor(repos.unitRepo),
|
unitProcessor: processor.NewUnitProcessor(repos.unitRepo),
|
||||||
ingredientProcessor: processor.NewIngredientProcessor(repos.ingredientRepo, repos.unitRepo),
|
ingredientProcessor: processor.NewIngredientProcessor(repos.ingredientRepo, repos.unitRepo),
|
||||||
|
fileClient: fileClient,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -227,6 +230,7 @@ type services struct {
|
|||||||
fileService service.FileService
|
fileService service.FileService
|
||||||
customerService service.CustomerService
|
customerService service.CustomerService
|
||||||
analyticsService *service.AnalyticsServiceImpl
|
analyticsService *service.AnalyticsServiceImpl
|
||||||
|
reportService service.ReportService
|
||||||
tableService *service.TableServiceImpl
|
tableService *service.TableServiceImpl
|
||||||
unitService *service.UnitServiceImpl
|
unitService *service.UnitServiceImpl
|
||||||
ingredientService *service.IngredientServiceImpl
|
ingredientService *service.IngredientServiceImpl
|
||||||
@ -248,6 +252,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
|
|||||||
fileService := service.NewFileServiceImpl(processors.fileProcessor)
|
fileService := service.NewFileServiceImpl(processors.fileProcessor)
|
||||||
var customerService service.CustomerService = service.NewCustomerService(processors.customerProcessor)
|
var customerService service.CustomerService = service.NewCustomerService(processors.customerProcessor)
|
||||||
analyticsService := service.NewAnalyticsServiceImpl(processors.analyticsProcessor)
|
analyticsService := service.NewAnalyticsServiceImpl(processors.analyticsProcessor)
|
||||||
|
reportService := service.NewReportService(analyticsService, repos.organizationRepo, repos.outletRepo, processors.fileClient)
|
||||||
tableService := service.NewTableService(processors.tableProcessor, transformer.NewTableTransformer())
|
tableService := service.NewTableService(processors.tableProcessor, transformer.NewTableTransformer())
|
||||||
unitService := service.NewUnitService(processors.unitProcessor)
|
unitService := service.NewUnitService(processors.unitProcessor)
|
||||||
ingredientService := service.NewIngredientService(processors.ingredientProcessor)
|
ingredientService := service.NewIngredientService(processors.ingredientProcessor)
|
||||||
@ -267,6 +272,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
|
|||||||
fileService: fileService,
|
fileService: fileService,
|
||||||
customerService: customerService,
|
customerService: customerService,
|
||||||
analyticsService: analyticsService,
|
analyticsService: analyticsService,
|
||||||
|
reportService: reportService,
|
||||||
tableService: tableService,
|
tableService: tableService,
|
||||||
unitService: unitService,
|
unitService: unitService,
|
||||||
ingredientService: ingredientService,
|
ingredientService: ingredientService,
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type CreateUserRequest struct {
|
type CreateUserRequest struct {
|
||||||
OrganizationID uuid.UUID `json:"organization_id" validate:"required"`
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
Name string `json:"name" validate:"required,min=1,max=255"`
|
Name string `json:"name" validate:"required,min=1,max=255"`
|
||||||
Email string `json:"email" validate:"required,email"`
|
Email string `json:"email" validate:"required,email"`
|
||||||
|
|||||||
55
internal/handler/report_handler.go
Normal file
55
internal/handler/report_handler.go
Normal file
@ -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")
|
||||||
|
}
|
||||||
@ -27,12 +27,16 @@ func NewUserHandler(userService UserService, userValidator UserValidator) *UserH
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *UserHandler) CreateUser(c *gin.Context) {
|
func (h *UserHandler) CreateUser(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
contextInfo := appcontext.FromGinContext(ctx)
|
||||||
|
|
||||||
var req contract.CreateUserRequest
|
var req contract.CreateUserRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
logger.FromContext(c).WithError(err).Error("UserHandler::CreateUser -> request binding failed")
|
logger.FromContext(c).WithError(err).Error("UserHandler::CreateUser -> request binding failed")
|
||||||
h.sendValidationErrorResponse(c, "Invalid request body", constants.MissingFieldErrorCode)
|
h.sendValidationErrorResponse(c, "Invalid request body", constants.MissingFieldErrorCode)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
req.OrganizationID = contextInfo.OrganizationID
|
||||||
|
|
||||||
validationError, validationErrorCode := h.userValidator.ValidateCreateUserRequest(&req)
|
validationError, validationErrorCode := h.userValidator.ValidateCreateUserRequest(&req)
|
||||||
if validationError != nil {
|
if validationError != nil {
|
||||||
|
|||||||
@ -44,7 +44,10 @@ func (m *AuthMiddleware) RequireAuth() gin.HandlerFunc {
|
|||||||
setKeyInContext(c, appcontext.UserRoleKey, userResponse.Role)
|
setKeyInContext(c, appcontext.UserRoleKey, userResponse.Role)
|
||||||
setKeyInContext(c, appcontext.OrganizationIDKey, userResponse.OrganizationID.String())
|
setKeyInContext(c, appcontext.OrganizationIDKey, userResponse.OrganizationID.String())
|
||||||
setKeyInContext(c, appcontext.UserIDKey, userResponse.ID.String())
|
setKeyInContext(c, appcontext.UserIDKey, userResponse.ID.String())
|
||||||
|
|
||||||
|
if (userResponse.Role != "superadmin") {
|
||||||
setKeyInContext(c, appcontext.OutletIDKey, userResponse.OutletID.String())
|
setKeyInContext(c, appcontext.OutletIDKey, userResponse.OutletID.String())
|
||||||
|
}
|
||||||
|
|
||||||
logger.FromContext(c.Request.Context()).Infof("AuthMiddleware::RequireAuth -> User authenticated: %s", userResponse.Email)
|
logger.FromContext(c.Request.Context()).Infof("AuthMiddleware::RequireAuth -> User authenticated: %s", userResponse.Email)
|
||||||
c.Next()
|
c.Next()
|
||||||
|
|||||||
@ -28,6 +28,7 @@ type Router struct {
|
|||||||
customerHandler *handler.CustomerHandler
|
customerHandler *handler.CustomerHandler
|
||||||
paymentMethodHandler *handler.PaymentMethodHandler
|
paymentMethodHandler *handler.PaymentMethodHandler
|
||||||
analyticsHandler *handler.AnalyticsHandler
|
analyticsHandler *handler.AnalyticsHandler
|
||||||
|
reportHandler *handler.ReportHandler
|
||||||
tableHandler *handler.TableHandler
|
tableHandler *handler.TableHandler
|
||||||
unitHandler *handler.UnitHandler
|
unitHandler *handler.UnitHandler
|
||||||
ingredientHandler *handler.IngredientHandler
|
ingredientHandler *handler.IngredientHandler
|
||||||
@ -62,6 +63,7 @@ func NewRouter(cfg *config.Config,
|
|||||||
paymentMethodService service.PaymentMethodService,
|
paymentMethodService service.PaymentMethodService,
|
||||||
paymentMethodValidator validator.PaymentMethodValidator,
|
paymentMethodValidator validator.PaymentMethodValidator,
|
||||||
analyticsService *service.AnalyticsServiceImpl,
|
analyticsService *service.AnalyticsServiceImpl,
|
||||||
|
reportService service.ReportService,
|
||||||
tableService *service.TableServiceImpl,
|
tableService *service.TableServiceImpl,
|
||||||
tableValidator *validator.TableValidator,
|
tableValidator *validator.TableValidator,
|
||||||
unitService handler.UnitService,
|
unitService handler.UnitService,
|
||||||
@ -83,6 +85,7 @@ func NewRouter(cfg *config.Config,
|
|||||||
customerHandler: handler.NewCustomerHandler(customerService, customerValidator),
|
customerHandler: handler.NewCustomerHandler(customerService, customerValidator),
|
||||||
paymentMethodHandler: handler.NewPaymentMethodHandler(paymentMethodService, paymentMethodValidator),
|
paymentMethodHandler: handler.NewPaymentMethodHandler(paymentMethodService, paymentMethodValidator),
|
||||||
analyticsHandler: handler.NewAnalyticsHandler(analyticsService, transformer.NewTransformer()),
|
analyticsHandler: handler.NewAnalyticsHandler(analyticsService, transformer.NewTransformer()),
|
||||||
|
reportHandler: handler.NewReportHandler(reportService, userService),
|
||||||
tableHandler: handler.NewTableHandler(tableService, tableValidator),
|
tableHandler: handler.NewTableHandler(tableService, tableValidator),
|
||||||
unitHandler: handler.NewUnitHandler(unitService),
|
unitHandler: handler.NewUnitHandler(unitService),
|
||||||
ingredientHandler: handler.NewIngredientHandler(ingredientService),
|
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.PUT("/printer-setting/:outlet_id", r.outletSettingHandler.UpdatePrinterSettings)
|
||||||
outlets.GET("/:outlet_id/tables/available", r.tableHandler.GetAvailableTables)
|
outlets.GET("/:outlet_id/tables/available", r.tableHandler.GetAvailableTables)
|
||||||
outlets.GET("/:outlet_id/tables/occupied", r.tableHandler.GetOccupiedTables)
|
outlets.GET("/:outlet_id/tables/occupied", r.tableHandler.GetOccupiedTables)
|
||||||
|
// Reports
|
||||||
|
outlets.GET("/:outlet_id/reports/daily-transaction.pdf", r.reportHandler.GetDailyTransactionReportPDF)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
133
internal/service/report_pdf_util.go
Normal file
133
internal/service/report_pdf_util.go
Normal file
@ -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()
|
||||||
|
}
|
||||||
198
internal/service/report_service.go
Normal file
198
internal/service/report_service.go
Normal file
@ -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
|
||||||
|
}
|
||||||
605
templates/daily_transaction.html
Normal file
605
templates/daily_transaction.html
Normal file
@ -0,0 +1,605 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="id">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Laporan Transaksi Harian - APSKEL</title>
|
||||||
|
<style>
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 20mm 15mm;
|
||||||
|
}
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #2d3748;
|
||||||
|
line-height: 1.6;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
.report-container {
|
||||||
|
max-width: 210mm;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: #ffffff;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
/* Header Section */
|
||||||
|
.report-header {
|
||||||
|
border-bottom: 4px solid #2b6cb0;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
.header-main {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.company-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
.logo-container {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 2px solid #e2e8f0;
|
||||||
|
background: #f7fafc;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.company-details h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #2b6cb0;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
.company-subtitle {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #4a5568;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.report-info {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.report-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #2d3748;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.report-subtitle {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #718096;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.report-meta {
|
||||||
|
background: #f7fafc;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 15px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
.meta-grid {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
.meta-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
.meta-label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #718096;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.meta-value {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2d3748;
|
||||||
|
}
|
||||||
|
/* Section Headers */
|
||||||
|
.section {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
.section-header {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 2px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
.section-number {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #2b6cb0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.section-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #2d3748;
|
||||||
|
}
|
||||||
|
/* Compact Summary Section */
|
||||||
|
.summary-container {
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.summary-header {
|
||||||
|
background: #2b6cb0;
|
||||||
|
color: #fff;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
.summary-header h3 {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.summary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.summary-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 8px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 6px;
|
||||||
|
text-align: center;
|
||||||
|
min-height: 80px;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.summary-label {
|
||||||
|
font-size: 8px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #4a5568;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.summary-value {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #2d3748;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.summary-description {
|
||||||
|
font-size: 8px;
|
||||||
|
color: #718096;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
/* Data Tables */
|
||||||
|
.table-container {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.table-header {
|
||||||
|
background: #f7fafc;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
.table-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #2d3748;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.table-subtitle {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #718096;
|
||||||
|
}
|
||||||
|
.data-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.data-table thead {
|
||||||
|
background: #edf2f7;
|
||||||
|
}
|
||||||
|
.data-table th {
|
||||||
|
padding: 10px 12px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #4a5568;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
border-bottom: 2px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
.data-table th.align-center { text-align: center; }
|
||||||
|
.data-table th.align-right { text-align: right; }
|
||||||
|
.data-table tbody tr {
|
||||||
|
border-bottom: 1px solid #f7fafc;
|
||||||
|
}
|
||||||
|
.data-table tbody tr:nth-child(even) {
|
||||||
|
background-color: #f7fafc;
|
||||||
|
}
|
||||||
|
.data-table tbody tr:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.data-table td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #4a5568;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.data-table td.align-center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.data-table td.align-right {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.data-table td.number-cell {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2d3748;
|
||||||
|
}
|
||||||
|
.item-name {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #2d3748;
|
||||||
|
}
|
||||||
|
/* Financial Summary */
|
||||||
|
.financial-summary {
|
||||||
|
max-width: 400px;
|
||||||
|
margin-left: 0;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.financial-header {
|
||||||
|
background: linear-gradient(135deg, #2d3748 0%, #4a5568 100%);
|
||||||
|
color: #ffffff;
|
||||||
|
padding: 12px 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.financial-header h3 {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.financial-content {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.financial-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-bottom: 1px solid #f7fafc;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.financial-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.financial-row.subtotal {
|
||||||
|
background: #f7fafc;
|
||||||
|
border-bottom: 2px solid #e2e8f0;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.financial-row.final {
|
||||||
|
background: linear-gradient(135deg, #2b6cb0 0%, #3182ce 100%);
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.financial-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
.financial-amount {
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
/* Footer Notes */
|
||||||
|
.report-footer {
|
||||||
|
margin-top: 30px;
|
||||||
|
padding-top: 15px;
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
|
background: #f7fafc;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
.footer-title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #2d3748;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.footer-notes {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.footer-notes li {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #718096;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
padding-left: 12px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.footer-notes li:before {
|
||||||
|
content: "•";
|
||||||
|
color: #2b6cb0;
|
||||||
|
font-weight: bold;
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
/* Fixed Layout for PDF */
|
||||||
|
body {
|
||||||
|
font-size: 10pt;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.report-container {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
max-width: none;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.data-table tbody tr:nth-child(even) {
|
||||||
|
background-color: #f7fafc !important;
|
||||||
|
}
|
||||||
|
.section {
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
.summary-grid {
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
.data-table {
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
.page-break {
|
||||||
|
page-break-before: always;
|
||||||
|
}
|
||||||
|
.no-break {
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="report-container">
|
||||||
|
<!-- HEADER -->
|
||||||
|
<div class="report-header">
|
||||||
|
<div class="header-main">
|
||||||
|
<div class="company-section">
|
||||||
|
<div class="logo-container">
|
||||||
|
<!-- SVG Logo -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500" width="40" height="40">
|
||||||
|
<rect fill="rgb(241,234,249)" x="0" y="0" width="500" height="500" />
|
||||||
|
<g transform="translate(248 204) scale(1.97)">
|
||||||
|
<g transform="translate(18 17)">
|
||||||
|
<path fill="rgb(54,23,94)" d="M 71 60 v 6 a 4 4 0 0 1 -4 4 H 61 a 4 4 0 0 0 0 8 h 6 A 12 12 0 0 0 79 66 V 60 a 4 4 0 0 0 -8 0" />
|
||||||
|
</g>
|
||||||
|
<g transform="translate(-17 -18)">
|
||||||
|
<path fill="rgb(54,23,94)" d="M 30 39 V 33 a 4 4 0 0 1 4 -4 h 6 a 4 4 0 0 0 0 -8 H 34 A 12 12 0 0 0 22 33 v 6 a 4 4 0 0 0 8 0" />
|
||||||
|
</g>
|
||||||
|
<g transform="translate(17 -17)">
|
||||||
|
<path fill="rgb(54,23,94)" d="M 81 40 V 27 a 8 8 0 0 0 -8 -8 H 60.17 A 4.12 4.12 0 0 0 56 22.61 A 4 4 0 0 0 60 27 h 7.34 L 54.17 40.17 a 4 4 0 0 0 5.66 5.66 L 73 32.66 v 7.17 A 4.12 4.12 0 0 0 76.61 44 A 4 4 0 0 0 81 40" />
|
||||||
|
</g>
|
||||||
|
<g transform="translate(-16 16)">
|
||||||
|
<path fill="rgb(54,23,94)" d="M 19 61 V 71 A 10 10 0 0 0 29 81 H 39 A 10 10 0 0 0 49 71 V 61 A 10 10 0 0 0 39 51 H 29 A 10 10 0 0 0 19 61 m 8 0 a 2 2 0 0 1 2 -2 H 39 a 2 2 0 0 1 2 2 V 71 a 2 2 0 0 1 -2 2 H 29 a 2 2 0 0 1 -2 -2 Z" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="company-details">
|
||||||
|
<h1>APSKEL</h1>
|
||||||
|
<div class="company-subtitle">
|
||||||
|
{{.OrganizationName}}
|
||||||
|
{{if .OutletName}}– {{.OutletName}}{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="report-info">
|
||||||
|
<div class="report-title">Laporan Transaksi Harian</div>
|
||||||
|
<div class="report-subtitle">Daily Transaction Report</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="report-meta">
|
||||||
|
<div class="meta-grid">
|
||||||
|
<div class="meta-item">
|
||||||
|
<div class="meta-label">Tanggal Laporan</div>
|
||||||
|
<div class="meta-value">{{.ReportDate}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="meta-item">
|
||||||
|
<div class="meta-label">Periode</div>
|
||||||
|
<div class="meta-value">{{.StartDate}} – {{.EndDate}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="meta-item">
|
||||||
|
<div class="meta-label">Dicetak Oleh</div>
|
||||||
|
<div class="meta-value">{{.GeneratedBy}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="meta-item">
|
||||||
|
<div class="meta-label">Waktu Cetak</div>
|
||||||
|
<div class="meta-value">{{.PrintTime}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 01. RINGKASAN KINERJA -->
|
||||||
|
<div class="section no-break">
|
||||||
|
<div class="section-header">
|
||||||
|
<div class="section-number">01. Ringkasan Kinerja</div>
|
||||||
|
<div class="section-title">Metrik Kinerja Harian</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-container">
|
||||||
|
<div class="summary-header">
|
||||||
|
<h3>Ringkasan Keuangan & Operasional</h3>
|
||||||
|
</div>
|
||||||
|
<div class="summary-grid">
|
||||||
|
<div class="summary-item">
|
||||||
|
<div class="summary-label">Total Transaksi</div>
|
||||||
|
<div class="summary-value">{{.Summary.TotalTransactions}}</div>
|
||||||
|
<div class="summary-description">Jumlah transaksi yang diproses</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-item">
|
||||||
|
<div class="summary-label">Item Terjual</div>
|
||||||
|
<div class="summary-value">{{.Summary.TotalItems}}</div>
|
||||||
|
<div class="summary-description">Total unit produk yang terjual</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-item">
|
||||||
|
<div class="summary-label">Pendapatan Kotor</div>
|
||||||
|
<div class="summary-value">{{.Summary.GrossSales}}</div>
|
||||||
|
<div class="summary-description">Total penjualan sebelum potongan</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-item">
|
||||||
|
<div class="summary-label">Total Diskon</div>
|
||||||
|
<div class="summary-value">{{.Summary.Discount}}</div>
|
||||||
|
<div class="summary-description">Potongan harga yang diberikan</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-item">
|
||||||
|
<div class="summary-label">Pajak</div>
|
||||||
|
<div class="summary-value">{{.Summary.Tax}}</div>
|
||||||
|
<div class="summary-description">Total pajak yang dikumpulkan</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-item">
|
||||||
|
<div class="summary-label">Pendapatan Bersih</div>
|
||||||
|
<div class="summary-value">{{.Summary.NetSales}}</div>
|
||||||
|
<div class="summary-description">Pendapatan setelah diskon & pajak</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-item">
|
||||||
|
<div class="summary-label">HPP (COGS)</div>
|
||||||
|
<div class="summary-value">{{.Summary.COGS}}</div>
|
||||||
|
<div class="summary-description">Harga Pokok Penjualan</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-item">
|
||||||
|
<div class="summary-label">Laba Kotor</div>
|
||||||
|
<div class="summary-value">{{.Summary.GrossProfit}}</div>
|
||||||
|
<div class="summary-description">Keuntungan sebelum biaya operasional</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-item">
|
||||||
|
<div class="summary-label">Margin Laba Kotor</div>
|
||||||
|
<div class="summary-value">{{.Summary.GrossMarginPercent}}%</div>
|
||||||
|
<div class="summary-description">Persentase keuntungan terhadap penjualan</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 02. RINCIAN TRANSAKSI -->
|
||||||
|
<div class="section no-break">
|
||||||
|
<div class="section-header">
|
||||||
|
<div class="section-number">02. Rincian Transaksi</div>
|
||||||
|
<div class="section-title">Detail Penjualan Per Item</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-container">
|
||||||
|
<div class="table-header">
|
||||||
|
<div class="table-title">Breakdown Penjualan Harian</div>
|
||||||
|
<div class="table-subtitle">Rincian transaksi berdasarkan item produk yang terjual</div>
|
||||||
|
</div>
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>No.</th>
|
||||||
|
<th>Nama Item</th>
|
||||||
|
<th class="align-center">Qty</th>
|
||||||
|
<th class="align-right">Penjualan Kotor</th>
|
||||||
|
<th class="align-right">Diskon</th>
|
||||||
|
<th class="align-right">Penjualan Bersih</th>
|
||||||
|
<th class="align-right">HPP</th>
|
||||||
|
<th class="align-right">Laba Kotor</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range $i, $item := .Items}}
|
||||||
|
<tr>
|
||||||
|
<td class="align-center number-cell">{{add $i 1}}</td>
|
||||||
|
<td><span class="item-name">{{$item.Name}}</span></td>
|
||||||
|
<td class="align-center number-cell">{{$item.Quantity}}</td>
|
||||||
|
<td class="align-right number-cell">{{$item.GrossSales}}</td>
|
||||||
|
<td class="align-right number-cell">{{$item.Discount}}</td>
|
||||||
|
<td class="align-right number-cell">{{$item.NetSales}}</td>
|
||||||
|
<td class="align-right number-cell">{{$item.COGS}}</td>
|
||||||
|
<td class="align-right number-cell">{{$item.GrossProfit}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 03. RINGKASAN FINANSIAL -->
|
||||||
|
<div class="section no-break">
|
||||||
|
<div class="section-header">
|
||||||
|
<div class="section-number">03. Ringkasan Finansial</div>
|
||||||
|
<div class="section-title">Perhitungan Laba Rugi</div>
|
||||||
|
</div>
|
||||||
|
<div class="financial-summary">
|
||||||
|
<div class="financial-header">
|
||||||
|
<h3>Laporan Laba Kotor Harian</h3>
|
||||||
|
</div>
|
||||||
|
<div class="financial-content">
|
||||||
|
<div class="financial-row subtotal">
|
||||||
|
<span class="financial-label">Pendapatan Kotor</span>
|
||||||
|
<span class="financial-amount">{{.Summary.GrossSales}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="financial-row">
|
||||||
|
<span class="financial-label">Dikurangi: Diskon</span>
|
||||||
|
<span class="financial-amount">({{.Summary.Discount}})</span>
|
||||||
|
</div>
|
||||||
|
<div class="financial-row">
|
||||||
|
<span class="financial-label">Ditambah: Pajak</span>
|
||||||
|
<span class="financial-amount">{{.Summary.Tax}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="financial-row subtotal">
|
||||||
|
<span class="financial-label">Pendapatan Bersih</span>
|
||||||
|
<span class="financial-amount">{{.Summary.NetSales}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="financial-row">
|
||||||
|
<span class="financial-label">Dikurangi: HPP (COGS)</span>
|
||||||
|
<span class="financial-amount">({{.Summary.COGS}})</span>
|
||||||
|
</div>
|
||||||
|
<div class="financial-row final">
|
||||||
|
<span class="financial-label">Laba Kotor</span>
|
||||||
|
<span class="financial-amount">{{.Summary.GrossProfit}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- FOOTER NOTES -->
|
||||||
|
<div class="report-footer">
|
||||||
|
<div class="footer-title">Catatan Laporan</div>
|
||||||
|
<ul class="footer-notes">
|
||||||
|
<li>Laporan ini dibuat berdasarkan data transaksi yang tercatat dalam sistem APSKEL pada periode yang ditentukan</li>
|
||||||
|
<li>Semua nilai dalam mata uang Rupiah (IDR) kecuali dinyatakan lain dalam dokumen</li>
|
||||||
|
<li>Margin Laba Kotor dihitung sebagai (Laba Kotor ÷ Pendapatan Bersih) × 100%</li>
|
||||||
|
<li>HPP (Harga Pokok Penjualan) mencakup biaya langsung untuk memproduksi atau memperoleh barang yang terjual</li>
|
||||||
|
<li>Data ini dapat digunakan untuk analisis kinerja harian dan perencanaan strategis bisnis</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
x
Reference in New Issue
Block a user