fix log error and omset tracker scheduled
This commit is contained in:
parent
957c1ae53d
commit
328336ea5a
6
Makefile
6
Makefile
@ -83,6 +83,12 @@ migration-up:
|
||||
migration-down:
|
||||
@migrate -database $(DB_URL) -path ./migrations down 1
|
||||
|
||||
# Force migration to specific version
|
||||
|
||||
.SILENT: migration-force
|
||||
migration-force:
|
||||
@migrate -database $(DB_URL) -path ./migrations force $(version)
|
||||
|
||||
.SILENT: seeder-create
|
||||
seeder-create:
|
||||
@migrate create -ext sql -dir ./seeders -seq $(name)
|
||||
|
||||
@ -48,6 +48,7 @@ func (a *App) Initialize(cfg *config.Config) error {
|
||||
// Initialize omset milestone scheduler
|
||||
a.omsetScheduler = service.NewOmsetMilestoneScheduler(
|
||||
repos.organizationRepo,
|
||||
repos.outletRepo,
|
||||
repos.userRepo,
|
||||
processors.notificationProcessor,
|
||||
)
|
||||
@ -141,9 +142,9 @@ func (a *App) Initialize(cfg *config.Config) error {
|
||||
}
|
||||
|
||||
func (a *App) Start(port string) error {
|
||||
// Start the omset milestone scheduler (checks every hour)
|
||||
// Start the omset milestone scheduler (checks every 5 minutes for daily omset milestones)
|
||||
if a.omsetScheduler != nil {
|
||||
a.omsetScheduler.Start(1 * time.Hour)
|
||||
a.omsetScheduler.Start(5 * time.Minute)
|
||||
}
|
||||
|
||||
engine := a.router.Init()
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"apskel-pos-be/internal/logger"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
@ -47,7 +49,7 @@ func (m *CommonMiddleware) Recovery(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
|
||||
logger.FromContext(r.Context()).Error("Recovery", fmt.Sprintf("panic recovered: %v", err))
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
}()
|
||||
|
||||
@ -2,6 +2,8 @@ package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"apskel-pos-be/internal/entities"
|
||||
@ -110,3 +112,29 @@ func (r *OrganizationRepositoryImpl) GetTotalOmset(ctx context.Context, organiza
|
||||
Scan(&total).Error
|
||||
return total, err
|
||||
}
|
||||
|
||||
// GetTodayOmset returns the total revenue from completed orders for an organization on the current calendar day.
|
||||
func (r *OrganizationRepositoryImpl) GetTodayOmset(ctx context.Context, organizationID uuid.UUID) (float64, error) {
|
||||
var total float64
|
||||
err := r.db.WithContext(ctx).
|
||||
Table("orders").
|
||||
Where(
|
||||
"organization_id = ? AND payment_status = ? AND is_void = ? AND is_refund = ? AND created_at >= ? AND created_at < ?",
|
||||
organizationID, "completed", false, false,
|
||||
todayStart(), tomorrowStart(),
|
||||
).
|
||||
Select("COALESCE(SUM(total_amount), 0)").
|
||||
Scan(&total).Error
|
||||
return total, err
|
||||
}
|
||||
|
||||
// todayStart returns midnight of the current local day.
|
||||
func todayStart() time.Time {
|
||||
now := time.Now()
|
||||
return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
}
|
||||
|
||||
// tomorrowStart returns midnight of the next local day.
|
||||
func tomorrowStart() time.Time {
|
||||
return todayStart().AddDate(0, 0, 1)
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ package repository
|
||||
import (
|
||||
"apskel-pos-be/internal/entities"
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
@ -103,3 +104,22 @@ func (r *OutletRepositoryImpl) Count(ctx context.Context, filters map[string]int
|
||||
err := query.Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
// GetTodayOmset returns the total revenue from completed orders for an outlet on the current calendar day.
|
||||
func (r *OutletRepositoryImpl) GetTodayOmset(ctx context.Context, outletID uuid.UUID) (float64, error) {
|
||||
var total float64
|
||||
now := time.Now()
|
||||
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
tomorrowStart := todayStart.AddDate(0, 0, 1)
|
||||
|
||||
err := r.db.WithContext(ctx).
|
||||
Table("orders").
|
||||
Where(
|
||||
"outlet_id = ? AND payment_status = ? AND is_void = ? AND is_refund = ? AND created_at >= ? AND created_at < ?",
|
||||
outletID, "completed", false, false,
|
||||
todayStart, tomorrowStart,
|
||||
).
|
||||
Select("COALESCE(SUM(total_amount), 0)").
|
||||
Scan(&total).Error
|
||||
return total, err
|
||||
}
|
||||
|
||||
@ -4,11 +4,11 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"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"
|
||||
@ -17,32 +17,38 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
defaultCheckInterval = 1 * time.Hour
|
||||
defaultCheckInterval = 5 * time.Minute
|
||||
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.
|
||||
// OmsetMilestoneScheduler periodically checks each outlet's omset for the
|
||||
// current calendar day and sends a notification every time it crosses a new
|
||||
// multiple of OmsetMillionRupiah (1 jt, 2 jt, 3 jt, …).
|
||||
//
|
||||
// NOTE: Milestone tracking is in-memory; notifications may re-trigger after a restart.
|
||||
// For persistent tracking, persist the notified state in the database.
|
||||
// The notified state is keyed by "outletID:YYYY-MM-DD:N" so each multiple is
|
||||
// only notified once per day. State resets naturally on the next day (new key).
|
||||
// NOTE: state is in-memory; a server restart within the same day may re-send
|
||||
// notifications for already-crossed milestones.
|
||||
type OmsetMilestoneScheduler struct {
|
||||
orgRepo *repository.OrganizationRepositoryImpl
|
||||
outletRepo *repository.OutletRepositoryImpl
|
||||
userRepo *repository.UserRepositoryImpl
|
||||
notificationProc processor.NotificationProcessor
|
||||
|
||||
mu sync.Mutex
|
||||
notified map[string]bool // "orgID:milestone" -> already notified
|
||||
notified map[string]bool // "outletID:YYYY-MM-DD:N" -> already notified
|
||||
stopCh chan struct{}
|
||||
}
|
||||
|
||||
func NewOmsetMilestoneScheduler(
|
||||
orgRepo *repository.OrganizationRepositoryImpl,
|
||||
outletRepo *repository.OutletRepositoryImpl,
|
||||
userRepo *repository.UserRepositoryImpl,
|
||||
notificationProc processor.NotificationProcessor,
|
||||
) *OmsetMilestoneScheduler {
|
||||
return &OmsetMilestoneScheduler{
|
||||
orgRepo: orgRepo,
|
||||
outletRepo: outletRepo,
|
||||
userRepo: userRepo,
|
||||
notificationProc: notificationProc,
|
||||
notified: make(map[string]bool),
|
||||
@ -57,8 +63,8 @@ func (s *OmsetMilestoneScheduler) Start(interval time.Duration) {
|
||||
}
|
||||
|
||||
go func() {
|
||||
// Perform an initial check immediately.
|
||||
s.checkAllOrganizations()
|
||||
// Perform an initial check immediately on startup.
|
||||
s.checkAllOutlets()
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
@ -66,7 +72,7 @@ func (s *OmsetMilestoneScheduler) Start(interval time.Duration) {
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
s.checkAllOrganizations()
|
||||
s.checkAllOutlets()
|
||||
case <-s.stopCh:
|
||||
log.Println("Omset milestone scheduler stopped")
|
||||
return
|
||||
@ -74,7 +80,7 @@ func (s *OmsetMilestoneScheduler) Start(interval time.Duration) {
|
||||
}
|
||||
}()
|
||||
|
||||
log.Println("Omset milestone scheduler started")
|
||||
log.Printf("Omset milestone scheduler started (interval: %s)", interval)
|
||||
}
|
||||
|
||||
// Stop signals the scheduler to stop.
|
||||
@ -82,7 +88,7 @@ func (s *OmsetMilestoneScheduler) Stop() {
|
||||
close(s.stopCh)
|
||||
}
|
||||
|
||||
func (s *OmsetMilestoneScheduler) checkAllOrganizations() {
|
||||
func (s *OmsetMilestoneScheduler) checkAllOutlets() {
|
||||
ctx := context.Background()
|
||||
|
||||
orgs, _, err := s.orgRepo.List(ctx, nil, 1000, 0)
|
||||
@ -92,25 +98,38 @@ func (s *OmsetMilestoneScheduler) checkAllOrganizations() {
|
||||
}
|
||||
|
||||
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 {
|
||||
outlets, err := s.outletRepo.GetByOrganizationID(ctx, org.ID)
|
||||
if err != nil {
|
||||
log.Printf("OmsetMilestoneScheduler: failed to list outlets for org %s: %v", org.ID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
key := fmt.Sprintf("%s:%.0f", org.ID.String(), milestone)
|
||||
for _, outlet := range outlets {
|
||||
if !outlet.IsActive {
|
||||
continue
|
||||
}
|
||||
s.checkOutlet(ctx, org.ID, outlet.ID, outlet.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *OmsetMilestoneScheduler) checkOutlet(ctx context.Context, organizationID, outletID uuid.UUID, outletName string) {
|
||||
todayOmset, err := s.outletRepo.GetTodayOmset(ctx, outletID)
|
||||
if err != nil {
|
||||
log.Printf("OmsetMilestoneScheduler: failed to get today's omset for outlet %s: %v", outletID, err)
|
||||
return
|
||||
}
|
||||
|
||||
if todayOmset < OmsetMillionRupiah {
|
||||
return
|
||||
}
|
||||
|
||||
// How many full multiples of 1 juta have been crossed today?
|
||||
crossedMultiple := int(math.Floor(todayOmset / OmsetMillionRupiah))
|
||||
today := time.Now().Format("2006-01-02")
|
||||
|
||||
for n := 1; n <= crossedMultiple; n++ {
|
||||
key := fmt.Sprintf("%s:%s:%d", outletID.String(), today, n)
|
||||
|
||||
s.mu.Lock()
|
||||
if s.notified[key] {
|
||||
@ -120,23 +139,31 @@ func (s *OmsetMilestoneScheduler) checkOrganization(ctx context.Context, org *en
|
||||
s.notified[key] = true
|
||||
s.mu.Unlock()
|
||||
|
||||
s.sendMilestoneNotification(ctx, org, totalOmset, milestone)
|
||||
milestone := float64(n) * OmsetMillionRupiah
|
||||
s.sendMilestoneNotification(ctx, organizationID, outletID, outletName, todayOmset, milestone, n)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *OmsetMilestoneScheduler) sendMilestoneNotification(ctx context.Context, org *entities.Organization, totalOmset float64, milestone float64) {
|
||||
users, err := s.userRepo.GetByOrganizationID(ctx, org.ID)
|
||||
func (s *OmsetMilestoneScheduler) sendMilestoneNotification(
|
||||
ctx context.Context,
|
||||
organizationID, outletID uuid.UUID,
|
||||
outletName string,
|
||||
todayOmset, milestone float64,
|
||||
multiple int,
|
||||
) {
|
||||
// Fetch all users in the org, then filter to owner and manager only.
|
||||
// These roles are not assigned to a specific outlet, so we query by org.
|
||||
users, err := s.userRepo.GetByOrganizationID(ctx, organizationID)
|
||||
if err != nil {
|
||||
log.Printf("OmsetMilestoneScheduler: failed to get users for org %s: %v", org.ID, err)
|
||||
log.Printf("OmsetMilestoneScheduler: failed to get users for org %s: %v", organizationID, 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)
|
||||
for _, u := range users {
|
||||
role := string(u.Role)
|
||||
if role == string(constants.RoleOwner) || role == string(constants.RoleManager) {
|
||||
receiverIDs = append(receiverIDs, u.ID)
|
||||
}
|
||||
}
|
||||
|
||||
@ -144,28 +171,34 @@ func (s *OmsetMilestoneScheduler) sendMilestoneNotification(ctx context.Context,
|
||||
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)
|
||||
title := fmt.Sprintf("🎉 Omset %s Hari Ini Mencapai Rp %.0f!", outletName, milestone)
|
||||
body := fmt.Sprintf(
|
||||
"Selamat! Omset outlet %s hari ini sudah menembus Rp %.0f (total hari ini: Rp %.0f). Terus semangat!",
|
||||
outletName, milestone, todayOmset,
|
||||
)
|
||||
|
||||
notifReq := &models.SendNotificationRequest{
|
||||
Title: title,
|
||||
Body: body,
|
||||
Type: "milestone",
|
||||
Category: "omset_milestone",
|
||||
NotifiableType: "organization",
|
||||
NotifiableID: &orgID,
|
||||
NotifiableType: "outlet",
|
||||
NotifiableID: &outletID,
|
||||
ReceiverIDs: receiverIDs,
|
||||
Data: map[string]interface{}{
|
||||
"organization_id": org.ID.String(),
|
||||
"total_omset": totalOmset,
|
||||
"organization_id": organizationID.String(),
|
||||
"outlet_id": outletID.String(),
|
||||
"outlet_name": outletName,
|
||||
"today_omset": todayOmset,
|
||||
"milestone": milestone,
|
||||
"multiple": multiple,
|
||||
},
|
||||
}
|
||||
|
||||
if _, err := s.notificationProc.Send(ctx, notifReq); err != nil {
|
||||
log.Printf("OmsetMilestoneScheduler: failed to send notification for org %s: %v", org.ID, err)
|
||||
log.Printf("OmsetMilestoneScheduler: failed to send notification for outlet %s: %v", outletID, err)
|
||||
} else {
|
||||
log.Printf("OmsetMilestoneScheduler: sent milestone notification to org %s (omset: %.0f)", org.ID, totalOmset)
|
||||
log.Printf("OmsetMilestoneScheduler: sent milestone x%d (Rp %.0f) for outlet %s (today omset: %.0f)",
|
||||
multiple, milestone, outletName, todayOmset)
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,6 +16,12 @@ func HandleResponse(w http.ResponseWriter, r *http.Request, response *contract.R
|
||||
} else {
|
||||
responseError := response.GetErrors()[0]
|
||||
statusCode = MapErrorCodeToHttpStatus(responseError.GetCode())
|
||||
logger.FromContext(r.Context()).WithFields(map[string]interface{}{
|
||||
"error_code": responseError.GetCode(),
|
||||
"error_entity": responseError.GetEntity(),
|
||||
"error_cause": responseError.GetCause(),
|
||||
"status_code": statusCode,
|
||||
}).Error(methodName)
|
||||
}
|
||||
WriteResponse(w, r, *response, statusCode, methodName)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user