Compare commits

..

No commits in common. "30dff1727284de577e4ec860d851b6ee37c6b254" and "f8c732f0ff15db1c192707eedee8652e965dcae6" have entirely different histories.

8 changed files with 60 additions and 267 deletions

View File

@ -25,12 +25,11 @@ import (
) )
type App struct { type App struct {
server *http.Server server *http.Server
db *gorm.DB db *gorm.DB
redisClient *redis.Client redisClient *redis.Client
router *router.Router router *router.Router
shutdown chan os.Signal shutdown chan os.Signal
omsetScheduler *service.OmsetMilestoneScheduler
} }
func NewApp(db *gorm.DB, redisClient *redis.Client) *App { func NewApp(db *gorm.DB, redisClient *redis.Client) *App {
@ -44,14 +43,6 @@ func NewApp(db *gorm.DB, redisClient *redis.Client) *App {
func (a *App) Initialize(cfg *config.Config) error { func (a *App) Initialize(cfg *config.Config) error {
repos := a.initRepositories() repos := a.initRepositories()
processors := a.initProcessors(cfg, repos) processors := a.initProcessors(cfg, repos)
// Initialize omset milestone scheduler
a.omsetScheduler = service.NewOmsetMilestoneScheduler(
repos.organizationRepo,
repos.userRepo,
processors.notificationProcessor,
)
services := a.initServices(processors, repos, cfg) services := a.initServices(processors, repos, cfg)
validators := a.initValidators() validators := a.initValidators()
middleware := a.initMiddleware(services, cfg) middleware := a.initMiddleware(services, cfg)
@ -138,11 +129,6 @@ func (a *App) Initialize(cfg *config.Config) error {
} }
func (a *App) Start(port string) error { func (a *App) Start(port string) error {
// Start the omset milestone scheduler (checks every hour)
if a.omsetScheduler != nil {
a.omsetScheduler.Start(1 * time.Hour)
}
engine := a.router.Init() engine := a.router.Init()
a.server = &http.Server{ a.server = &http.Server{
@ -178,9 +164,6 @@ func (a *App) Start(port string) error {
} }
func (a *App) Shutdown() { func (a *App) Shutdown() {
if a.omsetScheduler != nil {
a.omsetScheduler.Stop()
}
close(a.shutdown) close(a.shutdown)
} }

View File

@ -7,7 +7,6 @@ const (
RoleManager UserRole = "manager" RoleManager UserRole = "manager"
RoleCashier UserRole = "cashier" RoleCashier UserRole = "cashier"
RoleWaiter UserRole = "waiter" RoleWaiter UserRole = "waiter"
RoleOwner UserRole = "owner"
) )
func GetAllUserRoles() []UserRole { func GetAllUserRoles() []UserRole {
@ -16,7 +15,6 @@ func GetAllUserRoles() []UserRole {
RoleManager, RoleManager,
RoleCashier, RoleCashier,
RoleWaiter, RoleWaiter,
RoleOwner,
} }
} }

View File

@ -7,11 +7,11 @@ import (
) )
type PaymentMethodAnalyticsRequest struct { type PaymentMethodAnalyticsRequest struct {
OrganizationID uuid.UUID `form:"organization_id"` OrganizationID uuid.UUID `form:"organization_id"`
OutletID *string `form:"outlet_id,omitempty"` OutletID *uuid.UUID `form:"outlet_id,omitempty"`
DateFrom string `form:"date_from" validate:"required"` DateFrom string `form:"date_from" validate:"required"`
DateTo string `form:"date_to" validate:"required"` DateTo string `form:"date_to" validate:"required"`
GroupBy string `form:"group_by,default=day" validate:"omitempty,oneof=day hour week month"` GroupBy string `form:"group_by,default=day" validate:"omitempty,oneof=day hour week month"`
} }
// PaymentMethodAnalyticsResponse represents the response for payment method analytics // PaymentMethodAnalyticsResponse represents the response for payment method analytics
@ -45,10 +45,10 @@ type PaymentMethodAnalyticsData struct {
type SalesAnalyticsRequest struct { type SalesAnalyticsRequest struct {
OrganizationID uuid.UUID OrganizationID uuid.UUID
OutletID *string `form:"outlet_id,omitempty"` OutletID *uuid.UUID `form:"outlet_id,omitempty"`
DateFrom string `form:"date_from" validate:"required"` DateFrom string `form:"date_from" validate:"required"`
DateTo string `form:"date_to" validate:"required"` DateTo string `form:"date_to" validate:"required"`
GroupBy string `form:"group_by,default=day" validate:"omitempty,oneof=day hour week month"` GroupBy string `form:"group_by,default=day" validate:"omitempty,oneof=day hour week month"`
} }
type SalesAnalyticsResponse struct { type SalesAnalyticsResponse struct {
@ -86,10 +86,10 @@ type SalesAnalyticsData struct {
// ProductAnalyticsRequest represents the request for product analytics // ProductAnalyticsRequest represents the request for product analytics
type ProductAnalyticsRequest struct { type ProductAnalyticsRequest struct {
OrganizationID uuid.UUID OrganizationID uuid.UUID
OutletID *string `form:"outlet_id,omitempty"` OutletID *uuid.UUID `form:"outlet_id,omitempty"`
DateFrom string `form:"date_from" validate:"required"` DateFrom string `form:"date_from" validate:"required"`
DateTo string `form:"date_to" validate:"required"` DateTo string `form:"date_to" validate:"required"`
Limit int `form:"limit,default=1000" validate:"min=1,max=1000"` Limit int `form:"limit,default=1000" validate:"min=1,max=1000"`
} }
// ProductAnalyticsResponse represents the response for product analytics // ProductAnalyticsResponse represents the response for product analytics
@ -123,9 +123,9 @@ type ProductAnalyticsData struct {
// ProductAnalyticsPerCategoryRequest represents the request for product analytics per category // ProductAnalyticsPerCategoryRequest represents the request for product analytics per category
type ProductAnalyticsPerCategoryRequest struct { type ProductAnalyticsPerCategoryRequest struct {
OrganizationID uuid.UUID OrganizationID uuid.UUID
OutletID *string `form:"outlet_id,omitempty"` OutletID *uuid.UUID `form:"outlet_id,omitempty"`
DateFrom string `form:"date_from" validate:"required"` DateFrom string `form:"date_from" validate:"required"`
DateTo string `form:"date_to" validate:"required"` DateTo string `form:"date_to" validate:"required"`
} }
// ProductAnalyticsPerCategoryResponse represents the response for product analytics per category // ProductAnalyticsPerCategoryResponse represents the response for product analytics per category
@ -152,9 +152,9 @@ type ProductAnalyticsPerCategoryData struct {
// DashboardAnalyticsRequest represents the request for dashboard analytics // DashboardAnalyticsRequest represents the request for dashboard analytics
type DashboardAnalyticsRequest struct { type DashboardAnalyticsRequest struct {
OrganizationID uuid.UUID OrganizationID uuid.UUID
OutletID *string `form:"outlet_id,omitempty"` OutletID *uuid.UUID `form:"outlet_id,omitempty"`
DateFrom string `form:"date_from" validate:"required"` DateFrom string `form:"date_from" validate:"required"`
DateTo string `form:"date_to" validate:"required"` DateTo string `form:"date_to" validate:"required"`
} }
// DashboardAnalyticsResponse represents the response for dashboard analytics // DashboardAnalyticsResponse represents the response for dashboard analytics
@ -182,10 +182,10 @@ type DashboardOverview struct {
// ProfitLossAnalyticsRequest represents the request for profit and loss analytics // ProfitLossAnalyticsRequest represents the request for profit and loss analytics
type ProfitLossAnalyticsRequest struct { type ProfitLossAnalyticsRequest struct {
OrganizationID uuid.UUID OrganizationID uuid.UUID
OutletID *string `form:"outlet_id,omitempty"` OutletID *uuid.UUID `form:"outlet_id,omitempty"`
DateFrom string `form:"date_from" validate:"required"` DateFrom string `form:"date_from" validate:"required"`
DateTo string `form:"date_to" validate:"required"` DateTo string `form:"date_to" validate:"required"`
GroupBy string `form:"group_by,default=day" validate:"omitempty,oneof=day hour week month"` GroupBy string `form:"group_by,default=day" validate:"omitempty,oneof=day hour week month"`
} }
// ProfitLossAnalyticsResponse represents the response for profit and loss analytics // ProfitLossAnalyticsResponse represents the response for profit and loss analytics

View File

@ -26,13 +26,14 @@ func NewAnalyticsHandler(
} }
} }
func (h *AnalyticsHandler) resolveOutletID(c *gin.Context, contextOutletID uuid.UUID) *string { func (h *AnalyticsHandler) resolveOutletID(c *gin.Context, contextOutletID uuid.UUID) *uuid.UUID {
if outletIDStr := c.Query("outlet_id"); outletIDStr != "" { if outletIDStr := c.Query("outlet_id"); outletIDStr != "" {
return &outletIDStr if parsedID, err := uuid.Parse(outletIDStr); err == nil {
return &parsedID
}
} }
if contextOutletID != uuid.Nil { if contextOutletID != uuid.Nil {
s := contextOutletID.String() return &contextOutletID
return &s
} }
return nil return nil
} }

View File

@ -29,13 +29,6 @@ func NewAnalyticsRepositoryImpl(db *gorm.DB) *AnalyticsRepositoryImpl {
} }
} }
func (r *AnalyticsRepositoryImpl) resolveOutletID(query *gorm.DB, outletID *uuid.UUID, column string) *gorm.DB {
if outletID != nil {
return query.Where(column+" = ?", *outletID)
}
return query
}
func (r *AnalyticsRepositoryImpl) GetPaymentMethodAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]*entities.PaymentMethodAnalytics, error) { func (r *AnalyticsRepositoryImpl) GetPaymentMethodAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]*entities.PaymentMethodAnalytics, error) {
var results []*entities.PaymentMethodAnalytics var results []*entities.PaymentMethodAnalytics
@ -57,7 +50,9 @@ func (r *AnalyticsRepositoryImpl) GetPaymentMethodAnalytics(ctx context.Context,
Where("p.status = ?", entities.PaymentTransactionStatusCompleted). Where("p.status = ?", entities.PaymentTransactionStatusCompleted).
Where("o.created_at >= ? AND o.created_at <= ?", dateFrom, dateTo) Where("o.created_at >= ? AND o.created_at <= ?", dateFrom, dateTo)
query = r.resolveOutletID(query, outletID, "o.outlet_id") if outletID != nil {
query = query.Where("o.outlet_id = ?", *outletID)
}
err := query. err := query.
Group("pm.id, pm.name, pm.type"). Group("pm.id, pm.name, pm.type").
@ -185,7 +180,9 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organ
Where("oi.status != ?", entities.OrderItemStatusCancelled). Where("oi.status != ?", entities.OrderItemStatusCancelled).
Where("o.created_at >= ? AND o.created_at <= ?", dateFrom, dateTo) Where("o.created_at >= ? AND o.created_at <= ?", dateFrom, dateTo)
query = r.resolveOutletID(query, outletID, "o.outlet_id") if outletID != nil {
query = query.Where("o.outlet_id = ?", *outletID)
}
err := query. err := query.
Group("p.id, p.name, p.cost, c.id, c.name, c.order, mahpp.hpp_per_unit"). Group("p.id, p.name, p.cost, c.id, c.name, c.order, mahpp.hpp_per_unit").
@ -238,7 +235,9 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalyticsPerCategory(ctx context.Con
Where("oi.status != ?", entities.OrderItemStatusCancelled). Where("oi.status != ?", entities.OrderItemStatusCancelled).
Where("o.created_at >= ? AND o.created_at <= ?", dateFrom, dateTo) Where("o.created_at >= ? AND o.created_at <= ?", dateFrom, dateTo)
query = r.resolveOutletID(query, outletID, "o.outlet_id") if outletID != nil {
query = query.Where("o.outlet_id = ?", *outletID)
}
err := query. err := query.
Group("c.id, c.name"). Group("c.id, c.name").
@ -268,7 +267,9 @@ func (r *AnalyticsRepositoryImpl) GetDashboardOverview(ctx context.Context, orga
Where("o.organization_id = ?", organizationID). Where("o.organization_id = ?", organizationID).
Where("o.created_at >= ? AND o.created_at <= ?", dateFrom, dateTo) Where("o.created_at >= ? AND o.created_at <= ?", dateFrom, dateTo)
query = r.resolveOutletID(query, outletID, "o.outlet_id") if outletID != nil {
query = query.Where("o.outlet_id = ?", *outletID)
}
err := query.Scan(&result).Error err := query.Scan(&result).Error
if err != nil { if err != nil {
@ -319,7 +320,9 @@ func (r *AnalyticsRepositoryImpl) GetProfitLossAnalytics(ctx context.Context, or
Where("o.is_void = false AND o.is_refund = false"). Where("o.is_void = false AND o.is_refund = false").
Where("o.created_at >= ? AND o.created_at <= ?", dateFrom, dateTo) Where("o.created_at >= ? AND o.created_at <= ?", dateFrom, dateTo)
summaryQuery = r.resolveOutletID(summaryQuery, outletID, "o.outlet_id") if outletID != nil {
summaryQuery = summaryQuery.Where("o.outlet_id = ?", *outletID)
}
err := summaryQuery.Scan(&summary).Error err := summaryQuery.Scan(&summary).Error
if err != nil { if err != nil {
@ -371,7 +374,9 @@ func (r *AnalyticsRepositoryImpl) GetProfitLossAnalytics(ctx context.Context, or
Group(timeFormat). Group(timeFormat).
Order(timeFormat) Order(timeFormat)
dataQuery = r.resolveOutletID(dataQuery, outletID, "o.outlet_id") if outletID != nil {
dataQuery = dataQuery.Where("o.outlet_id = ?", *outletID)
}
err = dataQuery.Scan(&data).Error err = dataQuery.Scan(&data).Error
if err != nil { if err != nil {
@ -414,7 +419,9 @@ func (r *AnalyticsRepositoryImpl) GetProfitLossAnalytics(ctx context.Context, or
Order("p.name ASC"). Order("p.name ASC").
Limit(1000) Limit(1000)
productQuery = r.resolveOutletID(productQuery, outletID, "o.outlet_id") if outletID != nil {
productQuery = productQuery.Where("o.outlet_id = ?", *outletID)
}
err = productQuery.Scan(&productData).Error err = productQuery.Scan(&productData).Error
if err != nil { if err != nil {

View File

@ -99,14 +99,3 @@ func (r *OrganizationRepositoryImpl) GetByEmail(ctx context.Context, email strin
} }
return &org, nil return &org, nil
} }
// GetTotalOmset returns the total revenue from completed orders for an organization.
func (r *OrganizationRepositoryImpl) GetTotalOmset(ctx context.Context, organizationID uuid.UUID) (float64, error) {
var total float64
err := r.db.WithContext(ctx).
Table("orders").
Where("organization_id = ? AND payment_status = ?", organizationID, "completed").
Select("COALESCE(SUM(total_amount), 0)").
Scan(&total).Error
return total, err
}

View File

@ -1,171 +0,0 @@
package service
import (
"context"
"fmt"
"log"
"sync"
"time"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/processor"
"apskel-pos-be/internal/repository"
"github.com/google/uuid"
)
const (
defaultCheckInterval = 1 * time.Hour
OmsetMillionRupiah = 1_000_000.0
)
// OmsetMilestoneScheduler periodically checks each organization's total omset
// and sends a notification to owner/admin users when a milestone is reached.
//
// NOTE: Milestone tracking is in-memory; notifications may re-trigger after a restart.
// For persistent tracking, persist the notified state in the database.
type OmsetMilestoneScheduler struct {
orgRepo *repository.OrganizationRepositoryImpl
userRepo *repository.UserRepositoryImpl
notificationProc processor.NotificationProcessor
mu sync.Mutex
notified map[string]bool // "orgID:milestone" -> already notified
stopCh chan struct{}
}
func NewOmsetMilestoneScheduler(
orgRepo *repository.OrganizationRepositoryImpl,
userRepo *repository.UserRepositoryImpl,
notificationProc processor.NotificationProcessor,
) *OmsetMilestoneScheduler {
return &OmsetMilestoneScheduler{
orgRepo: orgRepo,
userRepo: userRepo,
notificationProc: notificationProc,
notified: make(map[string]bool),
stopCh: make(chan struct{}),
}
}
// Start begins the periodic milestone check in a background goroutine.
func (s *OmsetMilestoneScheduler) Start(interval time.Duration) {
if interval <= 0 {
interval = defaultCheckInterval
}
go func() {
// Perform an initial check immediately.
s.checkAllOrganizations()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
s.checkAllOrganizations()
case <-s.stopCh:
log.Println("Omset milestone scheduler stopped")
return
}
}
}()
log.Println("Omset milestone scheduler started")
}
// Stop signals the scheduler to stop.
func (s *OmsetMilestoneScheduler) Stop() {
close(s.stopCh)
}
func (s *OmsetMilestoneScheduler) checkAllOrganizations() {
ctx := context.Background()
orgs, _, err := s.orgRepo.List(ctx, nil, 1000, 0)
if err != nil {
log.Printf("OmsetMilestoneScheduler: failed to list organizations: %v", err)
return
}
for _, org := range orgs {
s.checkOrganization(ctx, org)
}
}
func (s *OmsetMilestoneScheduler) checkOrganization(ctx context.Context, org *entities.Organization) {
totalOmset, err := s.orgRepo.GetTotalOmset(ctx, org.ID)
if err != nil {
log.Printf("OmsetMilestoneScheduler: failed to get total omset for org %s: %v", org.ID, err)
return
}
milestones := []float64{OmsetMillionRupiah}
for _, milestone := range milestones {
if totalOmset < milestone {
continue
}
key := fmt.Sprintf("%s:%.0f", org.ID.String(), milestone)
s.mu.Lock()
if s.notified[key] {
s.mu.Unlock()
continue
}
s.notified[key] = true
s.mu.Unlock()
s.sendMilestoneNotification(ctx, org, totalOmset, milestone)
}
}
func (s *OmsetMilestoneScheduler) sendMilestoneNotification(ctx context.Context, org *entities.Organization, totalOmset float64, milestone float64) {
users, err := s.userRepo.GetByOrganizationID(ctx, org.ID)
if err != nil {
log.Printf("OmsetMilestoneScheduler: failed to get users for org %s: %v", org.ID, err)
return
}
// Notify owner and admin users.
var receiverIDs []uuid.UUID
for _, user := range users {
roleStr := string(user.Role)
if roleStr == string(constants.RoleOwner) || roleStr == string(constants.RoleAdmin) {
receiverIDs = append(receiverIDs, user.ID)
}
}
if len(receiverIDs) == 0 {
return
}
orgID := org.ID
title := "🎉 Selamat! Omset Telah Mencapai 1 Juta Rupiah"
body := fmt.Sprintf("Organisasi %s telah mencapai omset Rp %.0f. Terus tingkatkan prestasinya!", org.Name, totalOmset)
notifReq := &models.SendNotificationRequest{
Title: title,
Body: body,
Type: "milestone",
Category: "omset_milestone",
NotifiableType: "organization",
NotifiableID: &orgID,
ReceiverIDs: receiverIDs,
Data: map[string]interface{}{
"organization_id": org.ID.String(),
"total_omset": totalOmset,
"milestone": milestone,
},
}
if _, err := s.notificationProc.Send(ctx, notifReq); err != nil {
log.Printf("OmsetMilestoneScheduler: failed to send notification for org %s: %v", org.ID, err)
} else {
log.Printf("OmsetMilestoneScheduler: sent milestone notification to org %s (omset: %.0f)", org.ID, totalOmset)
}
}

View File

@ -6,22 +6,8 @@ import (
"apskel-pos-be/internal/util" "apskel-pos-be/internal/util"
"fmt" "fmt"
"time" "time"
"github.com/google/uuid"
) )
// parseOutletID converts a *string outlet ID to *uuid.UUID, returning nil for invalid/empty values.
func parseOutletID(s *string) *uuid.UUID {
if s == nil {
return nil
}
id, err := uuid.Parse(*s)
if err != nil {
return nil
}
return &id
}
// PaymentMethodAnalyticsContractToModel converts contract request to model // PaymentMethodAnalyticsContractToModel converts contract request to model
func PaymentMethodAnalyticsContractToModel(req *contract.PaymentMethodAnalyticsRequest) *models.PaymentMethodAnalyticsRequest { func PaymentMethodAnalyticsContractToModel(req *contract.PaymentMethodAnalyticsRequest) *models.PaymentMethodAnalyticsRequest {
var dateFrom, dateTo time.Time var dateFrom, dateTo time.Time
@ -37,7 +23,7 @@ func PaymentMethodAnalyticsContractToModel(req *contract.PaymentMethodAnalyticsR
return &models.PaymentMethodAnalyticsRequest{ return &models.PaymentMethodAnalyticsRequest{
OrganizationID: req.OrganizationID, OrganizationID: req.OrganizationID,
OutletID: parseOutletID(req.OutletID), OutletID: req.OutletID,
DateFrom: dateFrom, DateFrom: dateFrom,
DateTo: dateTo, DateTo: dateTo,
GroupBy: req.GroupBy, GroupBy: req.GroupBy,
@ -93,7 +79,7 @@ func SalesAnalyticsContractToModel(req *contract.SalesAnalyticsRequest) *models.
return &models.SalesAnalyticsRequest{ return &models.SalesAnalyticsRequest{
OrganizationID: req.OrganizationID, OrganizationID: req.OrganizationID,
OutletID: parseOutletID(req.OutletID), OutletID: req.OutletID,
DateFrom: dateFrom, DateFrom: dateFrom,
DateTo: dateTo, DateTo: dateTo,
GroupBy: req.GroupBy, GroupBy: req.GroupBy,
@ -153,7 +139,7 @@ func ProductAnalyticsContractToModel(req *contract.ProductAnalyticsRequest) *mod
return &models.ProductAnalyticsRequest{ return &models.ProductAnalyticsRequest{
OrganizationID: req.OrganizationID, OrganizationID: req.OrganizationID,
OutletID: parseOutletID(req.OutletID), OutletID: req.OutletID,
DateFrom: dateFrom, DateFrom: dateFrom,
DateTo: dateTo, DateTo: dateTo,
Limit: req.Limit, Limit: req.Limit,
@ -213,7 +199,7 @@ func ProductAnalyticsPerCategoryContractToModel(req *contract.ProductAnalyticsPe
return &models.ProductAnalyticsPerCategoryRequest{ return &models.ProductAnalyticsPerCategoryRequest{
OrganizationID: req.OrganizationID, OrganizationID: req.OrganizationID,
OutletID: parseOutletID(req.OutletID), OutletID: req.OutletID,
DateFrom: dateFrom, DateFrom: dateFrom,
DateTo: dateTo, DateTo: dateTo,
} }
@ -265,7 +251,7 @@ func DashboardAnalyticsContractToModel(req *contract.DashboardAnalyticsRequest)
return &models.DashboardAnalyticsRequest{ return &models.DashboardAnalyticsRequest{
OrganizationID: req.OrganizationID, OrganizationID: req.OrganizationID,
OutletID: parseOutletID(req.OutletID), OutletID: req.OutletID,
DateFrom: dateFrom, DateFrom: dateFrom,
DateTo: dateTo, DateTo: dateTo,
} }
@ -360,7 +346,7 @@ func ProfitLossAnalyticsContractToModel(req *contract.ProfitLossAnalyticsRequest
return &models.ProfitLossAnalyticsRequest{ return &models.ProfitLossAnalyticsRequest{
OrganizationID: req.OrganizationID, OrganizationID: req.OrganizationID,
OutletID: parseOutletID(req.OutletID), OutletID: req.OutletID,
DateFrom: *dateFrom, DateFrom: *dateFrom,
DateTo: *dateTo, DateTo: *dateTo,
GroupBy: req.GroupBy, GroupBy: req.GroupBy,