Compare commits

...
Author SHA1 Message Date
ryan 80f2d0e150 add product migrations 2026-05-13 16:29:59 +07:00
ryan 12f96c1514 feat: add nullable outlet_id to product 2026-05-13 16:29:52 +07:00
ryan dc5a823508 add migration for outlet 2026-05-13 15:22:23 +07:00
ryan 691e2ea614 add outlet association to category entity and related layers 2026-05-13 15:05:03 +07:00
ryan c5f94229a7 add OutletID field to category across all layers 2026-05-13 15:04:42 +07:00
efrilm fa037b4d2a fix request outlet id at analytic 2026-05-13 14:23:27 +07:00
ryan d38a770ec5 Add omset milestone scheduler with owner role and revenue tracking 2026-05-13 09:48:17 +07:00
ryan 015292e830 Refactor: extract outlet ID filtering to helper method 2026-05-12 21:50:53 +07:00
ryan c573b23d76 Add outlet_id to use context or request 2026-05-12 18:32:36 +07:00
Efril f73a5d533c add notif at create order 2026-05-10 23:36:22 +07:00
Efril 4ea8e32a8e Merge branch 'self-order+notification' of https://gits.altru.id/apksel-dev/apskel-pos-backend into self-order+notification 2026-05-10 23:10:03 +07:00
Efril 06d79046d0 fix order and self order response 2026-05-10 23:07:57 +07:00
ryan 8eb19c57ba Change self-order response 2026-05-10 21:34:45 +07:00
Efril f123de7233 Revert "change order response at self order"
This reverts commit 7ba776555e.
2026-05-10 21:31:17 +07:00
Efril 7ba776555e change order response at self order 2026-05-10 21:19:37 +07:00
Efril bccf02b5f7 rename session_id 2026-05-10 19:56:34 +07:00
Efril c24a8a8c13 rename organization_id, add customer_name and order_type at order self order 2026-05-10 19:15:50 +07:00
ryan 6064ef8fde Update QR token generation 2026-05-10 14:52:02 +07:00
Efril 1834dd0b19 update url qrcode table 2026-05-10 14:13:20 +07:00
Efril 9f653eef37 fix migration number 2026-05-10 13:30:40 +07:00
Efril ddaf6df436 migration notification 2026-05-10 12:35:44 +07:00
49 changed files with 633 additions and 196 deletions
+1
View File
@@ -4,4 +4,5 @@ type Server struct {
Port string `mapstructure:"port"` Port string `mapstructure:"port"`
BaseUrl string `mapstructure:"common-url"` BaseUrl string `mapstructure:"common-url"`
LocalUrl string `mapstructure:"local-url"` LocalUrl string `mapstructure:"local-url"`
SelfOrderUrl string `mapstructure:"self-order-url"`
} }
+2 -1
View File
@@ -1,6 +1,7 @@
server: server:
base-url: base-url:
local-url: local-url:
self-order-url: http://localhost:5173
port: 4000 port: 4000
jwt: jwt:
@@ -57,4 +58,4 @@ fonnte:
fcm: fcm:
credentials_file: "infra/firebase-service-account.json" credentials_file: "infra/firebase-service-account.json"
project_id: "your-firebase-project-id" project_id: "apskel-pos-v2"
+19 -2
View File
@@ -30,6 +30,7 @@ type App struct {
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 {
@@ -43,6 +44,14 @@ 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)
@@ -129,6 +138,11 @@ 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{
@@ -164,6 +178,9 @@ 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)
} }
@@ -409,7 +426,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
productService := service.NewProductService(processors.productProcessor) productService := service.NewProductService(processors.productProcessor)
productVariantService := service.NewProductVariantService(processors.productVariantProcessor) productVariantService := service.NewProductVariantService(processors.productVariantProcessor)
inventoryService := service.NewInventoryService(processors.inventoryProcessor) inventoryService := service.NewInventoryService(processors.inventoryProcessor)
orderService := service.NewOrderServiceImpl(processors.orderProcessor, repos.tableRepo, nil, processors.orderIngredientTransactionProcessor, *repos.productRecipeRepo, repos.txManager, repos.sessionRepo) // Will be updated after orderIngredientTransactionService is created orderService := service.NewOrderServiceImpl(processors.orderProcessor, repos.tableRepo, nil, processors.orderIngredientTransactionProcessor, *repos.productRecipeRepo, repos.txManager, repos.sessionRepo, processors.notificationProcessor, repos.userRepo) // Will be updated after orderIngredientTransactionService is created
paymentMethodService := service.NewPaymentMethodService(processors.paymentMethodProcessor) paymentMethodService := service.NewPaymentMethodService(processors.paymentMethodProcessor)
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)
@@ -436,7 +453,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
notificationService := service.NewNotificationService(processors.notificationProcessor) notificationService := service.NewNotificationService(processors.notificationProcessor)
// Update order service with order ingredient transaction service // Update order service with order ingredient transaction service
orderService = service.NewOrderServiceImpl(processors.orderProcessor, repos.tableRepo, orderIngredientTransactionService, processors.orderIngredientTransactionProcessor, *repos.productRecipeRepo, repos.txManager, repos.sessionRepo) orderService = service.NewOrderServiceImpl(processors.orderProcessor, repos.tableRepo, orderIngredientTransactionService, processors.orderIngredientTransactionProcessor, *repos.productRecipeRepo, repos.txManager, repos.sessionRepo, processors.notificationProcessor, repos.userRepo)
return &services{ return &services{
userService: service.NewUserService(processors.userProcessor), userService: service.NewUserService(processors.userProcessor),
+2
View File
@@ -7,6 +7,7 @@ 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 {
@@ -15,6 +16,7 @@ func GetAllUserRoles() []UserRole {
RoleManager, RoleManager,
RoleCashier, RoleCashier,
RoleWaiter, RoleWaiter,
RoleOwner,
} }
} }
+6 -6
View File
@@ -8,7 +8,7 @@ import (
type PaymentMethodAnalyticsRequest struct { type PaymentMethodAnalyticsRequest struct {
OrganizationID uuid.UUID `form:"organization_id"` OrganizationID uuid.UUID `form:"organization_id"`
OutletID *uuid.UUID `form:"outlet_id,omitempty"` OutletID *string `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"`
@@ -45,7 +45,7 @@ type PaymentMethodAnalyticsData struct {
type SalesAnalyticsRequest struct { type SalesAnalyticsRequest struct {
OrganizationID uuid.UUID OrganizationID uuid.UUID
OutletID *uuid.UUID `form:"outlet_id,omitempty"` OutletID *string `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"`
@@ -86,7 +86,7 @@ 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 *uuid.UUID `form:"outlet_id,omitempty"` OutletID *string `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"`
@@ -123,7 +123,7 @@ 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 *uuid.UUID `form:"outlet_id,omitempty"` OutletID *string `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"`
} }
@@ -152,7 +152,7 @@ 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 *uuid.UUID `form:"outlet_id,omitempty"` OutletID *string `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"`
} }
@@ -182,7 +182,7 @@ 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 *uuid.UUID `form:"outlet_id,omitempty"` OutletID *string `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"`
+4
View File
@@ -8,6 +8,7 @@ import (
type CreateCategoryRequest struct { type CreateCategoryRequest struct {
Name string `json:"name" validate:"required,min=1,max=255"` Name string `json:"name" validate:"required,min=1,max=255"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`
BusinessType *string `json:"business_type,omitempty"` BusinessType *string `json:"business_type,omitempty"`
Order *int `json:"order,omitempty"` Order *int `json:"order,omitempty"`
@@ -16,6 +17,7 @@ type CreateCategoryRequest struct {
type UpdateCategoryRequest struct { type UpdateCategoryRequest struct {
Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"` Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`
BusinessType *string `json:"business_type,omitempty"` BusinessType *string `json:"business_type,omitempty"`
Order *int `json:"order,omitempty"` Order *int `json:"order,omitempty"`
@@ -24,6 +26,7 @@ type UpdateCategoryRequest struct {
type ListCategoriesRequest struct { type ListCategoriesRequest struct {
OrganizationID *uuid.UUID `json:"organization_id,omitempty"` OrganizationID *uuid.UUID `json:"organization_id,omitempty"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
BusinessType string `json:"business_type,omitempty"` BusinessType string `json:"business_type,omitempty"`
Search string `json:"search,omitempty"` Search string `json:"search,omitempty"`
Page int `json:"page" validate:"required,min=1"` Page int `json:"page" validate:"required,min=1"`
@@ -34,6 +37,7 @@ type ListCategoriesRequest struct {
type CategoryResponse struct { type CategoryResponse struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
Name string `json:"name"` Name string `json:"name"`
Description *string `json:"description"` Description *string `json:"description"`
BusinessType string `json:"business_type"` BusinessType string `json:"business_type"`
+4
View File
@@ -7,6 +7,7 @@ import (
) )
type CreateProductRequest struct { type CreateProductRequest struct {
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
CategoryID uuid.UUID `json:"category_id" validate:"required"` CategoryID uuid.UUID `json:"category_id" validate:"required"`
SKU *string `json:"sku,omitempty"` SKU *string `json:"sku,omitempty"`
Name string `json:"name" validate:"required,min=1,max=255"` Name string `json:"name" validate:"required,min=1,max=255"`
@@ -25,6 +26,7 @@ type CreateProductRequest struct {
} }
type UpdateProductRequest struct { type UpdateProductRequest struct {
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
CategoryID *uuid.UUID `json:"category_id,omitempty"` CategoryID *uuid.UUID `json:"category_id,omitempty"`
SKU *string `json:"sku,omitempty"` SKU *string `json:"sku,omitempty"`
Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"` Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"`
@@ -58,6 +60,7 @@ type UpdateProductVariantRequest struct {
type ProductResponse struct { type ProductResponse struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id"`
CategoryID uuid.UUID `json:"category_id"` CategoryID uuid.UUID `json:"category_id"`
CategoryName string `json:"category_name"` CategoryName string `json:"category_name"`
SKU *string `json:"sku"` SKU *string `json:"sku"`
@@ -89,6 +92,7 @@ type ProductVariantResponse struct {
type ListProductsRequest struct { type ListProductsRequest struct {
OrganizationID *uuid.UUID `json:"organization_id,omitempty"` OrganizationID *uuid.UUID `json:"organization_id,omitempty"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
CategoryID *uuid.UUID `json:"category_id,omitempty"` CategoryID *uuid.UUID `json:"category_id,omitempty"`
BusinessType string `json:"business_type,omitempty"` BusinessType string `json:"business_type,omitempty"`
IsActive *bool `json:"is_active,omitempty"` IsActive *bool `json:"is_active,omitempty"`
+4 -34
View File
@@ -1,8 +1,6 @@
package contract package contract
import ( import (
"time"
"github.com/google/uuid" "github.com/google/uuid"
) )
@@ -51,6 +49,8 @@ type SelfOrderMenuVariant struct {
type SelfOrderCreateOrderRequest struct { type SelfOrderCreateOrderRequest struct {
SessionID string `json:"session_id" validate:"required"` SessionID string `json:"session_id" validate:"required"`
CustomerName string `json:"customer_name" validate:"required"`
OrderType string `json:"order_type" validate:"required,oneof=dine_in takeaway delivery"`
OrderItems []SelfOrderCreateOrderItem `json:"order_items" validate:"required,min=1,dive"` OrderItems []SelfOrderCreateOrderItem `json:"order_items" validate:"required,min=1,dive"`
} }
@@ -62,7 +62,7 @@ type SelfOrderCreateOrderItem struct {
} }
type SelfOrderListCategoriesRequest struct { type SelfOrderListCategoriesRequest struct {
OrganizationID string `form:"organisasi_id" validate:"required"` OrganizationID string `form:"organization_id" validate:"required"`
OutletID string `form:"outlet_id" validate:"required"` OutletID string `form:"outlet_id" validate:"required"`
} }
@@ -78,35 +78,5 @@ type SelfOrderListCategoriesResponse struct {
} }
type SelfOrderListOrdersResponse struct { type SelfOrderListOrdersResponse struct {
Orders []SelfOrderOrderItem `json:"orders"` Orders []OrderResponse `json:"orders"`
}
type SelfOrderOrderItem struct {
ID uuid.UUID `json:"id"`
OrderNumber string `json:"order_number"`
TableNumber *string `json:"table_number,omitempty"`
OrderType string `json:"order_type"`
Status string `json:"status"`
Subtotal float64 `json:"subtotal"`
TaxAmount float64 `json:"tax_amount"`
DiscountAmount float64 `json:"discount_amount"`
TotalAmount float64 `json:"total_amount"`
RemainingAmount float64 `json:"remaining_amount"`
PaymentStatus string `json:"payment_status"`
IsVoid bool `json:"is_void"`
IsRefund bool `json:"is_refund"`
Items []SelfOrderOrderLineItem `json:"items,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type SelfOrderOrderLineItem struct {
ProductID uuid.UUID `json:"product_id"`
ProductName string `json:"product_name"`
ProductVariantID *uuid.UUID `json:"product_variant_id,omitempty"`
ProductVariantNam *string `json:"product_variant_name,omitempty"`
Quantity int `json:"quantity"`
UnitPrice float64 `json:"unit_price"`
TotalPrice float64 `json:"total_price"`
Notes *string `json:"notes,omitempty"`
Status string `json:"status"`
} }
+2
View File
@@ -33,6 +33,7 @@ func (m *Metadata) Scan(value interface{}) error {
type Category struct { type Category struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id" validate:"required"` OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id" validate:"required"`
OutletID *uuid.UUID `gorm:"type:uuid;index" json:"outlet_id,omitempty"`
Name string `gorm:"not null;size:255" json:"name" validate:"required,min=1,max=255"` Name string `gorm:"not null;size:255" json:"name" validate:"required,min=1,max=255"`
Description *string `gorm:"type:text" json:"description"` Description *string `gorm:"type:text" json:"description"`
Order int `gorm:"default:0" json:"order"` Order int `gorm:"default:0" json:"order"`
@@ -42,6 +43,7 @@ type Category struct {
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
Organization Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"` Organization Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"`
Outlet Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"`
Products []Product `gorm:"foreignKey:CategoryID" json:"products,omitempty"` Products []Product `gorm:"foreignKey:CategoryID" json:"products,omitempty"`
} }
+4
View File
@@ -37,6 +37,10 @@ func GetAllEntities() []interface{} {
&OtpSession{}, &OtpSession{},
// Analytics entities are not database tables, they are query results // Analytics entities are not database tables, they are query results
&UserDevice{}, &UserDevice{},
// Notification entities
&Notification{},
&NotificationReceiver{},
&NotificationDelivery{},
} }
} }
+2
View File
@@ -10,6 +10,7 @@ import (
type Product struct { type Product struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id" validate:"required"` OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id" validate:"required"`
OutletID *uuid.UUID `gorm:"type:uuid;index" json:"outlet_id"`
CategoryID uuid.UUID `gorm:"type:uuid;not null;index" json:"category_id" validate:"required"` CategoryID uuid.UUID `gorm:"type:uuid;not null;index" json:"category_id" validate:"required"`
SKU *string `gorm:"size:100;index" json:"sku"` SKU *string `gorm:"size:100;index" json:"sku"`
Name string `gorm:"not null;size:255" json:"name" validate:"required,min=1,max=255"` Name string `gorm:"not null;size:255" json:"name" validate:"required,min=1,max=255"`
@@ -27,6 +28,7 @@ type Product struct {
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
Organization Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"` Organization Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"`
Outlet *Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"`
Category Category `gorm:"foreignKey:CategoryID" json:"category,omitempty"` Category Category `gorm:"foreignKey:CategoryID" json:"category,omitempty"`
Unit *Unit `gorm:"foreignKey:UnitID" json:"unit,omitempty"` Unit *Unit `gorm:"foreignKey:UnitID" json:"unit,omitempty"`
ProductVariants []ProductVariant `gorm:"foreignKey:ProductID" json:"variants,omitempty"` ProductVariants []ProductVariant `gorm:"foreignKey:ProductID" json:"variants,omitempty"`
+18 -5
View File
@@ -8,6 +8,7 @@ import (
"apskel-pos-be/internal/util" "apskel-pos-be/internal/util"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid"
) )
type AnalyticsHandler struct { type AnalyticsHandler struct {
@@ -25,6 +26,17 @@ func NewAnalyticsHandler(
} }
} }
func (h *AnalyticsHandler) resolveOutletID(c *gin.Context, contextOutletID uuid.UUID) *string {
if outletIDStr := c.Query("outlet_id"); outletIDStr != "" {
return &outletIDStr
}
if contextOutletID != uuid.Nil {
s := contextOutletID.String()
return &s
}
return nil
}
func (h *AnalyticsHandler) GetPaymentMethodAnalytics(c *gin.Context) { func (h *AnalyticsHandler) GetPaymentMethodAnalytics(c *gin.Context) {
ctx := c.Request.Context() ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx) contextInfo := appcontext.FromGinContext(ctx)
@@ -36,7 +48,7 @@ func (h *AnalyticsHandler) GetPaymentMethodAnalytics(c *gin.Context) {
} }
req.OrganizationID = contextInfo.OrganizationID req.OrganizationID = contextInfo.OrganizationID
req.OutletID = &contextInfo.OutletID req.OutletID = h.resolveOutletID(c, contextInfo.OutletID)
modelReq := transformer.PaymentMethodAnalyticsContractToModel(&req) modelReq := transformer.PaymentMethodAnalyticsContractToModel(&req)
response, err := h.analyticsService.GetPaymentMethodAnalytics(ctx, modelReq) response, err := h.analyticsService.GetPaymentMethodAnalytics(ctx, modelReq)
@@ -60,7 +72,7 @@ func (h *AnalyticsHandler) GetSalesAnalytics(c *gin.Context) {
} }
req.OrganizationID = contextInfo.OrganizationID req.OrganizationID = contextInfo.OrganizationID
req.OutletID = &contextInfo.OutletID req.OutletID = h.resolveOutletID(c, contextInfo.OutletID)
modelReq := transformer.SalesAnalyticsContractToModel(&req) modelReq := transformer.SalesAnalyticsContractToModel(&req)
response, err := h.analyticsService.GetSalesAnalytics(ctx, modelReq) response, err := h.analyticsService.GetSalesAnalytics(ctx, modelReq)
@@ -84,7 +96,7 @@ func (h *AnalyticsHandler) GetProductAnalytics(c *gin.Context) {
} }
req.OrganizationID = contextInfo.OrganizationID req.OrganizationID = contextInfo.OrganizationID
req.OutletID = &contextInfo.OutletID req.OutletID = h.resolveOutletID(c, contextInfo.OutletID)
modelReq := transformer.ProductAnalyticsContractToModel(&req) modelReq := transformer.ProductAnalyticsContractToModel(&req)
response, err := h.analyticsService.GetProductAnalytics(ctx, modelReq) response, err := h.analyticsService.GetProductAnalytics(ctx, modelReq)
@@ -108,7 +120,7 @@ func (h *AnalyticsHandler) GetProductAnalyticsPerCategory(c *gin.Context) {
} }
req.OrganizationID = contextInfo.OrganizationID req.OrganizationID = contextInfo.OrganizationID
req.OutletID = &contextInfo.OutletID req.OutletID = h.resolveOutletID(c, contextInfo.OutletID)
modelReq := transformer.ProductAnalyticsPerCategoryContractToModel(&req) modelReq := transformer.ProductAnalyticsPerCategoryContractToModel(&req)
response, err := h.analyticsService.GetProductAnalyticsPerCategory(ctx, modelReq) response, err := h.analyticsService.GetProductAnalyticsPerCategory(ctx, modelReq)
@@ -132,7 +144,7 @@ func (h *AnalyticsHandler) GetDashboardAnalytics(c *gin.Context) {
} }
req.OrganizationID = contextInfo.OrganizationID req.OrganizationID = contextInfo.OrganizationID
req.OutletID = &contextInfo.OutletID req.OutletID = h.resolveOutletID(c, contextInfo.OutletID)
modelReq := transformer.DashboardAnalyticsContractToModel(&req) modelReq := transformer.DashboardAnalyticsContractToModel(&req)
response, err := h.analyticsService.GetDashboardAnalytics(ctx, modelReq) response, err := h.analyticsService.GetDashboardAnalytics(ctx, modelReq)
@@ -156,6 +168,7 @@ func (h *AnalyticsHandler) GetProfitLossAnalytics(c *gin.Context) {
} }
req.OrganizationID = contextInfo.OrganizationID req.OrganizationID = contextInfo.OrganizationID
req.OutletID = h.resolveOutletID(c, contextInfo.OutletID)
modelReq, err := transformer.ProfitLossAnalyticsContractToModel(&req) modelReq, err := transformer.ProfitLossAnalyticsContractToModel(&req)
if err != nil { if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetProfitLossAnalytics", err.Error())}), "AnalyticsHandler::GetProfitLossAnalytics") util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetProfitLossAnalytics", err.Error())}), "AnalyticsHandler::GetProfitLossAnalytics")
+6
View File
@@ -170,6 +170,12 @@ func (h *CategoryHandler) ListCategories(c *gin.Context) {
req.BusinessType = businessType req.BusinessType = businessType
} }
if outletIDStr := c.Query("outlet_id"); outletIDStr != "" {
if outletID, err := uuid.Parse(outletIDStr); err == nil {
req.OutletID = &outletID
}
}
if organizationIDStr := c.Query("organization_id"); organizationIDStr != "" { if organizationIDStr := c.Query("organization_id"); organizationIDStr != "" {
if organizationID, err := uuid.Parse(organizationIDStr); err == nil { if organizationID, err := uuid.Parse(organizationIDStr); err == nil {
req.OrganizationID = &organizationID req.OrganizationID = &organizationID
+6
View File
@@ -172,6 +172,12 @@ func (h *ProductHandler) ListProducts(c *gin.Context) {
} }
} }
if outletIDStr := c.Query("outlet_id"); outletIDStr != "" {
if outletID, err := uuid.Parse(outletIDStr); err == nil {
req.OutletID = &outletID
}
}
if categoryIDStr := c.Query("category_id"); categoryIDStr != "" { if categoryIDStr := c.Query("category_id"); categoryIDStr != "" {
if categoryID, err := uuid.Parse(categoryIDStr); err == nil { if categoryID, err := uuid.Parse(categoryIDStr); err == nil {
req.CategoryID = &categoryID req.CategoryID = &categoryID
+17 -1
View File
@@ -8,6 +8,7 @@ import (
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid"
) )
type ReportHandler struct { type ReportHandler struct {
@@ -19,11 +20,26 @@ func NewReportHandler(reportService service.ReportService, userService UserServi
return &ReportHandler{reportService: reportService, userService: userService} return &ReportHandler{reportService: reportService, userService: userService}
} }
func (h *ReportHandler) resolveOutletID(c *gin.Context, contextOutletID uuid.UUID) string {
if outletIDStr := c.Query("outlet_id"); outletIDStr != "" {
if _, err := uuid.Parse(outletIDStr); err == nil {
return outletIDStr
}
}
if pathOutletID := c.Param("outlet_id"); pathOutletID != "" {
return pathOutletID
}
if contextOutletID != uuid.Nil {
return contextOutletID.String()
}
return ""
}
func (h *ReportHandler) GetDailyTransactionReportPDF(c *gin.Context) { func (h *ReportHandler) GetDailyTransactionReportPDF(c *gin.Context) {
ctx := c.Request.Context() ctx := c.Request.Context()
ci := appcontext.FromGinContext(ctx) ci := appcontext.FromGinContext(ctx)
outletID := c.Param("outlet_id") outletID := h.resolveOutletID(c, ci.OutletID)
var dayPtr *time.Time var dayPtr *time.Time
if d := c.Query("date"); d != "" { if d := c.Query("date"); d != "" {
if t, err := time.Parse("2006-01-02", d); err == nil { if t, err := time.Parse("2006-01-02", d); err == nil {
+14 -43
View File
@@ -5,6 +5,7 @@ import (
"apskel-pos-be/internal/contract" "apskel-pos-be/internal/contract"
"apskel-pos-be/internal/entities" "apskel-pos-be/internal/entities"
"apskel-pos-be/internal/logger" "apskel-pos-be/internal/logger"
"apskel-pos-be/internal/mappers"
"apskel-pos-be/internal/models" "apskel-pos-be/internal/models"
"apskel-pos-be/internal/pkg/tabletoken" "apskel-pos-be/internal/pkg/tabletoken"
"apskel-pos-be/internal/processor" "apskel-pos-be/internal/processor"
@@ -330,6 +331,7 @@ func (h *SelfOrderHandler) CreateOrder(c *gin.Context) {
metadata := make(map[string]interface{}) metadata := make(map[string]interface{})
metadata["self_order"] = true metadata["self_order"] = true
metadata["session_id"] = session.ID metadata["session_id"] = session.ID
metadata["customer_name"] = req.CustomerName
tableID := table.ID tableID := table.ID
modelReq := &models.CreateOrderRequest{ modelReq := &models.CreateOrderRequest{
@@ -337,7 +339,7 @@ func (h *SelfOrderHandler) CreateOrder(c *gin.Context) {
UserID: userID, UserID: userID,
TableID: &tableID, TableID: &tableID,
TableNumber: &table.TableName, TableNumber: &table.TableName,
OrderType: constants.OrderTypeDineIn, OrderType: constants.OrderType(req.OrderType),
OrderItems: orderItems, OrderItems: orderItems,
Metadata: metadata, Metadata: metadata,
} }
@@ -357,7 +359,7 @@ func (h *SelfOrderHandler) CreateOrder(c *gin.Context) {
func (h *SelfOrderHandler) GetOrdersBySession(c *gin.Context) { func (h *SelfOrderHandler) GetOrdersBySession(c *gin.Context) {
ctx := c.Request.Context() ctx := c.Request.Context()
sessionID := c.Param("sessionId") sessionID := c.Param("session_id")
if sessionID == "" { if sessionID == "" {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
@@ -390,47 +392,15 @@ func (h *SelfOrderHandler) GetOrdersBySession(c *gin.Context) {
return return
} }
resp := &contract.SelfOrderListOrdersResponse{ modelOrders := mappers.OrderEntitiesToResponses(orders)
Orders: make([]contract.SelfOrderOrderItem, 0, len(orders)), contractOrders := make([]contract.OrderResponse, len(modelOrders))
} for i := range modelOrders {
for _, o := range orders { contractOrders[i] = *transformer.OrderModelToContract(&modelOrders[i])
item := contract.SelfOrderOrderItem{
ID: o.ID,
OrderNumber: o.OrderNumber,
TableNumber: o.TableNumber,
OrderType: string(o.OrderType),
Status: string(o.Status),
Subtotal: o.Subtotal,
TaxAmount: o.TaxAmount,
DiscountAmount: o.DiscountAmount,
TotalAmount: o.TotalAmount,
RemainingAmount: o.RemainingAmount,
PaymentStatus: string(o.PaymentStatus),
IsVoid: o.IsVoid,
IsRefund: o.IsRefund,
CreatedAt: o.CreatedAt,
}
for _, oi := range o.OrderItems {
lineItem := contract.SelfOrderOrderLineItem{
ProductID: oi.ProductID,
Quantity: oi.Quantity,
UnitPrice: oi.UnitPrice,
TotalPrice: oi.TotalPrice,
Notes: oi.Notes,
Status: string(oi.Status),
ProductVariantID: oi.ProductVariantID,
}
if oi.Product.ID != uuid.Nil {
lineItem.ProductName = oi.Product.Name
}
if oi.ProductVariant != nil {
lineItem.ProductVariantNam = &oi.ProductVariant.Name
}
item.Items = append(item.Items, lineItem)
}
resp.Orders = append(resp.Orders, item)
} }
resp := &contract.SelfOrderListOrdersResponse{
Orders: contractOrders,
}
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(resp), "SelfOrderHandler::GetOrdersBySession") util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(resp), "SelfOrderHandler::GetOrdersBySession")
} }
@@ -439,6 +409,7 @@ func (h *SelfOrderHandler) validateCreateOrderRequest(req *contract.SelfOrderCre
return fmt.Errorf("session_id is required") return fmt.Errorf("session_id is required")
} }
if len(req.OrderItems) == 0 { if len(req.OrderItems) == 0 {
return fmt.Errorf("at least one order item is required") return fmt.Errorf("at least one order item is required")
} }
for i, item := range req.OrderItems { for i, item := range req.OrderItems {
@@ -466,7 +437,7 @@ func (h *SelfOrderHandler) ListCategories(c *gin.Context) {
if req.OrganizationID == "" { if req.OrganizationID == "" {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, "organisasi_id is required"), contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, "organization_id is required"),
}), "SelfOrderHandler::ListCategories") }), "SelfOrderHandler::ListCategories")
return return
} }
@@ -481,7 +452,7 @@ func (h *SelfOrderHandler) ListCategories(c *gin.Context) {
orgID, err := uuid.Parse(req.OrganizationID) orgID, err := uuid.Parse(req.OrganizationID)
if err != nil { if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.ValidationErrorCode, constants.RequestEntity, "invalid organisasi_id format"), contract.NewResponseError(constants.ValidationErrorCode, constants.RequestEntity, "invalid organization_id format"),
}), "SelfOrderHandler::ListCategories") }), "SelfOrderHandler::ListCategories")
return return
} }
+5 -5
View File
@@ -19,14 +19,14 @@ import (
type TableHandler struct { type TableHandler struct {
tableService TableService tableService TableService
tableValidator *validator.TableValidator tableValidator *validator.TableValidator
baseURL string selfOrderURL string
} }
func NewTableHandler(tableService TableService, tableValidator *validator.TableValidator, baseURL string) *TableHandler { func NewTableHandler(tableService TableService, tableValidator *validator.TableValidator, selfOrderURL string) *TableHandler {
return &TableHandler{ return &TableHandler{
tableService: tableService, tableService: tableService,
tableValidator: tableValidator, tableValidator: tableValidator,
baseURL: baseURL, selfOrderURL: selfOrderURL,
} }
} }
@@ -312,7 +312,7 @@ func (h *TableHandler) GenerateQRCode(c *gin.Context) {
return return
} }
selfOrderURL := fmt.Sprintf("%s/api/v1/self-order/table/%s", h.baseURL, token) selfOrderURLResult := fmt.Sprintf("%s/menu?token=%s", h.selfOrderURL, token)
size := 256 size := 256
if sizeStr := c.Query("size"); sizeStr != "" { if sizeStr := c.Query("size"); sizeStr != "" {
@@ -321,7 +321,7 @@ func (h *TableHandler) GenerateQRCode(c *gin.Context) {
} }
} }
pngBytes, err := qrcode.GeneratePNG(selfOrderURL, size) pngBytes, err := qrcode.GeneratePNG(selfOrderURLResult, size)
if err != nil { if err != nil {
logger.FromContext(ctx).WithError(err).Error("TableHandler::GenerateQRCode -> QR generation failed") logger.FromContext(ctx).WithError(err).Error("TableHandler::GenerateQRCode -> QR generation failed")
validationResponseError := contract.NewResponseError(constants.InternalServerErrorCode, constants.TableEntity, "Failed to generate QR code") validationResponseError := contract.NewResponseError(constants.InternalServerErrorCode, constants.TableEntity, "Failed to generate QR code")
+4
View File
@@ -9,6 +9,7 @@ import (
type Category struct { type Category struct {
ID uuid.UUID ID uuid.UUID
OrganizationID uuid.UUID OrganizationID uuid.UUID
OutletID *uuid.UUID
Name string Name string
Description *string Description *string
ImageURL *string ImageURL *string
@@ -20,6 +21,7 @@ type Category struct {
type CreateCategoryRequest struct { type CreateCategoryRequest struct {
OrganizationID uuid.UUID `validate:"required"` OrganizationID uuid.UUID `validate:"required"`
OutletID *uuid.UUID
Name string `validate:"required,min=1,max=255"` Name string `validate:"required,min=1,max=255"`
Description *string `validate:"omitempty,max=1000"` Description *string `validate:"omitempty,max=1000"`
ImageURL *string `validate:"omitempty,url"` ImageURL *string `validate:"omitempty,url"`
@@ -27,6 +29,7 @@ type CreateCategoryRequest struct {
} }
type UpdateCategoryRequest struct { type UpdateCategoryRequest struct {
OutletID *uuid.UUID `validate:"omitempty,required"`
Name *string `validate:"omitempty,min=1,max=255"` Name *string `validate:"omitempty,min=1,max=255"`
Description *string `validate:"omitempty,max=1000"` Description *string `validate:"omitempty,max=1000"`
ImageURL *string `validate:"omitempty,url"` ImageURL *string `validate:"omitempty,url"`
@@ -37,6 +40,7 @@ type UpdateCategoryRequest struct {
type CategoryResponse struct { type CategoryResponse struct {
ID uuid.UUID ID uuid.UUID
OrganizationID uuid.UUID OrganizationID uuid.UUID
OutletID *uuid.UUID
Name string Name string
Description *string Description *string
ImageURL *string ImageURL *string
+4
View File
@@ -10,6 +10,7 @@ import (
type Product struct { type Product struct {
ID uuid.UUID ID uuid.UUID
OrganizationID uuid.UUID OrganizationID uuid.UUID
OutletID *uuid.UUID
CategoryID uuid.UUID CategoryID uuid.UUID
SKU *string SKU *string
Name string Name string
@@ -40,6 +41,7 @@ type ProductVariant struct {
type CreateProductRequest struct { type CreateProductRequest struct {
OrganizationID uuid.UUID `validate:"required"` OrganizationID uuid.UUID `validate:"required"`
OutletID *uuid.UUID `validate:"omitempty"`
CategoryID uuid.UUID `validate:"required"` CategoryID uuid.UUID `validate:"required"`
SKU *string `validate:"omitempty,max=100"` SKU *string `validate:"omitempty,max=100"`
Name string `validate:"required,min=1,max=255"` Name string `validate:"required,min=1,max=255"`
@@ -60,6 +62,7 @@ type CreateProductRequest struct {
} }
type UpdateProductRequest struct { type UpdateProductRequest struct {
OutletID *uuid.UUID `validate:"omitempty"`
CategoryID *uuid.UUID `validate:"omitempty"` CategoryID *uuid.UUID `validate:"omitempty"`
SKU *string `validate:"omitempty,max=100"` SKU *string `validate:"omitempty,max=100"`
Name *string `validate:"omitempty,min=1,max=255"` Name *string `validate:"omitempty,min=1,max=255"`
@@ -94,6 +97,7 @@ type UpdateProductVariantRequest struct {
type ProductResponse struct { type ProductResponse struct {
ID uuid.UUID ID uuid.UUID
OrganizationID uuid.UUID OrganizationID uuid.UUID
OutletID *uuid.UUID
CategoryID uuid.UUID CategoryID uuid.UUID
CategoryName string CategoryName string
SKU *string SKU *string
+4
View File
@@ -55,6 +55,7 @@ func (p *CategoryProcessorImpl) CreateCategory(ctx context.Context, req *models.
// Map request to entity // Map request to entity
categoryEntity := mappers.CreateCategoryRequestToEntity(req) categoryEntity := mappers.CreateCategoryRequestToEntity(req)
categoryEntity.OutletID = req.OutletID
// Create category // Create category
if err := p.categoryRepo.Create(ctx, categoryEntity); err != nil { if err := p.categoryRepo.Create(ctx, categoryEntity); err != nil {
@@ -86,6 +87,9 @@ func (p *CategoryProcessorImpl) UpdateCategory(ctx context.Context, id uuid.UUID
// Apply updates to entity // Apply updates to entity
mappers.UpdateCategoryEntityFromRequest(existingCategory, req) mappers.UpdateCategoryEntityFromRequest(existingCategory, req)
if req.OutletID != nil {
existingCategory.OutletID = req.OutletID
}
// Update category // Update category
if err := p.categoryRepo.Update(ctx, existingCategory); err != nil { if err := p.categoryRepo.Update(ctx, existingCategory); err != nil {
+10
View File
@@ -4,6 +4,7 @@ import (
"apskel-pos-be/internal/constants" "apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities" "apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models" "apskel-pos-be/internal/models"
"apskel-pos-be/internal/pkg/tabletoken"
"apskel-pos-be/internal/repository" "apskel-pos-be/internal/repository"
"context" "context"
"errors" "errors"
@@ -212,6 +213,15 @@ func (p *TableProcessor) GetTokenByID(ctx context.Context, id uuid.UUID) (string
if err != nil { if err != nil {
return "", err return "", err
} }
if _, _, _, err := tabletoken.Decode(table.Token); err != nil {
newToken := tabletoken.Encode(table.ID, table.OrganizationID, table.OutletID)
if updateErr := p.tableRepo.UpdateToken(ctx, table.ID, newToken); updateErr != nil {
return "", updateErr
}
return newToken, nil
}
return table.Token, nil return table.Token, nil
} }
+14 -21
View File
@@ -29,6 +29,13 @@ 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
@@ -50,9 +57,7 @@ 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)
if outletID != nil { query = r.resolveOutletID(query, outletID, "o.outlet_id")
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").
@@ -180,9 +185,7 @@ 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)
if outletID != nil { query = r.resolveOutletID(query, outletID, "o.outlet_id")
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").
@@ -235,9 +238,7 @@ 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)
if outletID != nil { query = r.resolveOutletID(query, outletID, "o.outlet_id")
query = query.Where("o.outlet_id = ?", *outletID)
}
err := query. err := query.
Group("c.id, c.name"). Group("c.id, c.name").
@@ -267,9 +268,7 @@ 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)
if outletID != nil { query = r.resolveOutletID(query, outletID, "o.outlet_id")
query = query.Where("o.outlet_id = ?", *outletID)
}
err := query.Scan(&result).Error err := query.Scan(&result).Error
if err != nil { if err != nil {
@@ -320,9 +319,7 @@ 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)
if outletID != nil { summaryQuery = r.resolveOutletID(summaryQuery, outletID, "o.outlet_id")
summaryQuery = summaryQuery.Where("o.outlet_id = ?", *outletID)
}
err := summaryQuery.Scan(&summary).Error err := summaryQuery.Scan(&summary).Error
if err != nil { if err != nil {
@@ -374,9 +371,7 @@ func (r *AnalyticsRepositoryImpl) GetProfitLossAnalytics(ctx context.Context, or
Group(timeFormat). Group(timeFormat).
Order(timeFormat) Order(timeFormat)
if outletID != nil { dataQuery = r.resolveOutletID(dataQuery, outletID, "o.outlet_id")
dataQuery = dataQuery.Where("o.outlet_id = ?", *outletID)
}
err = dataQuery.Scan(&data).Error err = dataQuery.Scan(&data).Error
if err != nil { if err != nil {
@@ -419,9 +414,7 @@ func (r *AnalyticsRepositoryImpl) GetProfitLossAnalytics(ctx context.Context, or
Order("p.name ASC"). Order("p.name ASC").
Limit(1000) Limit(1000)
if outletID != nil { productQuery = r.resolveOutletID(productQuery, outletID, "o.outlet_id")
productQuery = productQuery.Where("o.outlet_id = ?", *outletID)
}
err = productQuery.Scan(&productData).Error err = productQuery.Scan(&productData).Error
if err != nil { if err != nil {
+3 -3
View File
@@ -25,7 +25,7 @@ func (r *CategoryRepositoryImpl) Create(ctx context.Context, category *entities.
func (r *CategoryRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) (*entities.Category, error) { func (r *CategoryRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) (*entities.Category, error) {
var category entities.Category var category entities.Category
err := r.db.WithContext(ctx).First(&category, "id = ?", id).Error err := r.db.WithContext(ctx).Preload("Outlet").First(&category, "id = ?", id).Error
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -34,7 +34,7 @@ func (r *CategoryRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) (*en
func (r *CategoryRepositoryImpl) GetWithProducts(ctx context.Context, id uuid.UUID) (*entities.Category, error) { func (r *CategoryRepositoryImpl) GetWithProducts(ctx context.Context, id uuid.UUID) (*entities.Category, error) {
var category entities.Category var category entities.Category
err := r.db.WithContext(ctx).Preload("Products").First(&category, "id = ?", id).Error err := r.db.WithContext(ctx).Preload("Products").Preload("Outlet").First(&category, "id = ?", id).Error
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -81,7 +81,7 @@ func (r *CategoryRepositoryImpl) List(ctx context.Context, filters map[string]in
return nil, 0, err return nil, 0, err
} }
err := query.Order("\"order\" ASC").Limit(limit).Offset(offset).Find(&categories).Error err := query.Preload("Outlet").Order("\"order\" ASC").Limit(limit).Offset(offset).Find(&categories).Error
return categories, total, err return categories, total, err
} }
+18 -8
View File
@@ -11,6 +11,7 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
) )
type InventoryRepository interface { type InventoryRepository interface {
@@ -278,7 +279,12 @@ func (r *InventoryRepositoryImpl) UpdateReorderLevel(ctx context.Context, id uui
} }
func (r *InventoryRepositoryImpl) BulkCreate(ctx context.Context, inventoryItems []*entities.Inventory) error { func (r *InventoryRepositoryImpl) BulkCreate(ctx context.Context, inventoryItems []*entities.Inventory) error {
return r.db.WithContext(ctx).CreateInBatches(inventoryItems, 100).Error return r.db.WithContext(ctx).
Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "outlet_id"}, {Name: "product_id"}},
DoNothing: true,
}).
CreateInBatches(inventoryItems, 100).Error
} }
func (r *InventoryRepositoryImpl) BulkUpdate(ctx context.Context, inventoryItems []*entities.Inventory) error { func (r *InventoryRepositoryImpl) BulkUpdate(ctx context.Context, inventoryItems []*entities.Inventory) error {
@@ -301,21 +307,25 @@ func (r *InventoryRepositoryImpl) BulkAdjustQuantity(ctx context.Context, adjust
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for productID, delta := range adjustments { for productID, delta := range adjustments {
var inventory entities.Inventory var inventory entities.Inventory
if err := tx.Where("product_id = ? AND outlet_id = ?", productID, outletID).First(&inventory).Error; err != nil { err := tx.Set("gorm:query_option", "FOR UPDATE").
if errors.Is(err, gorm.ErrRecordNotFound) { Where("product_id = ? AND outlet_id = ?", productID, outletID).
// Inventory doesn't exist, create it with initial quantity First(&inventory).Error
if err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
// Use FirstOrCreate to handle race conditions — avoids duplicate key
// if another transaction already inserted this row concurrently.
inventory = entities.Inventory{ inventory = entities.Inventory{
ProductID: productID, ProductID: productID,
OutletID: outletID, OutletID: outletID,
Quantity: 0, Quantity: 0,
ReorderLevel: 0, ReorderLevel: 0,
} }
if err := tx.Create(&inventory).Error; err != nil { if err := tx.Where(entities.Inventory{ProductID: productID, OutletID: outletID}).
FirstOrCreate(&inventory).Error; err != nil {
return fmt.Errorf("failed to create inventory record for product %s: %w", productID, err) return fmt.Errorf("failed to create inventory record for product %s: %w", productID, err)
} }
} else {
return err
}
} }
inventory.UpdateQuantity(delta) inventory.UpdateQuantity(delta)
@@ -99,3 +99,14 @@ 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
}
+7
View File
@@ -171,6 +171,13 @@ func (r *TableRepository) ReleaseTable(ctx context.Context, tableID uuid.UUID, p
}).Error }).Error
} }
func (r *TableRepository) UpdateToken(ctx context.Context, tableID uuid.UUID, token string) error {
return r.db.WithContext(ctx).
Model(&entities.Table{}).
Where("id = ?", tableID).
Update("token", token).Error
}
func (r *TableRepository) GetByOrderID(ctx context.Context, orderID uuid.UUID) (*entities.Table, error) { func (r *TableRepository) GetByOrderID(ctx context.Context, orderID uuid.UUID) (*entities.Table, error) {
var table entities.Table var table entities.Table
err := r.db.WithContext(ctx). err := r.db.WithContext(ctx).
@@ -24,4 +24,5 @@ type TableRepositoryInterface interface {
OccupyTable(ctx context.Context, tableID, orderID uuid.UUID, startTime *time.Time) error OccupyTable(ctx context.Context, tableID, orderID uuid.UUID, startTime *time.Time) error
ReleaseTable(ctx context.Context, tableID uuid.UUID, paymentAmount float64) error ReleaseTable(ctx context.Context, tableID uuid.UUID, paymentAmount float64) error
GetByOrderID(ctx context.Context, orderID uuid.UUID) (*entities.Table, error) GetByOrderID(ctx context.Context, orderID uuid.UUID) (*entities.Table, error)
UpdateToken(ctx context.Context, tableID uuid.UUID, token string) error
} }
+11
View File
@@ -61,6 +61,17 @@ func (r *UserRepositoryImpl) GetActiveUsers(ctx context.Context, organizationID
return users, err return users, err
} }
func (r *UserRepositoryImpl) GetActiveByOutletID(ctx context.Context, organizationID, outletID uuid.UUID) ([]*entities.User, error) {
var users []*entities.User
err := r.db.WithContext(ctx).
Where(
"organization_id = ? AND is_active = ? AND (outlet_id = ? OR role IN ?)",
organizationID, true, outletID, []string{"admin", "manager"},
).
Find(&users).Error
return users, err
}
func (r *UserRepositoryImpl) Update(ctx context.Context, user *entities.User) error { func (r *UserRepositoryImpl) Update(ctx context.Context, user *entities.User) error {
return r.db.WithContext(ctx).Save(user).Error return r.db.WithContext(ctx).Save(user).Error
} }
+2 -2
View File
@@ -72,7 +72,7 @@ func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authSer
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), reportHandler: handler.NewReportHandler(reportService, userService),
tableHandler: handler.NewTableHandler(tableService, tableValidator, cfg.Server.BaseUrl), tableHandler: handler.NewTableHandler(tableService, tableValidator, cfg.Server.SelfOrderUrl),
unitHandler: handler.NewUnitHandler(unitService), unitHandler: handler.NewUnitHandler(unitService),
ingredientHandler: handler.NewIngredientHandler(ingredientService), ingredientHandler: handler.NewIngredientHandler(ingredientService),
productRecipeHandler: handler.NewProductRecipeHandler(productRecipeService), productRecipeHandler: handler.NewProductRecipeHandler(productRecipeService),
@@ -157,7 +157,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
selfOrder.GET("/categories", r.selfOrderHandler.ListCategories) selfOrder.GET("/categories", r.selfOrderHandler.ListCategories)
selfOrder.GET("/menu", r.selfOrderHandler.GetMenu) selfOrder.GET("/menu", r.selfOrderHandler.GetMenu)
selfOrder.POST("/orders", r.selfOrderHandler.CreateOrder) selfOrder.POST("/orders", r.selfOrderHandler.CreateOrder)
selfOrder.GET("/orders/:sessionId", r.selfOrderHandler.GetOrdersBySession) selfOrder.GET("/orders/:session_id", r.selfOrderHandler.GetOrdersBySession)
} }
organizations := v1.Group("/organizations") organizations := v1.Group("/organizations")
+3
View File
@@ -88,6 +88,9 @@ func (s *CategoryServiceImpl) ListCategories(ctx context.Context, req *contract.
if req.BusinessType != "" { if req.BusinessType != "" {
filters["business_type"] = req.BusinessType filters["business_type"] = req.BusinessType
} }
if req.OutletID != nil {
filters["outlet_id"] = *req.OutletID
}
if req.Search != "" { if req.Search != "" {
filters["search"] = req.Search filters["search"] = req.Search
} }
@@ -0,0 +1,171 @@
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)
}
}
+74 -2
View File
@@ -16,6 +16,11 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
) )
// orderUserRepository is a minimal interface to fetch users by organization for notification purposes.
type orderUserRepository interface {
GetActiveByOutletID(ctx context.Context, organizationID, outletID uuid.UUID) ([]*entities.User, error)
}
type OrderService interface { type OrderService interface {
CreateOrder(ctx context.Context, req *models.CreateOrderRequest, organizationID uuid.UUID) (*models.OrderResponse, error) CreateOrder(ctx context.Context, req *models.CreateOrderRequest, organizationID uuid.UUID) (*models.OrderResponse, error)
AddToOrder(ctx context.Context, orderID uuid.UUID, req *models.AddToOrderRequest) (*models.AddToOrderResponse, error) AddToOrder(ctx context.Context, orderID uuid.UUID, req *models.AddToOrderRequest) (*models.AddToOrderResponse, error)
@@ -38,9 +43,11 @@ type OrderServiceImpl struct {
productRecipeRepo repository.ProductRecipeRepository productRecipeRepo repository.ProductRecipeRepository
txManager *repository.TxManager txManager *repository.TxManager
sessionRepo repository.SessionRepository sessionRepo repository.SessionRepository
notificationProcessor processor.NotificationProcessor
userRepo orderUserRepository
} }
func NewOrderServiceImpl(orderProcessor processor.OrderProcessor, tableRepo repository.TableRepositoryInterface, orderIngredientTransactionService *OrderIngredientTransactionService, orderIngredientTransactionProcessor processor.OrderIngredientTransactionProcessor, productRecipeRepo repository.ProductRecipeRepository, txManager *repository.TxManager, sessionRepo repository.SessionRepository) *OrderServiceImpl { func NewOrderServiceImpl(orderProcessor processor.OrderProcessor, tableRepo repository.TableRepositoryInterface, orderIngredientTransactionService *OrderIngredientTransactionService, orderIngredientTransactionProcessor processor.OrderIngredientTransactionProcessor, productRecipeRepo repository.ProductRecipeRepository, txManager *repository.TxManager, sessionRepo repository.SessionRepository, notificationProcessor processor.NotificationProcessor, userRepo orderUserRepository) *OrderServiceImpl {
return &OrderServiceImpl{ return &OrderServiceImpl{
orderProcessor: orderProcessor, orderProcessor: orderProcessor,
tableRepo: tableRepo, tableRepo: tableRepo,
@@ -49,6 +56,8 @@ func NewOrderServiceImpl(orderProcessor processor.OrderProcessor, tableRepo repo
productRecipeRepo: productRecipeRepo, productRecipeRepo: productRecipeRepo,
txManager: txManager, txManager: txManager,
sessionRepo: sessionRepo, sessionRepo: sessionRepo,
notificationProcessor: notificationProcessor,
userRepo: userRepo,
} }
} }
@@ -104,10 +113,73 @@ func (s *OrderServiceImpl) CreateOrder(ctx context.Context, req *models.CreateOr
return nil, err return nil, err
} }
// Send notification to all org users if this is a self-order
if isSelfOrder(req.Metadata) {
go s.sendSelfOrderNotification(context.Background(), response, organizationID)
}
return response, nil return response, nil
} }
// createIngredientTransactions creates ingredient transactions for order items efficiently // isSelfOrder checks if the order metadata indicates a self-order.
func isSelfOrder(metadata map[string]interface{}) bool {
if metadata == nil {
return false
}
v, ok := metadata["self_order"]
if !ok {
return false
}
b, ok := v.(bool)
return ok && b
}
// sendSelfOrderNotification sends a new-order notification to all active users
// that can access the outlet where the self-order was placed.
func (s *OrderServiceImpl) sendSelfOrderNotification(ctx context.Context, order *models.OrderResponse, organizationID uuid.UUID) {
if s.notificationProcessor == nil || s.userRepo == nil {
return
}
users, err := s.userRepo.GetActiveByOutletID(ctx, organizationID, order.OutletID)
if err != nil || len(users) == 0 {
return
}
receiverIDs := make([]uuid.UUID, 0, len(users))
for _, u := range users {
receiverIDs = append(receiverIDs, u.ID)
}
tableName := ""
if order.TableNumber != nil {
tableName = *order.TableNumber
}
title := "Pesanan Baru Masuk"
body := fmt.Sprintf("Ada pesanan baru dari meja %s", tableName)
if tableName == "" {
body = "Ada pesanan baru masuk"
}
orderID := order.ID
notifReq := &models.SendNotificationRequest{
Title: title,
Body: body,
Type: "order",
Category: "self_order",
NotifiableType: "order",
NotifiableID: &orderID,
ReceiverIDs: receiverIDs,
Data: map[string]interface{}{
"order_id": order.ID.String(),
"order_number": order.OrderNumber,
"table_name": tableName,
},
}
_, _ = s.notificationProcessor.Send(ctx, notifReq)
}
func (s *OrderServiceImpl) createIngredientTransactions(ctx context.Context, orderID uuid.UUID, orderItems []models.OrderItemResponse) ([]*contract.CreateOrderIngredientTransactionRequest, error) { func (s *OrderServiceImpl) createIngredientTransactions(ctx context.Context, orderID uuid.UUID, orderItems []models.OrderItemResponse) ([]*contract.CreateOrderIngredientTransactionRequest, error) {
appCtx := appcontext.FromGinContext(ctx) appCtx := appcontext.FromGinContext(ctx)
organizationID := appCtx.OrganizationID organizationID := appCtx.OrganizationID
+3
View File
@@ -85,6 +85,9 @@ func (s *ProductServiceImpl) ListProducts(ctx context.Context, req *contract.Lis
if req.OrganizationID != nil { if req.OrganizationID != nil {
filters["organization_id"] = *req.OrganizationID filters["organization_id"] = *req.OrganizationID
} }
if req.OutletID != nil {
filters["outlet_id"] = *req.OutletID
}
if req.CategoryID != nil { if req.CategoryID != nil {
filters["category_id"] = *req.CategoryID filters["category_id"] = *req.CategoryID
} }
+20 -6
View File
@@ -6,8 +6,22 @@ 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
@@ -23,7 +37,7 @@ func PaymentMethodAnalyticsContractToModel(req *contract.PaymentMethodAnalyticsR
return &models.PaymentMethodAnalyticsRequest{ return &models.PaymentMethodAnalyticsRequest{
OrganizationID: req.OrganizationID, OrganizationID: req.OrganizationID,
OutletID: req.OutletID, OutletID: parseOutletID(req.OutletID),
DateFrom: dateFrom, DateFrom: dateFrom,
DateTo: dateTo, DateTo: dateTo,
GroupBy: req.GroupBy, GroupBy: req.GroupBy,
@@ -79,7 +93,7 @@ func SalesAnalyticsContractToModel(req *contract.SalesAnalyticsRequest) *models.
return &models.SalesAnalyticsRequest{ return &models.SalesAnalyticsRequest{
OrganizationID: req.OrganizationID, OrganizationID: req.OrganizationID,
OutletID: req.OutletID, OutletID: parseOutletID(req.OutletID),
DateFrom: dateFrom, DateFrom: dateFrom,
DateTo: dateTo, DateTo: dateTo,
GroupBy: req.GroupBy, GroupBy: req.GroupBy,
@@ -139,7 +153,7 @@ func ProductAnalyticsContractToModel(req *contract.ProductAnalyticsRequest) *mod
return &models.ProductAnalyticsRequest{ return &models.ProductAnalyticsRequest{
OrganizationID: req.OrganizationID, OrganizationID: req.OrganizationID,
OutletID: req.OutletID, OutletID: parseOutletID(req.OutletID),
DateFrom: dateFrom, DateFrom: dateFrom,
DateTo: dateTo, DateTo: dateTo,
Limit: req.Limit, Limit: req.Limit,
@@ -199,7 +213,7 @@ func ProductAnalyticsPerCategoryContractToModel(req *contract.ProductAnalyticsPe
return &models.ProductAnalyticsPerCategoryRequest{ return &models.ProductAnalyticsPerCategoryRequest{
OrganizationID: req.OrganizationID, OrganizationID: req.OrganizationID,
OutletID: req.OutletID, OutletID: parseOutletID(req.OutletID),
DateFrom: dateFrom, DateFrom: dateFrom,
DateTo: dateTo, DateTo: dateTo,
} }
@@ -251,7 +265,7 @@ func DashboardAnalyticsContractToModel(req *contract.DashboardAnalyticsRequest)
return &models.DashboardAnalyticsRequest{ return &models.DashboardAnalyticsRequest{
OrganizationID: req.OrganizationID, OrganizationID: req.OrganizationID,
OutletID: req.OutletID, OutletID: parseOutletID(req.OutletID),
DateFrom: dateFrom, DateFrom: dateFrom,
DateTo: dateTo, DateTo: dateTo,
} }
@@ -346,7 +360,7 @@ func ProfitLossAnalyticsContractToModel(req *contract.ProfitLossAnalyticsRequest
return &models.ProfitLossAnalyticsRequest{ return &models.ProfitLossAnalyticsRequest{
OrganizationID: req.OrganizationID, OrganizationID: req.OrganizationID,
OutletID: req.OutletID, OutletID: parseOutletID(req.OutletID),
DateFrom: *dateFrom, DateFrom: *dateFrom,
DateTo: *dateTo, DateTo: *dateTo,
GroupBy: req.GroupBy, GroupBy: req.GroupBy,
@@ -9,6 +9,7 @@ import (
func CreateCategoryRequestToModel(apctx *appcontext.ContextInfo, req *contract.CreateCategoryRequest) *models.CreateCategoryRequest { func CreateCategoryRequestToModel(apctx *appcontext.ContextInfo, req *contract.CreateCategoryRequest) *models.CreateCategoryRequest {
return &models.CreateCategoryRequest{ return &models.CreateCategoryRequest{
OrganizationID: apctx.OrganizationID, OrganizationID: apctx.OrganizationID,
OutletID: req.OutletID,
Name: req.Name, Name: req.Name,
Description: req.Description, Description: req.Description,
ImageURL: nil, ImageURL: nil,
@@ -18,6 +19,7 @@ func CreateCategoryRequestToModel(apctx *appcontext.ContextInfo, req *contract.C
func UpdateCategoryRequestToModel(req *contract.UpdateCategoryRequest) *models.UpdateCategoryRequest { func UpdateCategoryRequestToModel(req *contract.UpdateCategoryRequest) *models.UpdateCategoryRequest {
return &models.UpdateCategoryRequest{ return &models.UpdateCategoryRequest{
OutletID: req.OutletID,
Name: req.Name, Name: req.Name,
Description: req.Description, Description: req.Description,
ImageURL: nil, ImageURL: nil,
@@ -34,6 +36,7 @@ func CategoryModelResponseToResponse(cat *models.CategoryResponse) *contract.Cat
return &contract.CategoryResponse{ return &contract.CategoryResponse{
ID: cat.ID, ID: cat.ID,
OrganizationID: cat.OrganizationID, OrganizationID: cat.OrganizationID,
OutletID: cat.OutletID,
Name: cat.Name, Name: cat.Name,
Description: cat.Description, Description: cat.Description,
BusinessType: "restaurant", // Default business type BusinessType: "restaurant", // Default business type
@@ -39,6 +39,7 @@ func CreateProductRequestToModel(apctx *appcontext.ContextInfo, req *contract.Cr
return &models.CreateProductRequest{ return &models.CreateProductRequest{
OrganizationID: apctx.OrganizationID, OrganizationID: apctx.OrganizationID,
OutletID: req.OutletID,
CategoryID: req.CategoryID, CategoryID: req.CategoryID,
SKU: req.SKU, SKU: req.SKU,
Name: req.Name, Name: req.Name,
@@ -60,6 +61,7 @@ func UpdateProductRequestToModel(req *contract.UpdateProductRequest) *models.Upd
} }
return &models.UpdateProductRequest{ return &models.UpdateProductRequest{
OutletID: req.OutletID,
CategoryID: req.CategoryID, CategoryID: req.CategoryID,
SKU: req.SKU, SKU: req.SKU,
Name: req.Name, Name: req.Name,
@@ -100,6 +102,7 @@ func ProductModelResponseToResponse(prod *models.ProductResponse) *contract.Prod
return &contract.ProductResponse{ return &contract.ProductResponse{
ID: prod.ID, ID: prod.ID,
OrganizationID: prod.OrganizationID, OrganizationID: prod.OrganizationID,
OutletID: prod.OutletID,
CategoryID: prod.CategoryID, CategoryID: prod.CategoryID,
CategoryName: prod.CategoryName, CategoryName: prod.CategoryName,
SKU: prod.SKU, SKU: prod.SKU,
+1 -1
View File
@@ -59,7 +59,7 @@ func (v *CategoryValidatorImpl) ValidateUpdateCategoryRequest(req *contract.Upda
} }
// At least one field should be provided for update // At least one field should be provided for update
if req.Name == nil && req.Description == nil && req.BusinessType == nil && req.Metadata == nil { if req.Name == nil && req.Description == nil && req.BusinessType == nil && req.Metadata == nil && req.OutletID == nil {
return errors.New("at least one field must be provided for update"), constants.MissingFieldErrorCode return errors.New("at least one field must be provided for update"), constants.MissingFieldErrorCode
} }
@@ -0,0 +1 @@
DROP TABLE IF EXISTS notifications;
@@ -0,0 +1,28 @@
-- Notifications table (master notification record)
CREATE TABLE notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title VARCHAR(255) NOT NULL,
body TEXT,
type VARCHAR(100),
category VARCHAR(100),
priority VARCHAR(50) NOT NULL DEFAULT 'normal' CHECK (priority IN ('low', 'normal', 'high')),
image_url VARCHAR(512),
action_url VARCHAR(512),
notifiable_type VARCHAR(100),
notifiable_id UUID,
data JSONB,
scheduled_at TIMESTAMP WITH TIME ZONE,
sent_at TIMESTAMP WITH TIME ZONE,
expired_at TIMESTAMP WITH TIME ZONE,
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Indexes
CREATE INDEX idx_notifications_created_by ON notifications(created_by);
CREATE INDEX idx_notifications_type ON notifications(type);
CREATE INDEX idx_notifications_category ON notifications(category);
CREATE INDEX idx_notifications_notifiable ON notifications(notifiable_type, notifiable_id);
CREATE INDEX idx_notifications_scheduled_at ON notifications(scheduled_at);
CREATE INDEX idx_notifications_sent_at ON notifications(sent_at);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS notification_receivers;
@@ -0,0 +1,18 @@
-- Notification receivers table (links a notification to a specific user)
CREATE TABLE notification_receivers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
notification_id UUID NOT NULL REFERENCES notifications(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
is_read BOOLEAN NOT NULL DEFAULT FALSE,
read_at TIMESTAMP WITH TIME ZONE,
is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
deleted_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Indexes
CREATE INDEX idx_notification_receivers_notification_id ON notification_receivers(notification_id);
CREATE INDEX idx_notification_receivers_user_id ON notification_receivers(user_id);
CREATE INDEX idx_notification_receivers_user_unread ON notification_receivers(user_id, is_read) WHERE is_deleted = FALSE;
CREATE UNIQUE INDEX idx_notification_receivers_unique ON notification_receivers(notification_id, user_id);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS notification_deliveries;
@@ -0,0 +1,23 @@
-- Notification deliveries table (tracks per-device delivery attempts)
CREATE TABLE notification_deliveries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
notification_receiver_id UUID NOT NULL REFERENCES notification_receivers(id) ON DELETE CASCADE,
user_device_id UUID NOT NULL REFERENCES user_devices(id) ON DELETE CASCADE,
channel VARCHAR(50) NOT NULL DEFAULT 'push' CHECK (channel IN ('push', 'websocket', 'email')),
delivery_status VARCHAR(50) NOT NULL DEFAULT 'pending' CHECK (delivery_status IN ('pending', 'sent', 'delivered', 'failed')),
provider VARCHAR(50) CHECK (provider IN ('firebase', 'onesignal')),
provider_message_id VARCHAR(255),
sent_at TIMESTAMP WITH TIME ZONE,
delivered_at TIMESTAMP WITH TIME ZONE,
failed_at TIMESTAMP WITH TIME ZONE,
failure_reason TEXT,
retry_count INT NOT NULL DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Indexes
CREATE INDEX idx_notification_deliveries_receiver_id ON notification_deliveries(notification_receiver_id);
CREATE INDEX idx_notification_deliveries_device_id ON notification_deliveries(user_device_id);
CREATE INDEX idx_notification_deliveries_status ON notification_deliveries(delivery_status);
CREATE INDEX idx_notification_deliveries_provider ON notification_deliveries(provider);
@@ -0,0 +1,3 @@
DROP INDEX IF EXISTS idx_categories_outlet_id;
ALTER TABLE categories DROP CONSTRAINT IF EXISTS fk_categories_outlet;
ALTER TABLE categories DROP COLUMN IF EXISTS outlet_id;
@@ -0,0 +1,3 @@
ALTER TABLE categories ADD COLUMN outlet_id UUID;
ALTER TABLE categories ADD CONSTRAINT fk_categories_outlet FOREIGN KEY (outlet_id) REFERENCES outlets(id) ON DELETE SET NULL;
CREATE INDEX idx_categories_outlet_id ON categories(outlet_id);
@@ -0,0 +1,8 @@
-- Remove foreign key constraint
ALTER TABLE products DROP CONSTRAINT IF EXISTS fk_products_outlet;
-- Remove index
DROP INDEX IF EXISTS idx_products_outlet_id;
-- Remove outlet_id column
ALTER TABLE products DROP COLUMN IF EXISTS outlet_id;
@@ -0,0 +1,8 @@
-- Add nullable outlet_id column to products table
ALTER TABLE products ADD COLUMN outlet_id UUID;
-- Create index on outlet_id for faster queries
CREATE INDEX idx_products_outlet_id ON products (outlet_id);
-- Add foreign key constraint
ALTER TABLE products ADD CONSTRAINT fk_products_outlet FOREIGN KEY (outlet_id) REFERENCES outlets(id) ON DELETE SET NULL;