diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1 @@ +{} diff --git a/internal/constants/user.go b/internal/constants/user.go index 3a0542f..ba0bffb 100644 --- a/internal/constants/user.go +++ b/internal/constants/user.go @@ -3,11 +3,12 @@ package constants type UserRole string const ( - RoleAdmin UserRole = "admin" - RoleManager UserRole = "manager" - RoleCashier UserRole = "cashier" - RoleWaiter UserRole = "waiter" - RoleOwner UserRole = "owner" + RoleAdmin UserRole = "admin" + RoleManager UserRole = "manager" + RoleCashier UserRole = "cashier" + RoleWaiter UserRole = "waiter" + RoleOwner UserRole = "owner" + RolePurchasing UserRole = "purchasing" ) func GetAllUserRoles() []UserRole { @@ -17,6 +18,7 @@ func GetAllUserRoles() []UserRole { RoleCashier, RoleWaiter, RoleOwner, + RolePurchasing, } } diff --git a/internal/contract/user_contract.go b/internal/contract/user_contract.go index bbf0bb4..2af1856 100644 --- a/internal/contract/user_contract.go +++ b/internal/contract/user_contract.go @@ -12,14 +12,14 @@ type CreateUserRequest struct { Name string `json:"name" validate:"required,min=1,max=255"` Email string `json:"email" validate:"required,email"` Password string `json:"password" validate:"required,min=6"` - Role string `json:"role" validate:"required,oneof=admin manager cashier waiter"` + Role string `json:"role" validate:"required,oneof=admin manager cashier waiter owner purchasing"` Permissions map[string]interface{} `json:"permissions,omitempty"` } type UpdateUserRequest struct { Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"` Email *string `json:"email,omitempty" validate:"omitempty,email"` - Role *string `json:"role,omitempty" validate:"omitempty,oneof=admin manager cashier waiter"` + Role *string `json:"role,omitempty" validate:"omitempty,oneof=admin manager cashier waiter owner purchasing"` OutletID *uuid.UUID `json:"outlet_id,omitempty"` IsActive *bool `json:"is_active,omitempty"` Permissions *map[string]interface{} `json:"permissions,omitempty"` diff --git a/internal/entities/user.go b/internal/entities/user.go index d69214b..c5fd221 100644 --- a/internal/entities/user.go +++ b/internal/entities/user.go @@ -13,10 +13,12 @@ import ( type UserRole string const ( - RoleAdmin UserRole = "admin" - RoleManager UserRole = "manager" - RoleCashier UserRole = "cashier" - RoleWaiter UserRole = "waiter" + RoleAdmin UserRole = "admin" + RoleManager UserRole = "manager" + RoleCashier UserRole = "cashier" + RoleWaiter UserRole = "waiter" + RoleOwner UserRole = "owner" + RolePurchasing UserRole = "purchasing" ) type Permissions map[string]interface{} @@ -46,7 +48,7 @@ type User struct { Name string `gorm:"not null;size:255" json:"name" validate:"required,min=1,max=255"` Email string `gorm:"uniqueIndex;not null;size:255" json:"email" validate:"required,email"` PasswordHash string `gorm:"not null;size:255" json:"-"` - Role UserRole `gorm:"not null;size:50" json:"role" validate:"required,oneof=admin manager cashier waiter"` + Role UserRole `gorm:"not null;size:50" json:"role" validate:"required,oneof=admin manager cashier waiter owner purchasing"` Permissions Permissions `gorm:"type:jsonb;default:'{}'" json:"permissions"` IsActive bool `gorm:"default:true" json:"is_active"` CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` diff --git a/internal/handler/report_handler.go b/internal/handler/report_handler.go index dfc5c75..63bf9e2 100644 --- a/internal/handler/report_handler.go +++ b/internal/handler/report_handler.go @@ -66,3 +66,35 @@ func (h *ReportHandler) GetDailyTransactionReportPDF(c *gin.Context) { "file_name": fileName, }), "ReportHandler::GetDailyTransactionReportPDF") } + +func (h *ReportHandler) GetProfitLossReportPDF(c *gin.Context) { + ctx := c.Request.Context() + ci := appcontext.FromGinContext(ctx) + + outletID := h.resolveOutletID(c, ci.OutletID) + var dayPtr *time.Time + if d := c.Query("date"); d != "" { + if t, err := time.Parse("2006-01-02", d); err == nil { + dayPtr = &t + } + } + + user, err := h.userService.GetUserByID(ctx, ci.UserID) + var genBy string + if err != nil { + genBy = ci.UserID.String() + } else { + genBy = user.Name + } + + publicURL, fileName, err := h.reportService.GenerateProfitLossPDF(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::GetProfitLossReportPDF", err.Error())}), "ReportHandler::GetProfitLossReportPDF") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(map[string]string{ + "url": publicURL, + "file_name": fileName, + }), "ReportHandler::GetProfitLossReportPDF") +} diff --git a/internal/models/user.go b/internal/models/user.go index 909d6fc..16fd2ed 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -63,10 +63,12 @@ type UserResponse struct { func (u *User) HasPermission(requiredRole constants.UserRole) bool { roleHierarchy := map[constants.UserRole]int{ - constants.RoleWaiter: 1, - constants.RoleCashier: 2, - constants.RoleManager: 3, - constants.RoleAdmin: 4, + constants.RoleWaiter: 1, + constants.RoleCashier: 2, + constants.RolePurchasing: 3, + constants.RoleManager: 4, + constants.RoleAdmin: 5, + constants.RoleOwner: 6, } userLevel := roleHierarchy[u.Role] diff --git a/internal/router/router.go b/internal/router/router.go index 415ef04..a8433ef 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -620,6 +620,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { outlets.GET("/:outlet_id/tables/occupied", r.tableHandler.GetOccupiedTables) // Reports outlets.GET("/:outlet_id/reports/daily-transaction.pdf", r.reportHandler.GetDailyTransactionReportPDF) + outlets.GET("/:outlet_id/reports/profit-loss.pdf", r.reportHandler.GetProfitLossReportPDF) } // User device routes - accessible by authenticated users for their own devices diff --git a/internal/service/report_service.go b/internal/service/report_service.go index f24e98d..7c113ff 100644 --- a/internal/service/report_service.go +++ b/internal/service/report_service.go @@ -17,6 +17,7 @@ import ( type ReportService interface { // Returns (publicURL, fileName, error) GenerateDailyTransactionPDF(ctx context.Context, organizationID string, outletID string, reportDate *time.Time, generatedBy string) (string, string, error) + GenerateProfitLossPDF(ctx context.Context, organizationID string, outletID string, reportDate *time.Time, generatedBy string) (string, string, error) } type ReportServiceImpl struct { @@ -218,3 +219,296 @@ func getPLPctByID(rows []models.ProfitLossSummaryRow, id string) float64 { } return 0 } + +// profitLossReportData holds data for the profit/loss PDF template +type profitLossReportData struct { + OrganizationName string + MonthName string + ReportDate string + ReportDateUpper string + TotalPenjualan string + TotalBiaya string + LabaRugi string + LabaRugiClass string + LabaRugiValueClass string + LabaRugiMtd string + LabaRugiMtdClass string + LabaRugiMtdValueClass string + MainSummary []profitLossSummaryRowView + PurchasingItems []profitLossPurchasingItem + PurchasingTotal string + GeneratedBy string + PrintTime string +} + +type profitLossSummaryRowView struct { + Number string + Label string + TodayNominal string + TodayPct string + MtdNominal string + MtdPct string + RowClass string + SubItems []profitLossSummaryRowView +} + +type profitLossPurchasingItem struct { + Name string + Amount string +} + +func (s *ReportServiceImpl) GenerateProfitLossPDF(ctx context.Context, organizationID string, outletID string, reportDate *time.Time, generatedBy string) (string, string, error) { + orgID, err := uuid.Parse(organizationID) + if err != nil { + return "", "", fmt.Errorf("invalid organization id: %w", err) + } + + var outID *uuid.UUID + if outletID != "" { + parsed, err := uuid.Parse(outletID) + if err != nil { + return "", "", fmt.Errorf("invalid outlet id: %w", err) + } + outID = &parsed + } + + org, err := s.organizationRepo.GetByID(ctx, orgID) + if err != nil { + return "", "", fmt.Errorf("organization not found: %w", err) + } + + var tzName string + if outID != nil { + outlet, err := s.outletRepo.GetByID(ctx, *outID) + if err == nil && outlet.Timezone != nil && *outlet.Timezone != "" { + tzName = *outlet.Timezone + } + } + if tzName == "" { + tzName = "Asia/Jakarta" + } + + loc, locErr := time.LoadLocation(tzName) + if locErr != nil || loc == nil { + loc = time.Local + } + + 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) + } + + dayStart := day + dayEnd := day.Add(24*time.Hour - time.Nanosecond) + + // MTD: from 1st of month to end of the report day + mtdStart := time.Date(day.Year(), day.Month(), 1, 0, 0, 0, 0, loc) + mtdEnd := dayEnd + + // Get profit/loss analytics for the day + plReq := &models.ProfitLossAnalyticsRequest{ + OrganizationID: orgID, + OutletID: outID, + DateFrom: dayStart, + DateTo: mtdEnd, + GroupBy: "day", + } + pl, err := s.analyticsService.GetProfitLossAnalytics(ctx, plReq) + if err != nil { + return "", "", fmt.Errorf("get profit/loss analytics: %w", err) + } + + // Get purchasing analytics for the day (Rincian Biaya / Catatan) + purchReq := &models.PurchasingAnalyticsRequest{ + OrganizationID: orgID, + OutletID: outID, + DateFrom: dayStart, + DateTo: dayEnd, + GroupBy: "day", + } + purch, err := s.analyticsService.GetPurchasingAnalytics(ctx, purchReq) + if err != nil { + return "", "", fmt.Errorf("get purchasing analytics: %w", err) + } + + // Build summary values + totalOmset := getPLNominalByID(pl.MainSummary, "total_omset") + hpp := getPLNominalByID(pl.MainSummary, "hpp") + _ = mtdStart // used above + + // Total biaya = HPP + operational expenses for the day + totalBiayaToday := hpp + pl.OperationalExpensesTotal + + // Laba/Rugi today + labaRugiToday := totalOmset - totalBiayaToday + + // MTD values + mtdOmset := getMtdNominalByID(pl.MainSummary, "total_omset") + mtdCost := getMtdNominalByID(pl.MainSummary, "hpp") + mtdOps := getMtdNominalByID(pl.MainSummary, "biaya_ops") + mtdGaji := getMtdNominalByID(pl.MainSummary, "biaya_gaji") + labaRugiMtd := mtdOmset - mtdCost - mtdOps - mtdGaji + + // Month name in Indonesian + monthNames := []string{"", "Januari", "Februari", "Maret", "April", "Mei", "Juni", "Juli", "Agustus", "September", "Oktober", "November", "Desember"} + monthName := fmt.Sprintf("%s %d", monthNames[day.Month()], day.Year()) + + reportDateStr := fmt.Sprintf("%d %s %d", day.Day(), monthNames[day.Month()], day.Year()) + reportDateUpper := fmt.Sprintf("%d %s %d", day.Day(), strings.ToUpper(monthNames[day.Month()]), day.Year()) + + // Build main summary rows + mainSummaryRows := buildProfitLossSummaryRows(pl.MainSummary) + + // Build purchasing items from ingredient data + purchItems := make([]profitLossPurchasingItem, 0) + var purchTotal float64 + for _, item := range purch.IngredientData { + purchItems = append(purchItems, profitLossPurchasingItem{ + Name: item.IngredientName, + Amount: formatCurrency(item.TotalCost), + }) + purchTotal += item.TotalCost + } + + // Determine highlight classes + labaRugiClass := "" + labaRugiValueClass := "" + if labaRugiToday < 0 { + labaRugiClass = "highlight-red" + labaRugiValueClass = "negative" + } else { + labaRugiClass = "highlight-green" + labaRugiValueClass = "positive" + } + + labaRugiMtdClass := "" + labaRugiMtdValueClass := "" + if labaRugiMtd < 0 { + labaRugiMtdClass = "highlight-red" + labaRugiMtdValueClass = "negative" + } else { + labaRugiMtdClass = "highlight-green" + labaRugiMtdValueClass = "positive" + } + + data := profitLossReportData{ + OrganizationName: org.Name, + MonthName: monthName, + ReportDate: reportDateStr, + ReportDateUpper: reportDateUpper, + TotalPenjualan: formatCurrency(totalOmset), + TotalBiaya: formatCurrency(totalBiayaToday), + LabaRugi: formatCurrencySigned(labaRugiToday), + LabaRugiClass: labaRugiClass, + LabaRugiValueClass: labaRugiValueClass, + LabaRugiMtd: formatCurrencySigned(labaRugiMtd), + LabaRugiMtdClass: labaRugiMtdClass, + LabaRugiMtdValueClass: labaRugiMtdValueClass, + MainSummary: mainSummaryRows, + PurchasingItems: purchItems, + PurchasingTotal: formatCurrency(purchTotal), + GeneratedBy: generatedBy, + PrintTime: time.Now().In(loc).Format("02/01/2006 15:04:05"), + } + + templatePath := filepath.Join("templates", "profit_loss_report.html") + pdfBytes, err := renderTemplateToPDF(templatePath, data) + if err != nil { + return "", "", fmt.Errorf("render pdf: %w", err) + } + + safeOrg := orgID.String() + safeOutlet := "all" + if outID != nil { + safeOutlet = outID.String() + } + + fileName := fmt.Sprintf("laporan-laba-rugi-%s-%s.pdf", 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 +} + +func getMtdNominalByID(rows []models.ProfitLossSummaryRow, id string) float64 { + for _, row := range rows { + if row.ID == id { + return row.MtdNominal + } + } + return 0 +} + +func buildProfitLossSummaryRows(rows []models.ProfitLossSummaryRow) []profitLossSummaryRowView { + result := make([]profitLossSummaryRowView, 0, len(rows)) + for i, row := range rows { + rowClass := "" + if row.IsBold { + rowClass = "highlight-green-row" + } + // Highlight laba kotor row + if row.ID == "laba_kotor" { + rowClass = "highlight-row" + } + + number := "" + if row.ID != "" { + number = fmt.Sprintf("%d", i+1) + } + + subItems := make([]profitLossSummaryRowView, 0) + for _, sub := range row.SubItems { + subItems = append(subItems, profitLossSummaryRowView{ + Label: sub.Label, + TodayNominal: formatCurrencyOrDash(sub.TodayNominal), + TodayPct: formatPct(sub.TodayPct), + MtdNominal: formatCurrencyOrDash(sub.MtdNominal), + MtdPct: formatPct(sub.MtdPct), + RowClass: "", + }) + } + + result = append(result, profitLossSummaryRowView{ + Number: number, + Label: row.Label, + TodayNominal: formatCurrencyOrDash(row.TodayNominal), + TodayPct: formatPct(row.TodayPct), + MtdNominal: formatCurrencyOrDash(row.MtdNominal), + MtdPct: formatPct(row.MtdPct), + RowClass: rowClass, + SubItems: subItems, + }) + } + return result +} + +func formatCurrencyOrDash(amount float64) string { + if amount == 0 { + return "-" + } + if amount < 0 { + return formatCurrencySigned(amount) + } + return formatCurrency(amount) +} + +func formatCurrencySigned(amount float64) string { + if amount < 0 { + return "(Rp " + addThousandsSep(fmt.Sprintf("%.0f", -amount)) + ")" + } + return "Rp " + addThousandsSep(fmt.Sprintf("%.0f", amount)) +} + +func formatPct(pct float64) string { + if pct == 0 { + return "0%" + } + return fmt.Sprintf("%.0f%%", pct) +} diff --git a/internal/validator/user_validator.go b/internal/validator/user_validator.go index 402c386..2576876 100644 --- a/internal/validator/user_validator.go +++ b/internal/validator/user_validator.go @@ -140,10 +140,12 @@ func (v *UserValidatorImpl) ValidateUserID(userID uuid.UUID) (error, string) { func isValidUserRole(role string) bool { validRoles := map[string]bool{ - string(constants.RoleAdmin): true, - string(constants.RoleManager): true, - string(constants.RoleCashier): true, - string(constants.RoleWaiter): true, + string(constants.RoleAdmin): true, + string(constants.RoleManager): true, + string(constants.RoleCashier): true, + string(constants.RoleWaiter): true, + string(constants.RoleOwner): true, + string(constants.RolePurchasing): true, } return validRoles[role] } diff --git a/migrations/000083_add_owner_purchasing_roles.down.sql b/migrations/000083_add_owner_purchasing_roles.down.sql new file mode 100644 index 0000000..1fcbd8b --- /dev/null +++ b/migrations/000083_add_owner_purchasing_roles.down.sql @@ -0,0 +1,4 @@ +-- Revert to original roles +ALTER TABLE users DROP CONSTRAINT IF EXISTS users_role_check; +UPDATE users SET role = 'admin' WHERE role NOT IN ('admin', 'manager', 'cashier', 'waiter'); +ALTER TABLE users ADD CONSTRAINT users_role_check CHECK (role IN ('admin', 'manager', 'cashier', 'waiter')); diff --git a/migrations/000083_add_owner_purchasing_roles.up.sql b/migrations/000083_add_owner_purchasing_roles.up.sql new file mode 100644 index 0000000..fdee24a --- /dev/null +++ b/migrations/000083_add_owner_purchasing_roles.up.sql @@ -0,0 +1,4 @@ +-- Add 'owner' and 'purchasing' roles to users table +ALTER TABLE users DROP CONSTRAINT IF EXISTS users_role_check; +UPDATE users SET role = 'admin' WHERE role NOT IN ('admin', 'manager', 'cashier', 'waiter', 'owner', 'purchasing'); +ALTER TABLE users ADD CONSTRAINT users_role_check CHECK (role IN ('admin', 'manager', 'cashier', 'waiter', 'owner', 'purchasing')); diff --git a/templates/profit_loss_report.html b/templates/profit_loss_report.html new file mode 100644 index 0000000..0b82673 --- /dev/null +++ b/templates/profit_loss_report.html @@ -0,0 +1,394 @@ + + + + + + Laporan Penjualan Harian + + + +
+ +
+ +
LAPORAN PENJUALAN HARIAN
+
{{.OrganizationName}}
+
+ Bulan: {{.MonthName}} / Tanggal Report: {{.ReportDate}} +
+
+ + +
+
+ TOTAL PENJUALAN {{.ReportDateUpper}} + {{.TotalPenjualan}} +
+
+ TOTAL BIAYA {{.ReportDateUpper}} + {{.TotalBiaya}} +
+
+ LABA/RUGI {{.ReportDateUpper}} + {{.LabaRugi}} +
+
+ LABA/RUGI MTD + {{.LabaRugiMtd}} +
+
+ + +
1. Ringkasan Laporan
+ + + + + + + + + + + + + {{range $i, $row := .MainSummary}} + + + + + + + + + {{range $j, $sub := $row.SubItems}} + + + + + + + + + {{end}} {{end}} + +
NOKETERANGANTANGGAL REPORT
Nominal
%MTD
Nominal
%
{{if $row.Number}}{{$row.Number}}{{end}}{{$row.Label}}{{$row.TodayNominal}}{{$row.TodayPct}}{{$row.MtdNominal}}{{$row.MtdPct}}
{{$sub.Label}}{{$sub.TodayNominal}}{{$sub.TodayPct}}{{$sub.MtdNominal}}{{$sub.MtdPct}}
+ + +
2. Rincian Biaya / Catatan
+ + + + + + + + + + {{range $i, $item := .PurchasingItems}} + + + + + + {{end}} + + + + + + +
NOKETERANGANJUMLAH
{{add $i 1}}{{$item.Name}}{{$item.Amount}}
TOTAL{{.PurchasingTotal}}
+ + + +
+ +