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:
|
migration-down:
|
||||||
@migrate -database $(DB_URL) -path ./migrations down 1
|
@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
|
.SILENT: seeder-create
|
||||||
seeder-create:
|
seeder-create:
|
||||||
@migrate create -ext sql -dir ./seeders -seq $(name)
|
@migrate create -ext sql -dir ./seeders -seq $(name)
|
||||||
|
|||||||
@ -48,6 +48,7 @@ func (a *App) Initialize(cfg *config.Config) error {
|
|||||||
// Initialize omset milestone scheduler
|
// Initialize omset milestone scheduler
|
||||||
a.omsetScheduler = service.NewOmsetMilestoneScheduler(
|
a.omsetScheduler = service.NewOmsetMilestoneScheduler(
|
||||||
repos.organizationRepo,
|
repos.organizationRepo,
|
||||||
|
repos.outletRepo,
|
||||||
repos.userRepo,
|
repos.userRepo,
|
||||||
processors.notificationProcessor,
|
processors.notificationProcessor,
|
||||||
)
|
)
|
||||||
@ -141,9 +142,9 @@ 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)
|
// Start the omset milestone scheduler (checks every 5 minutes for daily omset milestones)
|
||||||
if a.omsetScheduler != nil {
|
if a.omsetScheduler != nil {
|
||||||
a.omsetScheduler.Start(1 * time.Hour)
|
a.omsetScheduler.Start(5 * time.Minute)
|
||||||
}
|
}
|
||||||
|
|
||||||
engine := a.router.Init()
|
engine := a.router.Init()
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"apskel-pos-be/internal/logger"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@ -47,7 +49,7 @@ func (m *CommonMiddleware) Recovery(next http.Handler) http.Handler {
|
|||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
defer func() {
|
defer func() {
|
||||||
if err := recover(); err != nil {
|
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)
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|||||||
@ -2,6 +2,8 @@ package repository
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
"apskel-pos-be/internal/entities"
|
"apskel-pos-be/internal/entities"
|
||||||
@ -110,3 +112,29 @@ func (r *OrganizationRepositoryImpl) GetTotalOmset(ctx context.Context, organiza
|
|||||||
Scan(&total).Error
|
Scan(&total).Error
|
||||||
return total, err
|
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 (
|
import (
|
||||||
"apskel-pos-be/internal/entities"
|
"apskel-pos-be/internal/entities"
|
||||||
"context"
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@ -103,3 +104,22 @@ func (r *OutletRepositoryImpl) Count(ctx context.Context, filters map[string]int
|
|||||||
err := query.Count(&count).Error
|
err := query.Count(&count).Error
|
||||||
return count, err
|
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"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"math"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"apskel-pos-be/internal/constants"
|
"apskel-pos-be/internal/constants"
|
||||||
"apskel-pos-be/internal/entities"
|
|
||||||
"apskel-pos-be/internal/models"
|
"apskel-pos-be/internal/models"
|
||||||
"apskel-pos-be/internal/processor"
|
"apskel-pos-be/internal/processor"
|
||||||
"apskel-pos-be/internal/repository"
|
"apskel-pos-be/internal/repository"
|
||||||
@ -17,32 +17,38 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
defaultCheckInterval = 1 * time.Hour
|
defaultCheckInterval = 5 * time.Minute
|
||||||
OmsetMillionRupiah = 1_000_000.0
|
OmsetMillionRupiah = 1_000_000.0
|
||||||
)
|
)
|
||||||
|
|
||||||
// OmsetMilestoneScheduler periodically checks each organization's total omset
|
// OmsetMilestoneScheduler periodically checks each outlet's omset for the
|
||||||
// and sends a notification to owner/admin users when a milestone is reached.
|
// 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.
|
// The notified state is keyed by "outletID:YYYY-MM-DD:N" so each multiple is
|
||||||
// For persistent tracking, persist the notified state in the database.
|
// 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 {
|
type OmsetMilestoneScheduler struct {
|
||||||
orgRepo *repository.OrganizationRepositoryImpl
|
orgRepo *repository.OrganizationRepositoryImpl
|
||||||
|
outletRepo *repository.OutletRepositoryImpl
|
||||||
userRepo *repository.UserRepositoryImpl
|
userRepo *repository.UserRepositoryImpl
|
||||||
notificationProc processor.NotificationProcessor
|
notificationProc processor.NotificationProcessor
|
||||||
|
|
||||||
mu sync.Mutex
|
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{}
|
stopCh chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewOmsetMilestoneScheduler(
|
func NewOmsetMilestoneScheduler(
|
||||||
orgRepo *repository.OrganizationRepositoryImpl,
|
orgRepo *repository.OrganizationRepositoryImpl,
|
||||||
|
outletRepo *repository.OutletRepositoryImpl,
|
||||||
userRepo *repository.UserRepositoryImpl,
|
userRepo *repository.UserRepositoryImpl,
|
||||||
notificationProc processor.NotificationProcessor,
|
notificationProc processor.NotificationProcessor,
|
||||||
) *OmsetMilestoneScheduler {
|
) *OmsetMilestoneScheduler {
|
||||||
return &OmsetMilestoneScheduler{
|
return &OmsetMilestoneScheduler{
|
||||||
orgRepo: orgRepo,
|
orgRepo: orgRepo,
|
||||||
|
outletRepo: outletRepo,
|
||||||
userRepo: userRepo,
|
userRepo: userRepo,
|
||||||
notificationProc: notificationProc,
|
notificationProc: notificationProc,
|
||||||
notified: make(map[string]bool),
|
notified: make(map[string]bool),
|
||||||
@ -57,8 +63,8 @@ func (s *OmsetMilestoneScheduler) Start(interval time.Duration) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
// Perform an initial check immediately.
|
// Perform an initial check immediately on startup.
|
||||||
s.checkAllOrganizations()
|
s.checkAllOutlets()
|
||||||
|
|
||||||
ticker := time.NewTicker(interval)
|
ticker := time.NewTicker(interval)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
@ -66,7 +72,7 @@ func (s *OmsetMilestoneScheduler) Start(interval time.Duration) {
|
|||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
s.checkAllOrganizations()
|
s.checkAllOutlets()
|
||||||
case <-s.stopCh:
|
case <-s.stopCh:
|
||||||
log.Println("Omset milestone scheduler stopped")
|
log.Println("Omset milestone scheduler stopped")
|
||||||
return
|
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.
|
// Stop signals the scheduler to stop.
|
||||||
@ -82,7 +88,7 @@ func (s *OmsetMilestoneScheduler) Stop() {
|
|||||||
close(s.stopCh)
|
close(s.stopCh)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *OmsetMilestoneScheduler) checkAllOrganizations() {
|
func (s *OmsetMilestoneScheduler) checkAllOutlets() {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
orgs, _, err := s.orgRepo.List(ctx, nil, 1000, 0)
|
orgs, _, err := s.orgRepo.List(ctx, nil, 1000, 0)
|
||||||
@ -92,25 +98,38 @@ func (s *OmsetMilestoneScheduler) checkAllOrganizations() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, org := range orgs {
|
for _, org := range orgs {
|
||||||
s.checkOrganization(ctx, org)
|
outlets, err := s.outletRepo.GetByOrganizationID(ctx, org.ID)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *OmsetMilestoneScheduler) checkOrganization(ctx context.Context, org *entities.Organization) {
|
|
||||||
totalOmset, err := s.orgRepo.GetTotalOmset(ctx, org.ID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("OmsetMilestoneScheduler: failed to get total omset for org %s: %v", org.ID, err)
|
log.Printf("OmsetMilestoneScheduler: failed to list outlets for org %s: %v", org.ID, err)
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
milestones := []float64{OmsetMillionRupiah}
|
|
||||||
|
|
||||||
for _, milestone := range milestones {
|
|
||||||
if totalOmset < milestone {
|
|
||||||
continue
|
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()
|
s.mu.Lock()
|
||||||
if s.notified[key] {
|
if s.notified[key] {
|
||||||
@ -120,23 +139,31 @@ func (s *OmsetMilestoneScheduler) checkOrganization(ctx context.Context, org *en
|
|||||||
s.notified[key] = true
|
s.notified[key] = true
|
||||||
s.mu.Unlock()
|
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) {
|
func (s *OmsetMilestoneScheduler) sendMilestoneNotification(
|
||||||
users, err := s.userRepo.GetByOrganizationID(ctx, org.ID)
|
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 {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify owner and admin users.
|
|
||||||
var receiverIDs []uuid.UUID
|
var receiverIDs []uuid.UUID
|
||||||
for _, user := range users {
|
for _, u := range users {
|
||||||
roleStr := string(user.Role)
|
role := string(u.Role)
|
||||||
if roleStr == string(constants.RoleOwner) || roleStr == string(constants.RoleAdmin) {
|
if role == string(constants.RoleOwner) || role == string(constants.RoleManager) {
|
||||||
receiverIDs = append(receiverIDs, user.ID)
|
receiverIDs = append(receiverIDs, u.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -144,28 +171,34 @@ func (s *OmsetMilestoneScheduler) sendMilestoneNotification(ctx context.Context,
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
orgID := org.ID
|
title := fmt.Sprintf("🎉 Omset %s Hari Ini Mencapai Rp %.0f!", outletName, milestone)
|
||||||
title := "🎉 Selamat! Omset Telah Mencapai 1 Juta Rupiah"
|
body := fmt.Sprintf(
|
||||||
body := fmt.Sprintf("Organisasi %s telah mencapai omset Rp %.0f. Terus tingkatkan prestasinya!", org.Name, totalOmset)
|
"Selamat! Omset outlet %s hari ini sudah menembus Rp %.0f (total hari ini: Rp %.0f). Terus semangat!",
|
||||||
|
outletName, milestone, todayOmset,
|
||||||
|
)
|
||||||
|
|
||||||
notifReq := &models.SendNotificationRequest{
|
notifReq := &models.SendNotificationRequest{
|
||||||
Title: title,
|
Title: title,
|
||||||
Body: body,
|
Body: body,
|
||||||
Type: "milestone",
|
Type: "milestone",
|
||||||
Category: "omset_milestone",
|
Category: "omset_milestone",
|
||||||
NotifiableType: "organization",
|
NotifiableType: "outlet",
|
||||||
NotifiableID: &orgID,
|
NotifiableID: &outletID,
|
||||||
ReceiverIDs: receiverIDs,
|
ReceiverIDs: receiverIDs,
|
||||||
Data: map[string]interface{}{
|
Data: map[string]interface{}{
|
||||||
"organization_id": org.ID.String(),
|
"organization_id": organizationID.String(),
|
||||||
"total_omset": totalOmset,
|
"outlet_id": outletID.String(),
|
||||||
|
"outlet_name": outletName,
|
||||||
|
"today_omset": todayOmset,
|
||||||
"milestone": milestone,
|
"milestone": milestone,
|
||||||
|
"multiple": multiple,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := s.notificationProc.Send(ctx, notifReq); err != nil {
|
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 {
|
} 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 {
|
} else {
|
||||||
responseError := response.GetErrors()[0]
|
responseError := response.GetErrors()[0]
|
||||||
statusCode = MapErrorCodeToHttpStatus(responseError.GetCode())
|
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)
|
WriteResponse(w, r, *response, statusCode, methodName)
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user