Compare commits

..

1 Commits

Author SHA1 Message Date
15805a4853 Implement FCM 2026-05-09 14:18:36 +07:00
221 changed files with 1198 additions and 14644 deletions

3
.gitignore vendored
View File

@ -7,5 +7,4 @@ config/env/*
vendor vendor
# Firebase service account credentials *firebase-adminsdk*.json
infra/firebase-service-account.json

View File

@ -1 +0,0 @@
{}

View File

@ -1,5 +1,5 @@
# 1) Build stage # 1) Build stage
FROM golang:1.24-alpine AS build FROM golang:1.21-alpine AS build
RUN apk --no-cache add ca-certificates tzdata git curl RUN apk --no-cache add ca-certificates tzdata git curl
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./

View File

@ -83,12 +83,6 @@ 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)

View File

@ -31,7 +31,7 @@ type Config struct {
Log Log `mapstructure:"log"` Log Log `mapstructure:"log"`
S3Config S3Config `mapstructure:"s3"` S3Config S3Config `mapstructure:"s3"`
Fonnte Fonnte `mapstructure:"fonnte"` Fonnte Fonnte `mapstructure:"fonnte"`
FCM FCM `mapstructure:"fcm"` Firebase Firebase `mapstructure:"firebase"`
} }
var ( var (
@ -97,6 +97,6 @@ func (c *Config) GetFonnte() *Fonnte {
return &c.Fonnte return &c.Fonnte
} }
func (c *Config) GetFCM() *FCM { func (c *Config) GetFirebase() *Firebase {
return &c.FCM return &c.Firebase
} }

View File

@ -1,14 +0,0 @@
package config
type FCM struct {
CredentialsFile string `mapstructure:"credentials_file"`
ProjectID string `mapstructure:"project_id"`
}
func (f *FCM) GetCredentialsFile() string {
return f.CredentialsFile
}
func (f *FCM) GetProjectID() string {
return f.ProjectID
}

9
config/firebase.go Normal file
View File

@ -0,0 +1,9 @@
package config
type Firebase struct {
CredentialsFile string `mapstructure:"credentials_file"`
}
func (f *Firebase) GetCredentialsFile() string {
return f.CredentialsFile
}

View File

@ -4,5 +4,4 @@ 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"`
} }

View File

@ -1,7 +1,6 @@
server: server:
base-url: base-url:
local-url: local-url:
self-order-url: http://localhost:5173
port: 4000 port: 4000
jwt: jwt:
@ -29,7 +28,7 @@ postgresql:
debug: false debug: false
redis: redis:
host: 194.233.78.1 host: 127.0.0.1
port: 6379 port: 6379
password: "CmICdmnX1EZPhVBYzQPEGw==U" password: "CmICdmnX1EZPhVBYzQPEGw==U"
db: 0 db: 0
@ -56,6 +55,5 @@ fonnte:
token: "bADQrf9NTXfLZQCK2wGg" token: "bADQrf9NTXfLZQCK2wGg"
timeout: 30 timeout: 30
fcm: firebase:
credentials_file: "infra/firebase-service-account.json" credentials_file: "apskel-pos-v2-firebase-adminsdk-fbsvc-ae00499526.json"
project_id: "apskel-pos-v2"

View File

@ -30,7 +30,6 @@ 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 {
@ -44,15 +43,6 @@ func NewApp(db *gorm.DB, redisClient *redis.Client) *App {
func (a *App) Initialize(cfg *config.Config) error { func (a *App) Initialize(cfg *config.Config) error {
repos := a.initRepositories() repos := a.initRepositories()
processors := a.initProcessors(cfg, repos) processors := a.initProcessors(cfg, repos)
// Initialize omset milestone scheduler
a.omsetScheduler = service.NewOmsetMilestoneScheduler(
repos.organizationRepo,
repos.outletRepo,
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)
@ -66,7 +56,7 @@ func (a *App) Initialize(cfg *config.Config) error {
repos.userRepo, repos.userRepo,
repos.sessionRepo, repos.sessionRepo,
repos.orderRepo, repos.orderRepo,
services.productOutletPriceService, processors.fcmClient,
) )
a.router = router.NewRouter( a.router = router.NewRouter(
@ -108,8 +98,6 @@ func (a *App) Initialize(cfg *config.Config) error {
validators.vendorValidator, validators.vendorValidator,
services.purchaseOrderService, services.purchaseOrderService,
validators.purchaseOrderValidator, validators.purchaseOrderValidator,
services.purchaseCategoryService,
validators.purchaseCategoryValidator,
services.unitConverterService, services.unitConverterService,
validators.unitConverterValidator, validators.unitConverterValidator,
services.chartOfAccountTypeService, services.chartOfAccountTypeService,
@ -131,27 +119,13 @@ func (a *App) Initialize(cfg *config.Config) error {
services.customerPointsService, services.customerPointsService,
services.spinGameService, services.spinGameService,
middleware.customerAuthMiddleware, middleware.customerAuthMiddleware,
services.userDeviceService,
validators.userDeviceValidator,
services.notificationService,
validators.notificationValidator,
services.productOutletPriceService,
validators.productOutletPriceValidator,
selfOrderHandler, selfOrderHandler,
services.expenseService,
validators.expenseValidator,
a.redisClient,
) )
return nil return nil
} }
func (a *App) Start(port string) error { func (a *App) Start(port string) error {
// Start the omset milestone scheduler (checks every 5 minutes for daily omset milestones)
if a.omsetScheduler != nil {
a.omsetScheduler.Start(5 * time.Minute)
}
engine := a.router.Init() engine := a.router.Init()
a.server = &http.Server{ a.server = &http.Server{
@ -187,9 +161,6 @@ func (a *App) Start(port string) error {
} }
func (a *App) Shutdown() { func (a *App) Shutdown() {
if a.omsetScheduler != nil {
a.omsetScheduler.Stop()
}
close(a.shutdown) close(a.shutdown)
} }
@ -218,7 +189,6 @@ type repositories struct {
productRecipeRepo *repository.ProductRecipeRepository productRecipeRepo *repository.ProductRecipeRepository
vendorRepo *repository.VendorRepositoryImpl vendorRepo *repository.VendorRepositoryImpl
purchaseOrderRepo *repository.PurchaseOrderRepositoryImpl purchaseOrderRepo *repository.PurchaseOrderRepositoryImpl
purchaseCategoryRepo *repository.PurchaseCategoryRepositoryImpl
unitConverterRepo *repository.IngredientUnitConverterRepositoryImpl unitConverterRepo *repository.IngredientUnitConverterRepositoryImpl
chartOfAccountTypeRepo *repository.ChartOfAccountTypeRepositoryImpl chartOfAccountTypeRepo *repository.ChartOfAccountTypeRepositoryImpl
chartOfAccountRepo *repository.ChartOfAccountRepositoryImpl chartOfAccountRepo *repository.ChartOfAccountRepositoryImpl
@ -238,12 +208,6 @@ type repositories struct {
otpRepo repository.OtpRepository otpRepo repository.OtpRepository
sessionRepo repository.SessionRepository sessionRepo repository.SessionRepository
txManager *repository.TxManager txManager *repository.TxManager
userDeviceRepo *repository.UserDeviceRepositoryImpl
notificationRepo *repository.NotificationRepositoryImpl
notificationReceiverRepo *repository.NotificationReceiverRepositoryImpl
notificationDeliveryRepo *repository.NotificationDeliveryRepositoryImpl
productOutletPriceRepo *repository.ProductOutletPriceRepositoryImpl
expenseRepo *repository.ExpenseRepositoryImpl
} }
func (a *App) initRepositories() *repositories { func (a *App) initRepositories() *repositories {
@ -272,7 +236,6 @@ func (a *App) initRepositories() *repositories {
productRecipeRepo: repository.NewProductRecipeRepository(a.db), productRecipeRepo: repository.NewProductRecipeRepository(a.db),
vendorRepo: repository.NewVendorRepositoryImpl(a.db), vendorRepo: repository.NewVendorRepositoryImpl(a.db),
purchaseOrderRepo: repository.NewPurchaseOrderRepositoryImpl(a.db), purchaseOrderRepo: repository.NewPurchaseOrderRepositoryImpl(a.db),
purchaseCategoryRepo: repository.NewPurchaseCategoryRepositoryImpl(a.db),
unitConverterRepo: repository.NewIngredientUnitConverterRepositoryImpl(a.db).(*repository.IngredientUnitConverterRepositoryImpl), unitConverterRepo: repository.NewIngredientUnitConverterRepositoryImpl(a.db).(*repository.IngredientUnitConverterRepositoryImpl),
chartOfAccountTypeRepo: repository.NewChartOfAccountTypeRepositoryImpl(a.db), chartOfAccountTypeRepo: repository.NewChartOfAccountTypeRepositoryImpl(a.db),
chartOfAccountRepo: repository.NewChartOfAccountRepositoryImpl(a.db), chartOfAccountRepo: repository.NewChartOfAccountRepositoryImpl(a.db),
@ -292,12 +255,6 @@ func (a *App) initRepositories() *repositories {
otpRepo: repository.NewOtpRepository(a.db), otpRepo: repository.NewOtpRepository(a.db),
sessionRepo: repository.NewSessionRepository(a.redisClient), sessionRepo: repository.NewSessionRepository(a.redisClient),
txManager: repository.NewTxManager(a.db), txManager: repository.NewTxManager(a.db),
userDeviceRepo: repository.NewUserDeviceRepositoryImpl(a.db),
notificationRepo: repository.NewNotificationRepository(a.db),
notificationReceiverRepo: repository.NewNotificationReceiverRepository(a.db),
notificationDeliveryRepo: repository.NewNotificationDeliveryRepository(a.db),
productOutletPriceRepo: repository.NewProductOutletPriceRepositoryImpl(a.db),
expenseRepo: repository.NewExpenseRepositoryImpl(a.db),
} }
} }
@ -321,7 +278,6 @@ type processors struct {
productRecipeProcessor *processor.ProductRecipeProcessorImpl productRecipeProcessor *processor.ProductRecipeProcessorImpl
vendorProcessor *processor.VendorProcessorImpl vendorProcessor *processor.VendorProcessorImpl
purchaseOrderProcessor *processor.PurchaseOrderProcessorImpl purchaseOrderProcessor *processor.PurchaseOrderProcessorImpl
purchaseCategoryProcessor *processor.PurchaseCategoryProcessorImpl
unitConverterProcessor *processor.IngredientUnitConverterProcessorImpl unitConverterProcessor *processor.IngredientUnitConverterProcessorImpl
chartOfAccountTypeProcessor *processor.ChartOfAccountTypeProcessorImpl chartOfAccountTypeProcessor *processor.ChartOfAccountTypeProcessorImpl
chartOfAccountProcessor *processor.ChartOfAccountProcessorImpl chartOfAccountProcessor *processor.ChartOfAccountProcessorImpl
@ -340,16 +296,14 @@ type processors struct {
customerPointsProcessor *processor.CustomerPointsProcessor customerPointsProcessor *processor.CustomerPointsProcessor
otpProcessor processor.OtpProcessor otpProcessor processor.OtpProcessor
fileClient processor.FileClient fileClient processor.FileClient
fcmClient client.FcmClient
inventoryMovementService service.InventoryMovementService inventoryMovementService service.InventoryMovementService
userDeviceProcessor *processor.UserDeviceProcessorImpl
notificationProcessor *processor.NotificationProcessorImpl
productOutletPriceProcessor processor.ProductOutletPriceProcessor
expenseProcessor *processor.ExpenseProcessorImpl
} }
func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processors { func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processors {
fileClient := client.NewFileClient(cfg.S3Config) fileClient := client.NewFileClient(cfg.S3Config)
fonnteClient := client.NewFonnteClient(cfg.GetFonnte()) fonnteClient := client.NewFonnteClient(cfg.GetFonnte())
fcmClient := client.NewFcmClient(cfg.GetFirebase())
otpProcessor := processor.NewOtpProcessor(fonnteClient, repos.otpRepo) otpProcessor := processor.NewOtpProcessor(fonnteClient, repos.otpRepo)
inventoryMovementService := service.NewInventoryMovementService(repos.inventoryMovementRepo, repos.ingredientRepo) inventoryMovementService := service.NewInventoryMovementService(repos.inventoryMovementRepo, repos.ingredientRepo)
@ -359,21 +313,20 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
outletProcessor: processor.NewOutletProcessorImpl(repos.outletRepo), outletProcessor: processor.NewOutletProcessorImpl(repos.outletRepo),
outletSettingProcessor: processor.NewOutletSettingProcessorImpl(repos.outletSettingRepo, repos.outletRepo), outletSettingProcessor: processor.NewOutletSettingProcessorImpl(repos.outletSettingRepo, repos.outletRepo),
categoryProcessor: processor.NewCategoryProcessorImpl(repos.categoryRepo), categoryProcessor: processor.NewCategoryProcessorImpl(repos.categoryRepo),
productProcessor: processor.NewProductProcessorImpl(repos.productRepo, repos.categoryRepo, repos.productVariantRepo, repos.inventoryRepo, repos.outletRepo, repos.productOutletPriceRepo), productProcessor: processor.NewProductProcessorImpl(repos.productRepo, repos.categoryRepo, repos.productVariantRepo, repos.inventoryRepo, repos.outletRepo),
productVariantProcessor: processor.NewProductVariantProcessorImpl(repos.productVariantRepo, repos.productRepo), productVariantProcessor: processor.NewProductVariantProcessorImpl(repos.productVariantRepo, repos.productRepo),
inventoryProcessor: processor.NewInventoryProcessorImpl(repos.inventoryRepo, repos.productRepo, repos.outletRepo, repos.ingredientRepo, repos.inventoryMovementRepo), inventoryProcessor: processor.NewInventoryProcessorImpl(repos.inventoryRepo, repos.productRepo, repos.outletRepo, repos.ingredientRepo, repos.inventoryMovementRepo),
orderProcessor: processor.NewOrderProcessorImpl(repos.orderRepo, repos.orderItemRepo, repos.paymentRepo, repos.paymentOrderItemRepo, repos.productRepo, repos.paymentMethodRepo, repos.inventoryRepo, repos.inventoryMovementRepo, repos.productVariantRepo, repos.outletRepo, repos.customerRepo, repos.txManager, repos.productRecipeRepo, repos.ingredientRepo, inventoryMovementService, repos.productOutletPriceRepo), orderProcessor: processor.NewOrderProcessorImpl(repos.orderRepo, repos.orderItemRepo, repos.paymentRepo, repos.paymentOrderItemRepo, repos.productRepo, repos.paymentMethodRepo, repos.inventoryRepo, repos.inventoryMovementRepo, repos.productVariantRepo, repos.outletRepo, repos.customerRepo, repos.txManager, repos.productRecipeRepo, repos.ingredientRepo, inventoryMovementService),
paymentMethodProcessor: processor.NewPaymentMethodProcessorImpl(repos.paymentMethodRepo), paymentMethodProcessor: processor.NewPaymentMethodProcessorImpl(repos.paymentMethodRepo),
fileProcessor: processor.NewFileProcessorImpl(repos.fileRepo, fileClient), fileProcessor: processor.NewFileProcessorImpl(repos.fileRepo, fileClient),
customerProcessor: processor.NewCustomerProcessor(repos.customerRepo), customerProcessor: processor.NewCustomerProcessor(repos.customerRepo),
analyticsProcessor: processor.NewAnalyticsProcessorImpl(repos.analyticsRepo, repos.expenseRepo), analyticsProcessor: processor.NewAnalyticsProcessorImpl(repos.analyticsRepo),
tableProcessor: processor.NewTableProcessor(repos.tableRepo, repos.orderRepo), tableProcessor: processor.NewTableProcessor(repos.tableRepo, repos.orderRepo),
unitProcessor: processor.NewUnitProcessor(repos.unitRepo), unitProcessor: processor.NewUnitProcessor(repos.unitRepo),
ingredientProcessor: processor.NewIngredientProcessor(repos.ingredientRepo, repos.unitRepo, repos.ingredientCompositionRepo), ingredientProcessor: processor.NewIngredientProcessor(repos.ingredientRepo, repos.unitRepo, repos.ingredientCompositionRepo),
productRecipeProcessor: processor.NewProductRecipeProcessor(repos.productRecipeRepo, repos.productRepo, repos.ingredientRepo), productRecipeProcessor: processor.NewProductRecipeProcessor(repos.productRecipeRepo, repos.productRepo, repos.ingredientRepo),
vendorProcessor: processor.NewVendorProcessorImpl(repos.vendorRepo), vendorProcessor: processor.NewVendorProcessorImpl(repos.vendorRepo),
purchaseOrderProcessor: processor.NewPurchaseOrderProcessorImpl(repos.purchaseOrderRepo, repos.vendorRepo, repos.ingredientRepo, repos.purchaseCategoryRepo, repos.unitRepo, repos.fileRepo, inventoryMovementService, repos.unitConverterRepo), purchaseOrderProcessor: processor.NewPurchaseOrderProcessorImpl(repos.purchaseOrderRepo, repos.vendorRepo, repos.ingredientRepo, repos.unitRepo, repos.fileRepo, inventoryMovementService, repos.unitConverterRepo),
purchaseCategoryProcessor: processor.NewPurchaseCategoryProcessorImpl(repos.purchaseCategoryRepo),
unitConverterProcessor: processor.NewIngredientUnitConverterProcessorImpl(repos.unitConverterRepo, repos.ingredientRepo, repos.unitRepo), unitConverterProcessor: processor.NewIngredientUnitConverterProcessorImpl(repos.unitConverterRepo, repos.ingredientRepo, repos.unitRepo),
chartOfAccountTypeProcessor: processor.NewChartOfAccountTypeProcessorImpl(repos.chartOfAccountTypeRepo), chartOfAccountTypeProcessor: processor.NewChartOfAccountTypeProcessorImpl(repos.chartOfAccountTypeRepo),
chartOfAccountProcessor: processor.NewChartOfAccountProcessorImpl(repos.chartOfAccountRepo, repos.chartOfAccountTypeRepo), chartOfAccountProcessor: processor.NewChartOfAccountProcessorImpl(repos.chartOfAccountRepo, repos.chartOfAccountTypeRepo),
@ -392,11 +345,8 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
customerPointsProcessor: processor.NewCustomerPointsProcessor(repos.customerPointsRepo, repos.gameRepo), customerPointsProcessor: processor.NewCustomerPointsProcessor(repos.customerPointsRepo, repos.gameRepo),
otpProcessor: otpProcessor, otpProcessor: otpProcessor,
fileClient: fileClient, fileClient: fileClient,
fcmClient: fcmClient,
inventoryMovementService: inventoryMovementService, inventoryMovementService: inventoryMovementService,
userDeviceProcessor: processor.NewUserDeviceProcessorImpl(repos.userDeviceRepo),
notificationProcessor: buildNotificationProcessor(cfg, repos),
productOutletPriceProcessor: processor.NewProductOutletPriceProcessorImpl(repos.productOutletPriceRepo, repos.productRepo, repos.outletRepo),
expenseProcessor: processor.NewExpenseProcessorImpl(repos.expenseRepo, repos.purchaseCategoryRepo),
} }
} }
@ -422,7 +372,6 @@ type services struct {
productRecipeService *service.ProductRecipeServiceImpl productRecipeService *service.ProductRecipeServiceImpl
vendorService *service.VendorServiceImpl vendorService *service.VendorServiceImpl
purchaseOrderService *service.PurchaseOrderServiceImpl purchaseOrderService *service.PurchaseOrderServiceImpl
purchaseCategoryService service.PurchaseCategoryService
unitConverterService *service.IngredientUnitConverterServiceImpl unitConverterService *service.IngredientUnitConverterServiceImpl
chartOfAccountTypeService service.ChartOfAccountTypeService chartOfAccountTypeService service.ChartOfAccountTypeService
chartOfAccountService service.ChartOfAccountService chartOfAccountService service.ChartOfAccountService
@ -434,15 +383,11 @@ type services struct {
customerAuthService service.CustomerAuthService customerAuthService service.CustomerAuthService
customerPointsService service.CustomerPointsService customerPointsService service.CustomerPointsService
spinGameService service.SpinGameService spinGameService service.SpinGameService
userDeviceService service.UserDeviceService
notificationService service.NotificationService
productOutletPriceService service.ProductOutletPriceService
expenseService *service.ExpenseServiceImpl
} }
func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services { func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services {
authConfig := cfg.Auth() authConfig := cfg.Auth()
authService := service.NewAuthService(processors.userProcessor, processors.userDeviceProcessor, authConfig) authService := service.NewAuthService(processors.userProcessor, authConfig)
organizationService := service.NewOrganizationService(processors.organizationProcessor) organizationService := service.NewOrganizationService(processors.organizationProcessor)
outletService := service.NewOutletService(processors.outletProcessor) outletService := service.NewOutletService(processors.outletProcessor)
outletSettingService := service.NewOutletSettingService(processors.outletSettingProcessor) outletSettingService := service.NewOutletSettingService(processors.outletSettingProcessor)
@ -450,7 +395,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, processors.notificationProcessor, repos.userRepo) // Will be updated after orderIngredientTransactionService is created orderService := service.NewOrderServiceImpl(processors.orderProcessor, repos.tableRepo, nil, processors.orderIngredientTransactionProcessor, *repos.productRecipeRepo, repos.txManager, repos.sessionRepo) // 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)
@ -462,7 +407,6 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
productRecipeService := service.NewProductRecipeService(processors.productRecipeProcessor) productRecipeService := service.NewProductRecipeService(processors.productRecipeProcessor)
vendorService := service.NewVendorService(processors.vendorProcessor) vendorService := service.NewVendorService(processors.vendorProcessor)
purchaseOrderService := service.NewPurchaseOrderService(processors.purchaseOrderProcessor) purchaseOrderService := service.NewPurchaseOrderService(processors.purchaseOrderProcessor)
purchaseCategoryService := service.NewPurchaseCategoryService(processors.purchaseCategoryProcessor)
unitConverterService := service.NewIngredientUnitConverterService(processors.unitConverterProcessor) unitConverterService := service.NewIngredientUnitConverterService(processors.unitConverterProcessor)
chartOfAccountTypeService := service.NewChartOfAccountTypeService(processors.chartOfAccountTypeProcessor) chartOfAccountTypeService := service.NewChartOfAccountTypeService(processors.chartOfAccountTypeProcessor)
chartOfAccountService := service.NewChartOfAccountService(processors.chartOfAccountProcessor) chartOfAccountService := service.NewChartOfAccountService(processors.chartOfAccountProcessor)
@ -474,11 +418,9 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
customerAuthService := service.NewCustomerAuthService(processors.customerAuthProcessor) customerAuthService := service.NewCustomerAuthService(processors.customerAuthProcessor)
customerPointsService := service.NewCustomerPointsService(processors.customerPointsProcessor) customerPointsService := service.NewCustomerPointsService(processors.customerPointsProcessor)
spinGameService := service.NewSpinGameService(processors.gamePlayProcessor, repos.txManager) spinGameService := service.NewSpinGameService(processors.gamePlayProcessor, repos.txManager)
userDeviceService := service.NewUserDeviceService(processors.userDeviceProcessor)
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, processors.notificationProcessor, repos.userRepo) orderService = service.NewOrderServiceImpl(processors.orderProcessor, repos.tableRepo, orderIngredientTransactionService, processors.orderIngredientTransactionProcessor, *repos.productRecipeRepo, repos.txManager, repos.sessionRepo)
return &services{ return &services{
userService: service.NewUserService(processors.userProcessor), userService: service.NewUserService(processors.userProcessor),
@ -502,7 +444,6 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
productRecipeService: productRecipeService, productRecipeService: productRecipeService,
vendorService: vendorService, vendorService: vendorService,
purchaseOrderService: purchaseOrderService, purchaseOrderService: purchaseOrderService,
purchaseCategoryService: purchaseCategoryService,
unitConverterService: unitConverterService, unitConverterService: unitConverterService,
chartOfAccountTypeService: chartOfAccountTypeService, chartOfAccountTypeService: chartOfAccountTypeService,
chartOfAccountService: chartOfAccountService, chartOfAccountService: chartOfAccountService,
@ -514,10 +455,6 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
customerAuthService: customerAuthService, customerAuthService: customerAuthService,
customerPointsService: customerPointsService, customerPointsService: customerPointsService,
spinGameService: spinGameService, spinGameService: spinGameService,
userDeviceService: userDeviceService,
notificationService: notificationService,
productOutletPriceService: service.NewProductOutletPriceService(processors.productOutletPriceProcessor),
expenseService: service.NewExpenseService(processors.expenseProcessor),
} }
} }
@ -548,7 +485,6 @@ type validators struct {
tableValidator *validator.TableValidator tableValidator *validator.TableValidator
vendorValidator *validator.VendorValidatorImpl vendorValidator *validator.VendorValidatorImpl
purchaseOrderValidator *validator.PurchaseOrderValidatorImpl purchaseOrderValidator *validator.PurchaseOrderValidatorImpl
purchaseCategoryValidator *validator.PurchaseCategoryValidatorImpl
unitConverterValidator *validator.IngredientUnitConverterValidatorImpl unitConverterValidator *validator.IngredientUnitConverterValidatorImpl
chartOfAccountTypeValidator *validator.ChartOfAccountTypeValidatorImpl chartOfAccountTypeValidator *validator.ChartOfAccountTypeValidatorImpl
chartOfAccountValidator *validator.ChartOfAccountValidatorImpl chartOfAccountValidator *validator.ChartOfAccountValidatorImpl
@ -558,10 +494,6 @@ type validators struct {
rewardValidator validator.RewardValidator rewardValidator validator.RewardValidator
campaignValidator validator.CampaignValidator campaignValidator validator.CampaignValidator
customerAuthValidator validator.CustomerAuthValidator customerAuthValidator validator.CustomerAuthValidator
userDeviceValidator *validator.UserDeviceValidatorImpl
notificationValidator *validator.NotificationValidatorImpl
productOutletPriceValidator *validator.ProductOutletPriceValidatorImpl
expenseValidator *validator.ExpenseValidatorImpl
} }
func (a *App) initValidators() *validators { func (a *App) initValidators() *validators {
@ -580,7 +512,6 @@ func (a *App) initValidators() *validators {
tableValidator: validator.NewTableValidator(), tableValidator: validator.NewTableValidator(),
vendorValidator: validator.NewVendorValidator(), vendorValidator: validator.NewVendorValidator(),
purchaseOrderValidator: validator.NewPurchaseOrderValidator(), purchaseOrderValidator: validator.NewPurchaseOrderValidator(),
purchaseCategoryValidator: validator.NewPurchaseCategoryValidator(),
unitConverterValidator: validator.NewIngredientUnitConverterValidator().(*validator.IngredientUnitConverterValidatorImpl), unitConverterValidator: validator.NewIngredientUnitConverterValidator().(*validator.IngredientUnitConverterValidatorImpl),
chartOfAccountTypeValidator: validator.NewChartOfAccountTypeValidator().(*validator.ChartOfAccountTypeValidatorImpl), chartOfAccountTypeValidator: validator.NewChartOfAccountTypeValidator().(*validator.ChartOfAccountTypeValidatorImpl),
chartOfAccountValidator: validator.NewChartOfAccountValidator().(*validator.ChartOfAccountValidatorImpl), chartOfAccountValidator: validator.NewChartOfAccountValidator().(*validator.ChartOfAccountValidatorImpl),
@ -590,32 +521,5 @@ func (a *App) initValidators() *validators {
rewardValidator: validator.NewRewardValidator(), rewardValidator: validator.NewRewardValidator(),
campaignValidator: validator.NewCampaignValidator(), campaignValidator: validator.NewCampaignValidator(),
customerAuthValidator: validator.NewCustomerAuthValidator(), customerAuthValidator: validator.NewCustomerAuthValidator(),
userDeviceValidator: validator.NewUserDeviceValidator(),
notificationValidator: validator.NewNotificationValidator(),
productOutletPriceValidator: validator.NewProductOutletPriceValidator(),
expenseValidator: validator.NewExpenseValidator(),
} }
} }
// buildNotificationProcessor creates the notification processor with FCM integration.
// If FCM is not configured, it returns a processor with a nil FCM client (FCM dispatch will be skipped).
func buildNotificationProcessor(cfg *config.Config, repos *repositories) *processor.NotificationProcessorImpl {
var fcmClient client.FCMClient
if cfg.FCM.CredentialsFile != "" {
var err error
fcmClient, err = client.NewFCMClient(&cfg.FCM)
if err != nil {
// FCM init failure is non-fatal; notifications will still be persisted.
fcmClient = nil
}
}
return processor.NewNotificationProcessor(
repos.notificationRepo,
repos.notificationReceiverRepo,
repos.notificationDeliveryRepo,
repos.userDeviceRepo,
repos.userRepo,
fcmClient,
)
}

View File

@ -3,140 +3,81 @@ package client
import ( import (
"context" "context"
"fmt" "fmt"
"log"
"apskel-pos-be/config"
firebase "firebase.google.com/go/v4" firebase "firebase.google.com/go/v4"
"firebase.google.com/go/v4/messaging" "firebase.google.com/go/v4/messaging"
"google.golang.org/api/option" "google.golang.org/api/option"
) )
type FCMConfig interface { type FcmClient interface {
GetCredentialsFile() string SendMulticastNotification(ctx context.Context, tokens []string, title string, body string) error
GetProjectID() string
}
type FCMClient interface {
SendNotification(ctx context.Context, token string, title string, body string, data map[string]string) error
SendMulticastNotification(ctx context.Context, tokens []string, title string, body string, data map[string]string) error
SendToTopic(ctx context.Context, topic string, title string, body string, data map[string]string) error
} }
type fcmClient struct { type fcmClient struct {
messaging *messaging.Client messagingClient *messaging.Client
} }
func NewFCMClient(cfg FCMConfig) (FCMClient, error) { func NewFcmClient(cfg *config.Firebase) FcmClient {
ctx := context.Background() if cfg == nil || cfg.GetCredentialsFile() == "" {
log.Println("FCM: credentials file not configured, FCM client is disabled")
return &fcmClient{messagingClient: nil}
}
opt := option.WithCredentialsFile(cfg.GetCredentialsFile()) opt := option.WithCredentialsFile(cfg.GetCredentialsFile())
app, err := firebase.NewApp(context.Background(), nil, opt)
app, err := firebase.NewApp(ctx, &firebase.Config{
ProjectID: cfg.GetProjectID(),
}, opt)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to initialize firebase app: %w", err) log.Printf("FCM: failed to initialize Firebase app: %v", err)
return &fcmClient{messagingClient: nil}
} }
msgClient, err := app.Messaging(ctx) client, err := app.Messaging(context.Background())
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to initialize firebase messaging client: %w", err) log.Printf("FCM: failed to create messaging client: %v", err)
return &fcmClient{messagingClient: nil}
} }
return &fcmClient{ log.Println("FCM: client initialized successfully")
messaging: msgClient, return &fcmClient{messagingClient: client}
}, nil
} }
// SendNotification sends a push notification to a single device token. func (c *fcmClient) SendMulticastNotification(ctx context.Context, tokens []string, title string, body string) error {
func (f *fcmClient) SendNotification(ctx context.Context, token string, title string, body string, data map[string]string) error { if c.messagingClient == nil {
message := &messaging.Message{ log.Println("FCM: client not initialized, skipping notification")
Token: token,
Notification: &messaging.Notification{
Title: title,
Body: body,
},
Data: data,
Android: &messaging.AndroidConfig{
Priority: "high",
},
APNS: &messaging.APNSConfig{
Payload: &messaging.APNSPayload{
Aps: &messaging.Aps{
Sound: "default",
},
},
},
}
_, err := f.messaging.Send(ctx, message)
if err != nil {
return fmt.Errorf("failed to send FCM notification: %w", err)
}
return nil return nil
} }
// SendMulticastNotification sends a push notification to multiple device tokens.
func (f *fcmClient) SendMulticastNotification(ctx context.Context, tokens []string, title string, body string, data map[string]string) error {
if len(tokens) == 0 { if len(tokens) == 0 {
return nil return nil
} }
message := &messaging.MulticastMessage{ message := &messaging.MulticastMessage{
Notification: &messaging.Notification{
Title: title,
Body: body,
},
Tokens: tokens, Tokens: tokens,
Notification: &messaging.Notification{
Title: title,
Body: body,
},
Data: data,
Android: &messaging.AndroidConfig{ Android: &messaging.AndroidConfig{
Priority: "high", Priority: "high",
}, },
APNS: &messaging.APNSConfig{
Payload: &messaging.APNSPayload{
Aps: &messaging.Aps{
Sound: "default",
},
},
},
} }
batchResp, err := f.messaging.SendEachForMulticast(ctx, message) response, err := c.messagingClient.SendMulticast(ctx, message)
if err != nil { if err != nil {
return fmt.Errorf("failed to send FCM multicast notification: %w", err) return fmt.Errorf("FCM: failed to send multicast notification: %w", err)
} }
if batchResp.FailureCount > 0 { if response.FailureCount > 0 {
return fmt.Errorf("FCM multicast: %d/%d messages failed to send", batchResp.FailureCount, len(tokens)) log.Printf("FCM: %d tokens failed out of %d", response.FailureCount, len(tokens))
} for i, resp := range response.Responses {
if !resp.Success {
return nil log.Printf("FCM: token[%d] failed: %v", i, resp.Error)
} }
}
// SendToTopic sends a push notification to all devices subscribed to a topic.
func (f *fcmClient) SendToTopic(ctx context.Context, topic string, title string, body string, data map[string]string) error {
message := &messaging.Message{
Topic: topic,
Notification: &messaging.Notification{
Title: title,
Body: body,
},
Data: data,
Android: &messaging.AndroidConfig{
Priority: "high",
},
APNS: &messaging.APNSConfig{
Payload: &messaging.APNSPayload{
Aps: &messaging.Aps{
Sound: "default",
},
},
},
}
_, err := f.messaging.Send(ctx, message)
if err != nil {
return fmt.Errorf("failed to send FCM topic notification: %w", err)
} }
log.Printf("FCM: sent %d/%d notifications successfully", response.SuccessCount, len(tokens))
return nil return nil
} }

View File

@ -40,7 +40,6 @@ const (
OutletServiceEntity = "outlet_service" OutletServiceEntity = "outlet_service"
VendorServiceEntity = "vendor_service" VendorServiceEntity = "vendor_service"
PurchaseOrderServiceEntity = "purchase_order_service" PurchaseOrderServiceEntity = "purchase_order_service"
PurchaseCategoryServiceEntity = "purchase_category_service"
IngredientUnitConverterServiceEntity = "ingredient_unit_converter_service" IngredientUnitConverterServiceEntity = "ingredient_unit_converter_service"
IngredientCompositionServiceEntity = "ingredient_composition_service" IngredientCompositionServiceEntity = "ingredient_composition_service"
TableEntity = "table" TableEntity = "table"
@ -57,11 +56,6 @@ const (
CampaignRuleEntity = "campaign_rule" CampaignRuleEntity = "campaign_rule"
CustomerEntity = "customer" CustomerEntity = "customer"
SpinGameHandlerEntity = "spin_game_handler" SpinGameHandlerEntity = "spin_game_handler"
UserDeviceServiceEntity = "user_device_service"
NotificationServiceEntity = "notification_service"
NotificationHandlerEntity = "notification_handler"
ProductOutletPriceServiceEntity = "product_outlet_price_service"
ExpenseServiceEntity = "expense_service"
) )
var HttpErrorMap = map[string]int{ var HttpErrorMap = map[string]int{

View File

@ -1,28 +0,0 @@
package constants
type ExpenseStatus string
const (
ExpenseStatusDraft ExpenseStatus = "draft"
ExpenseStatusSent ExpenseStatus = "sent"
ExpenseStatusApproved ExpenseStatus = "approved"
ExpenseStatusCancel ExpenseStatus = "cancel"
)
func GetAllExpenseStatuses() []ExpenseStatus {
return []ExpenseStatus{
ExpenseStatusDraft,
ExpenseStatusSent,
ExpenseStatusApproved,
ExpenseStatusCancel,
}
}
func IsValidExpenseStatus(status ExpenseStatus) bool {
for _, validStatus := range GetAllExpenseStatuses() {
if status == validStatus {
return true
}
}
return false
}

View File

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

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 *string `form:"outlet_id,omitempty"` OutletID *uuid.UUID `form:"outlet_id,omitempty"`
DateFrom string `form:"date_from" validate:"required"` DateFrom string `form:"date_from" validate:"required"`
DateTo string `form:"date_to" validate:"required"` DateTo string `form:"date_to" validate:"required"`
GroupBy string `form:"group_by,default=day" validate:"omitempty,oneof=day hour week month"` GroupBy string `form:"group_by,default=day" validate:"omitempty,oneof=day hour week month"`
@ -18,7 +18,6 @@ type PaymentMethodAnalyticsRequest struct {
type PaymentMethodAnalyticsResponse struct { type PaymentMethodAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"` DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"` DateTo time.Time `json:"date_to"`
GroupBy string `json:"group_by"` GroupBy string `json:"group_by"`
@ -46,7 +45,7 @@ type PaymentMethodAnalyticsData struct {
type SalesAnalyticsRequest struct { type SalesAnalyticsRequest struct {
OrganizationID uuid.UUID OrganizationID uuid.UUID
OutletID *string `form:"outlet_id,omitempty"` OutletID *uuid.UUID `form:"outlet_id,omitempty"`
DateFrom string `form:"date_from" validate:"required"` DateFrom string `form:"date_from" validate:"required"`
DateTo string `form:"date_to" validate:"required"` DateTo string `form:"date_to" validate:"required"`
GroupBy string `form:"group_by,default=day" validate:"omitempty,oneof=day hour week month"` GroupBy string `form:"group_by,default=day" validate:"omitempty,oneof=day hour week month"`
@ -55,7 +54,6 @@ type SalesAnalyticsRequest struct {
type SalesAnalyticsResponse struct { type SalesAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"` DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"` DateTo time.Time `json:"date_to"`
GroupBy string `json:"group_by"` GroupBy string `json:"group_by"`
@ -85,75 +83,10 @@ type SalesAnalyticsData struct {
NetSales float64 `json:"net_sales"` NetSales float64 `json:"net_sales"`
} }
type PurchasingAnalyticsRequest struct {
OrganizationID uuid.UUID
OutletID *string `form:"outlet_id,omitempty"`
DateFrom string `form:"date_from" validate:"required"`
DateTo string `form:"date_to" validate:"required"`
GroupBy string `form:"group_by,default=day" validate:"omitempty,oneof=day hour week month"`
}
type PurchasingAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"`
GroupBy string `json:"group_by"`
Summary PurchasingSummary `json:"summary"`
Data []PurchasingAnalyticsData `json:"data"`
IngredientData []PurchasingIngredientData `json:"ingredient_data"`
VendorData []PurchasingVendorData `json:"vendor_data"`
}
type PurchasingSummary struct {
TotalPurchases float64 `json:"total_purchases"`
RawMaterialPurchases float64 `json:"raw_material_purchases"`
ExpensePurchases float64 `json:"expense_purchases"`
TotalPurchaseOrders int64 `json:"total_purchase_orders"`
RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"`
ExpenseCount int64 `json:"expense_count"`
TotalQuantity float64 `json:"total_quantity"`
AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"`
TotalIngredients int64 `json:"total_ingredients"`
TotalVendors int64 `json:"total_vendors"`
}
type PurchasingAnalyticsData struct {
Date time.Time `json:"date"`
Purchases float64 `json:"purchases"`
RawMaterialPurchases float64 `json:"raw_material_purchases"`
ExpensePurchases float64 `json:"expense_purchases"`
PurchaseOrders int64 `json:"purchase_orders"`
RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"`
ExpenseCount int64 `json:"expense_count"`
Quantity float64 `json:"quantity"`
Ingredients int64 `json:"ingredients"`
Vendors int64 `json:"vendors"`
}
type PurchasingIngredientData struct {
IngredientID uuid.UUID `json:"ingredient_id"`
IngredientName string `json:"ingredient_name"`
Quantity float64 `json:"quantity"`
TotalCost float64 `json:"total_cost"`
AverageUnitCost float64 `json:"average_unit_cost"`
PurchaseOrderCount int64 `json:"purchase_order_count"`
}
type PurchasingVendorData struct {
VendorID *uuid.UUID `json:"vendor_id"`
VendorName string `json:"vendor_name"`
TotalCost float64 `json:"total_cost"`
PurchaseOrderCount int64 `json:"purchase_order_count"`
IngredientCount int64 `json:"ingredient_count"`
Quantity float64 `json:"quantity"`
}
// ProductAnalyticsRequest represents the request for product analytics // ProductAnalyticsRequest represents the request for product analytics
type ProductAnalyticsRequest struct { type ProductAnalyticsRequest struct {
OrganizationID uuid.UUID OrganizationID uuid.UUID
OutletID *string `form:"outlet_id,omitempty"` OutletID *uuid.UUID `form:"outlet_id,omitempty"`
DateFrom string `form:"date_from" validate:"required"` DateFrom string `form:"date_from" validate:"required"`
DateTo string `form:"date_to" validate:"required"` DateTo string `form:"date_to" validate:"required"`
Limit int `form:"limit,default=1000" validate:"min=1,max=1000"` Limit int `form:"limit,default=1000" validate:"min=1,max=1000"`
@ -163,7 +96,6 @@ type ProductAnalyticsRequest struct {
type ProductAnalyticsResponse struct { type ProductAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"` DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"` DateTo time.Time `json:"date_to"`
Data []ProductAnalyticsData `json:"data"` Data []ProductAnalyticsData `json:"data"`
@ -173,7 +105,6 @@ type ProductAnalyticsData struct {
ProductID uuid.UUID `json:"product_id"` ProductID uuid.UUID `json:"product_id"`
ProductName string `json:"product_name"` ProductName string `json:"product_name"`
ProductSku string `json:"product_sku"` ProductSku string `json:"product_sku"`
ProductPrice float64 `json:"product_price"`
CategoryID uuid.UUID `json:"category_id"` CategoryID uuid.UUID `json:"category_id"`
CategoryName string `json:"category_name"` CategoryName string `json:"category_name"`
CategoryOrder int `json:"category_order"` CategoryOrder int `json:"category_order"`
@ -192,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 *string `form:"outlet_id,omitempty"` OutletID *uuid.UUID `form:"outlet_id,omitempty"`
DateFrom string `form:"date_from" validate:"required"` DateFrom string `form:"date_from" validate:"required"`
DateTo string `form:"date_to" validate:"required"` DateTo string `form:"date_to" validate:"required"`
} }
@ -201,7 +132,6 @@ type ProductAnalyticsPerCategoryRequest struct {
type ProductAnalyticsPerCategoryResponse struct { type ProductAnalyticsPerCategoryResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"` DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"` DateTo time.Time `json:"date_to"`
Data []ProductAnalyticsPerCategoryData `json:"data"` Data []ProductAnalyticsPerCategoryData `json:"data"`
@ -222,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 *string `form:"outlet_id,omitempty"` OutletID *uuid.UUID `form:"outlet_id,omitempty"`
DateFrom string `form:"date_from" validate:"required"` DateFrom string `form:"date_from" validate:"required"`
DateTo string `form:"date_to" validate:"required"` DateTo string `form:"date_to" validate:"required"`
} }
@ -231,7 +161,6 @@ type DashboardAnalyticsRequest struct {
type DashboardAnalyticsResponse struct { type DashboardAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"` DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"` DateTo time.Time `json:"date_to"`
Overview DashboardOverview `json:"overview"` Overview DashboardOverview `json:"overview"`
@ -248,52 +177,30 @@ type DashboardOverview struct {
TotalCustomers int64 `json:"total_customers"` TotalCustomers int64 `json:"total_customers"`
VoidedOrders int64 `json:"voided_orders"` VoidedOrders int64 `json:"voided_orders"`
RefundedOrders int64 `json:"refunded_orders"` RefundedOrders int64 `json:"refunded_orders"`
TotalItemSold int64 `json:"total_item_sold"`
TotalLowStock int64 `json:"total_low_stock"`
TotalProductActive int64 `json:"total_product_active"`
} }
// ProfitLossAnalyticsRequest represents the request for profit and loss analytics
type ProfitLossAnalyticsRequest struct { type ProfitLossAnalyticsRequest struct {
OrganizationID uuid.UUID OrganizationID uuid.UUID
OutletID *string `form:"outlet_id,omitempty"` OutletID *uuid.UUID `form:"outlet_id,omitempty"`
DateFrom string `form:"date_from" validate:"required"` DateFrom string `form:"date_from" validate:"required"`
DateTo string `form:"date_to" validate:"required"` DateTo string `form:"date_to" validate:"required"`
GroupBy string `form:"group_by,default=day" validate:"omitempty,oneof=day hour week month"` GroupBy string `form:"group_by,default=day" validate:"omitempty,oneof=day hour week month"`
} }
// ProfitLossAnalyticsResponse represents the response for profit and loss analytics
type ProfitLossAnalyticsResponse struct { type ProfitLossAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"` DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"` DateTo time.Time `json:"date_to"`
GroupBy string `json:"group_by"` GroupBy string `json:"group_by"`
Summary ProfitLossSummary `json:"summary"` Summary ProfitLossSummary `json:"summary"`
Data []ProfitLossData `json:"data"` Data []ProfitLossData `json:"data"`
ProductData []ProductProfitData `json:"product_data"` ProductData []ProductProfitData `json:"product_data"`
MainSummary []ProfitLossSummaryRow `json:"main_summary"`
Purchasing ProfitLossPurchasing `json:"purchasing"`
OperationalExpenses []OperationalExpenseItem `json:"operational_expenses"`
OperationalExpensesTotal float64 `json:"operational_expenses_total"`
}
type ProfitLossPurchasing struct {
TodayTotal float64 `json:"today_total"`
MtdTotal float64 `json:"mtd_total"`
TodayRawMaterial float64 `json:"today_raw_material"`
MtdRawMaterial float64 `json:"mtd_raw_material"`
TodayExpense float64 `json:"today_expense"`
MtdExpense float64 `json:"mtd_expense"`
Items []ProfitLossPurchasingItem `json:"items"`
}
type ProfitLossPurchasingItem struct {
Date time.Time `json:"date"`
Item string `json:"item"`
Quantity float64 `json:"quantity"`
Nominal float64 `json:"nominal"`
} }
// ProfitLossSummary represents the summary of profit and loss analytics
type ProfitLossSummary struct { type ProfitLossSummary struct {
TotalRevenue float64 `json:"total_revenue"` TotalRevenue float64 `json:"total_revenue"`
TotalCost float64 `json:"total_cost"` TotalCost float64 `json:"total_cost"`
@ -308,6 +215,7 @@ type ProfitLossSummary struct {
ProfitabilityRatio float64 `json:"profitability_ratio"` ProfitabilityRatio float64 `json:"profitability_ratio"`
} }
// ProfitLossData represents individual profit and loss data point by time period
type ProfitLossData struct { type ProfitLossData struct {
Date time.Time `json:"date"` Date time.Time `json:"date"`
Revenue float64 `json:"revenue"` Revenue float64 `json:"revenue"`
@ -321,6 +229,7 @@ type ProfitLossData struct {
Orders int64 `json:"orders"` Orders int64 `json:"orders"`
} }
// ProductProfitData represents profit data for individual products
type ProductProfitData struct { type ProductProfitData struct {
ProductID uuid.UUID `json:"product_id"` ProductID uuid.UUID `json:"product_id"`
ProductName string `json:"product_name"` ProductName string `json:"product_name"`
@ -335,139 +244,3 @@ type ProductProfitData struct {
AverageCost float64 `json:"average_cost"` AverageCost float64 `json:"average_cost"`
ProfitPerUnit float64 `json:"profit_per_unit"` ProfitPerUnit float64 `json:"profit_per_unit"`
} }
type ProfitLossSummaryRow struct {
ID string `json:"id"`
Label string `json:"label"`
IsBold bool `json:"is_bold"`
TodayNominal float64 `json:"today_nominal"`
TodayPct float64 `json:"today_pct"`
MtdNominal float64 `json:"mtd_nominal"`
MtdPct float64 `json:"mtd_pct"`
SubItems []ProfitLossSummaryRow `json:"sub_items,omitempty"`
}
type OperationalExpenseItem struct {
Item string `json:"item"`
Nominal float64 `json:"nominal"`
}
type ExclusiveSummaryPeriodRequest struct {
OrganizationID uuid.UUID
OutletID *string `form:"outlet_id,omitempty"`
DateFrom string `form:"date_from" validate:"required"`
DateTo string `form:"date_to" validate:"required"`
ExcludeGajiStaffFromReimburse bool `form:"exclude_gaji_staff_from_reimburse"`
}
type ExclusiveSummaryMonthlyRequest struct {
OrganizationID uuid.UUID
OutletID *string `form:"outlet_id,omitempty"`
Month string `form:"month" validate:"required"`
}
type ExclusiveSummaryMTDRequest struct {
OrganizationID uuid.UUID
OutletID *string `form:"outlet_id,omitempty"`
DateTo string `form:"date_to" validate:"required"`
ExcludeGajiStaffFromReimburse bool `form:"exclude_gaji_staff_from_reimburse"`
}
type ExclusiveSummaryPeriodResponse struct {
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
Period ExclusiveSummaryPeriodRange `json:"period"`
Summary ExclusiveSummaryPeriodSummary `json:"summary"`
Reimburse ExclusiveSummaryReimburse `json:"reimburse"`
HPPBreakdown []ExclusiveSummaryCategoryBreakdown `json:"hpp_breakdown"`
OperationalExpenseBreakdown []ExclusiveSummaryCategoryBreakdown `json:"operational_expense_breakdown"`
DailySummary []ExclusiveSummaryDailySummary `json:"daily_summary"`
DailyTransactions []ExclusiveSummaryDailyTransaction `json:"daily_transactions"`
}
type ExclusiveSummaryPeriodRange struct {
DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"`
}
type ExclusiveSummaryPeriodSummary struct {
Sales float64 `json:"sales"`
HPP float64 `json:"hpp"`
GrossProfit float64 `json:"gross_profit"`
SalaryTotal float64 `json:"salary_total"`
SalaryDW float64 `json:"salary_dw"`
SalaryStaff float64 `json:"salary_staff"`
SalaryOther float64 `json:"salary_other"`
OtherOperationalExpenses float64 `json:"other_operational_expenses"`
OperationalExpensesTotal float64 `json:"operational_expenses_total"`
TotalCost float64 `json:"total_cost"`
NetProfit float64 `json:"net_profit"`
}
type ExclusiveSummaryReimburse struct {
TotalCost float64 `json:"total_cost"`
ExcludedSalaryStaff float64 `json:"excluded_salary_staff"`
TotalReimburse float64 `json:"total_reimburse"`
}
type ExclusiveSummaryCategoryBreakdown struct {
CategoryCode string `json:"category_code"`
CategoryName string `json:"category_name"`
Amount float64 `json:"amount"`
Percentage float64 `json:"percentage"`
}
type ExclusiveSummaryDailySummary struct {
Date time.Time `json:"date"`
TransactionCount int64 `json:"transaction_count"`
TotalCost float64 `json:"total_cost"`
}
type ExclusiveSummaryDailyTransaction struct {
Date time.Time `json:"date"`
CategoryCode string `json:"category_code"`
CategoryName string `json:"category_name"`
Description string `json:"description"`
Amount float64 `json:"amount"`
Source string `json:"source"`
}
type ExclusiveSummaryMonthlyResponse struct {
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
Month string `json:"month"`
Summary ExclusiveSummaryMonthlySummary `json:"summary"`
Periods []ExclusiveSummaryMonthlyPeriod `json:"periods"`
BankBalance []ExclusiveSummaryBankBalance `json:"bank_balance"`
}
type ExclusiveSummaryMonthlySummary struct {
TotalSales float64 `json:"total_sales"`
HPP float64 `json:"hpp"`
GrossProfit float64 `json:"gross_profit"`
OperationalExpensesTotal float64 `json:"operational_expenses_total"`
TotalCost float64 `json:"total_cost"`
NetProfit float64 `json:"net_profit"`
NetProfitMargin float64 `json:"net_profit_margin"`
}
type ExclusiveSummaryMonthlyPeriod struct {
Label string `json:"label"`
DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"`
Sales float64 `json:"sales"`
HPP float64 `json:"hpp"`
GrossProfit float64 `json:"gross_profit"`
GrossMargin float64 `json:"gross_margin"`
}
type ExclusiveSummaryBankBalance struct {
Bank string `json:"bank"`
OpeningBalance *float64 `json:"opening_balance"`
IncomingMutation *float64 `json:"incoming_mutation"`
OutgoingMutation *float64 `json:"outgoing_mutation"`
ClosingBalance *float64 `json:"closing_balance"`
Notes *string `json:"notes"`
}

View File

@ -10,7 +10,6 @@ type CreateCategoryRequest struct {
Name string `json:"name" validate:"required,min=1,max=255"` Name string `json:"name" validate:"required,min=1,max=255"`
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`
BusinessType *string `json:"business_type,omitempty"` BusinessType *string `json:"business_type,omitempty"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
Order *int `json:"order,omitempty"` Order *int `json:"order,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"` Metadata map[string]interface{} `json:"metadata,omitempty"`
} }
@ -19,14 +18,12 @@ 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"`
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`
BusinessType *string `json:"business_type,omitempty"` BusinessType *string `json:"business_type,omitempty"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
Order *int `json:"order,omitempty"` Order *int `json:"order,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"` Metadata map[string]interface{} `json:"metadata,omitempty"`
} }
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"`
@ -37,7 +34,6 @@ 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"`
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"`

View File

@ -1,161 +0,0 @@
package contract
import (
"time"
"github.com/google/uuid"
)
type CreateExpenseRequest struct {
Receiver string `json:"receiver" validate:"required"`
TransactionDate string `json:"transaction_date" validate:"required"`
CodeNumber string `json:"code_number" validate:"required"`
OutletID string `json:"outlet_id" validate:"required"`
Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved cancel"`
Description *string `json:"description,omitempty"`
Tax float64 `json:"tax"`
Total float64 `json:"total" validate:"required"`
Items []CreateExpenseItemRequest `json:"items" validate:"required"`
}
type CreateExpenseItemRequest struct {
ChartOfAccountID string `json:"chart_of_account_id" validate:"required"`
PurchaseCategoryID string `json:"purchase_category_id" validate:"required"`
Item string `json:"item" validate:"required"`
Description *string `json:"description,omitempty"`
Amount float64 `json:"amount" validate:"required"`
}
type UpdateExpenseRequest struct {
Receiver *string `json:"receiver,omitempty"`
TransactionDate *string `json:"transaction_date,omitempty"`
CodeNumber *string `json:"code_number,omitempty"`
OutletID *string `json:"outlet_id,omitempty"`
Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved cancel"`
Description *string `json:"description,omitempty"`
Tax *float64 `json:"tax,omitempty"`
Total *float64 `json:"total,omitempty"`
Reserved1 *string `json:"reserved1,omitempty"`
Items []UpdateExpenseItemRequest `json:"items,omitempty"`
}
type UpdateExpenseItemRequest struct {
ChartOfAccountID *string `json:"chart_of_account_id,omitempty"`
PurchaseCategoryID *string `json:"purchase_category_id,omitempty"`
Item *string `json:"item,omitempty"`
Description *string `json:"description,omitempty"`
Amount *float64 `json:"amount,omitempty"`
}
type ExpenseResponse struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
OutletID uuid.UUID `json:"outlet_id"`
Receiver string `json:"receiver"`
TransactionDate time.Time `json:"transaction_date"`
CodeNumber string `json:"code_number"`
Status string `json:"status"`
Description *string `json:"description"`
Tax float64 `json:"tax"`
Total float64 `json:"total"`
Reserved1 *string `json:"reserved1,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Items []ExpenseItemResponse `json:"items,omitempty"`
}
type ExpenseItemResponse struct {
ID uuid.UUID `json:"id"`
ExpenseID uuid.UUID `json:"expense_id"`
ChartOfAccountID uuid.UUID `json:"chart_of_account_id"`
ChartOfAccountName string `json:"chart_of_account_name,omitempty"`
PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
PurchaseCategoryName string `json:"purchase_category_name,omitempty"`
PurchaseCategoryType string `json:"purchase_category_type,omitempty"`
PurchaseCategory *PurchaseCategoryResponse `json:"purchase_category,omitempty"`
Item string `json:"item"`
Description *string `json:"description"`
Amount float64 `json:"amount"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ListExpenseRequest struct {
Page int `json:"page" validate:"min=1"`
Limit int `json:"limit" validate:"min=1,max=100"`
Search string `json:"search,omitempty"`
OutletID string `json:"outlet_id,omitempty"`
Status string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved cancel"`
StartDate string `json:"start_date,omitempty"`
EndDate string `json:"end_date,omitempty"`
}
type ListExpenseResponse struct {
Expenses []ExpenseResponse `json:"expenses"`
TotalCount int `json:"total_count"`
Page int `json:"page"`
Limit int `json:"limit"`
TotalPages int `json:"total_pages"`
}
type ExpenseAnalyticsRequest struct {
OutletID *string `form:"outlet_id,omitempty"`
DateFrom string `form:"date_from" validate:"required"`
DateTo string `form:"date_to" validate:"required"`
GroupBy string `form:"group_by,default=day" validate:"omitempty,oneof=day hour week month"`
}
type ExpenseAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"`
GroupBy string `json:"group_by"`
Summary ExpenseAnalyticsSummary `json:"summary"`
Data []ExpenseAnalyticsData `json:"data"`
CategoryData []ExpenseAnalyticsCategoryData `json:"category_data"`
ChartOfAccountData []ExpenseAnalyticsChartOfAccountData `json:"chart_of_account_data"`
ItemData []ExpenseAnalyticsItemData `json:"item_data"`
}
type ExpenseAnalyticsSummary struct {
TotalExpenses float64 `json:"total_expenses"`
TotalExpenseCount int64 `json:"total_expense_count"`
TotalTax float64 `json:"total_tax"`
AverageExpenseValue float64 `json:"average_expense_value"`
TotalCategories int64 `json:"total_categories"`
TotalItems int64 `json:"total_items"`
}
type ExpenseAnalyticsData struct {
Date time.Time `json:"date"`
Expenses float64 `json:"expenses"`
ExpenseCount int64 `json:"expense_count"`
Tax float64 `json:"tax"`
Items int64 `json:"items"`
Categories int64 `json:"categories"`
}
type ExpenseAnalyticsCategoryData struct {
PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
PurchaseCategoryName string `json:"purchase_category_name"`
PurchaseCategoryType string `json:"purchase_category_type"`
TotalAmount float64 `json:"total_amount"`
ExpenseCount int64 `json:"expense_count"`
ItemCount int64 `json:"item_count"`
}
type ExpenseAnalyticsChartOfAccountData struct {
ChartOfAccountID uuid.UUID `json:"chart_of_account_id"`
ChartOfAccountName string `json:"chart_of_account_name"`
TotalAmount float64 `json:"total_amount"`
ExpenseCount int64 `json:"expense_count"`
ItemCount int64 `json:"item_count"`
}
type ExpenseAnalyticsItemData struct {
Item string `json:"item"`
TotalAmount float64 `json:"total_amount"`
ExpenseCount int64 `json:"expense_count"`
ItemCount int64 `json:"item_count"`
}

View File

@ -81,3 +81,4 @@ type IngredientUnitsResponse struct {
BaseUnitName string `json:"base_unit_name"` BaseUnitName string `json:"base_unit_name"`
Units []*UnitResponse `json:"units"` Units []*UnitResponse `json:"units"`
} }

View File

@ -1,92 +0,0 @@
package contract
import (
"time"
"apskel-pos-be/internal/entities"
"github.com/google/uuid"
)
// ---- Request contracts ----
type SendNotificationRequest struct {
Title string `json:"title" validate:"required,min=1,max=255"`
Body string `json:"body" validate:"required"`
Type string `json:"type,omitempty" validate:"omitempty,max=100"`
Category string `json:"category,omitempty" validate:"omitempty,max=100"`
Priority entities.NotificationPriority `json:"priority,omitempty" validate:"omitempty,oneof=low normal high"`
ImageURL string `json:"image_url,omitempty" validate:"omitempty,max=512"`
ActionURL string `json:"action_url,omitempty" validate:"omitempty,max=512"`
NotifiableType string `json:"notifiable_type,omitempty" validate:"omitempty,max=100"`
NotifiableID *uuid.UUID `json:"notifiable_id,omitempty"`
Data map[string]interface{} `json:"data,omitempty"`
ReceiverIDs []uuid.UUID `json:"receiver_ids" validate:"required,min=1"`
ScheduledAt *time.Time `json:"scheduled_at,omitempty"`
ExpiredAt *time.Time `json:"expired_at,omitempty"`
}
type BroadcastNotificationRequest struct {
Title string `json:"title" validate:"required,min=1,max=255"`
Body string `json:"body" validate:"required"`
Type string `json:"type,omitempty" validate:"omitempty,max=100"`
Category string `json:"category,omitempty" validate:"omitempty,max=100"`
Priority entities.NotificationPriority `json:"priority,omitempty" validate:"omitempty,oneof=low normal high"`
ImageURL string `json:"image_url,omitempty" validate:"omitempty,max=512"`
ActionURL string `json:"action_url,omitempty" validate:"omitempty,max=512"`
NotifiableType string `json:"notifiable_type,omitempty" validate:"omitempty,max=100"`
NotifiableID *uuid.UUID `json:"notifiable_id,omitempty"`
Data map[string]interface{} `json:"data,omitempty"`
ScheduledAt *time.Time `json:"scheduled_at,omitempty"`
ExpiredAt *time.Time `json:"expired_at,omitempty"`
}
type ListNotificationsRequest struct {
Page int `form:"page" validate:"min=1"`
Limit int `form:"limit" validate:"min=1,max=100"`
IsRead *bool `form:"is_read"`
}
// ---- Response contracts ----
type NotificationResponse struct {
ID uuid.UUID `json:"id"`
Title string `json:"title"`
Body string `json:"body"`
Type string `json:"type"`
Category string `json:"category"`
Priority entities.NotificationPriority `json:"priority"`
ImageURL string `json:"image_url"`
ActionURL string `json:"action_url"`
NotifiableType string `json:"notifiable_type"`
NotifiableID *uuid.UUID `json:"notifiable_id"`
Data map[string]interface{} `json:"data"`
ScheduledAt *time.Time `json:"scheduled_at"`
SentAt *time.Time `json:"sent_at"`
ExpiredAt *time.Time `json:"expired_at"`
CreatedBy *uuid.UUID `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type NotificationReceiverResponse struct {
ID uuid.UUID `json:"id"`
NotificationID uuid.UUID `json:"notification_id"`
UserID uuid.UUID `json:"user_id"`
IsRead bool `json:"is_read"`
ReadAt *time.Time `json:"read_at"`
IsDeleted bool `json:"is_deleted"`
DeletedAt *time.Time `json:"deleted_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Notification *NotificationResponse `json:"notification,omitempty"`
}
type ListNotificationsResponse struct {
Notifications []*NotificationReceiverResponse `json:"notifications"`
TotalCount int64 `json:"total_count"`
UnreadCount int64 `json:"unread_count"`
Page int `json:"page"`
Limit int `json:"limit"`
TotalPages int `json:"total_pages"`
}

View File

@ -98,8 +98,6 @@ type OrderItemResponse struct {
ProductName string `json:"product_name"` ProductName string `json:"product_name"`
ProductVariantID *uuid.UUID `json:"product_variant_id"` ProductVariantID *uuid.UUID `json:"product_variant_id"`
ProductVariantName *string `json:"product_variant_name,omitempty"` ProductVariantName *string `json:"product_variant_name,omitempty"`
CategoryID *uuid.UUID `json:"category_id,omitempty"`
CategoryName *string `json:"category_name,omitempty"`
Quantity int `json:"quantity"` Quantity int `json:"quantity"`
UnitPrice float64 `json:"unit_price"` UnitPrice float64 `json:"unit_price"`
TotalPrice float64 `json:"total_price"` TotalPrice float64 `json:"total_price"`
@ -110,7 +108,6 @@ type OrderItemResponse struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
PrinterType string `json:"printer_type"` PrinterType string `json:"printer_type"`
PrintToChecker bool `json:"print_to_checker"`
PaidQuantity int `json:"paid_quantity"` PaidQuantity int `json:"paid_quantity"`
} }

View File

@ -8,7 +8,6 @@ import (
type CreateProductRequest struct { type CreateProductRequest struct {
CategoryID uuid.UUID `json:"category_id" validate:"required"` CategoryID uuid.UUID `json:"category_id" validate:"required"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
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"`
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`
@ -17,17 +16,15 @@ type CreateProductRequest struct {
BusinessType *string `json:"business_type,omitempty"` BusinessType *string `json:"business_type,omitempty"`
ImageURL *string `json:"image_url,omitempty" validate:"omitempty,max=500"` ImageURL *string `json:"image_url,omitempty" validate:"omitempty,max=500"`
PrinterType *string `json:"printer_type,omitempty" validate:"omitempty,max=50"` PrinterType *string `json:"printer_type,omitempty" validate:"omitempty,max=50"`
PrintToChecker *bool `json:"print_to_checker,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"` Metadata map[string]interface{} `json:"metadata,omitempty"`
IsActive *bool `json:"is_active,omitempty"` IsActive *bool `json:"is_active,omitempty"`
Variants []CreateProductVariantRequest `json:"variants,omitempty"` Variants []CreateProductVariantRequest `json:"variants,omitempty"`
InitialStock *int `json:"initial_stock,omitempty" validate:"omitempty,min=0"` InitialStock *int `json:"initial_stock,omitempty" validate:"omitempty,min=0"` // Initial stock quantity for all outlets
ReorderLevel *int `json:"reorder_level,omitempty" validate:"omitempty,min=0"` ReorderLevel *int `json:"reorder_level,omitempty" validate:"omitempty,min=0"` // Reorder level for all outlets
CreateInventory bool `json:"create_inventory,omitempty"` CreateInventory bool `json:"create_inventory,omitempty"` // Whether to create inventory records for all outlets
} }
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"`
@ -37,10 +34,10 @@ type UpdateProductRequest struct {
BusinessType *string `json:"business_type,omitempty"` BusinessType *string `json:"business_type,omitempty"`
ImageURL *string `json:"image_url,omitempty" validate:"omitempty,max=500"` ImageURL *string `json:"image_url,omitempty" validate:"omitempty,max=500"`
PrinterType *string `json:"printer_type,omitempty" validate:"omitempty,max=50"` PrinterType *string `json:"printer_type,omitempty" validate:"omitempty,max=50"`
PrintToChecker *bool `json:"print_to_checker,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"` Metadata map[string]interface{} `json:"metadata,omitempty"`
IsActive *bool `json:"is_active,omitempty"` IsActive *bool `json:"is_active,omitempty"`
ReorderLevel *int `json:"reorder_level,omitempty" validate:"omitempty,min=0"` // Stock management fields
ReorderLevel *int `json:"reorder_level,omitempty" validate:"omitempty,min=0"` // Update reorder level for all existing inventory records
} }
type CreateProductVariantRequest struct { type CreateProductVariantRequest struct {
@ -67,13 +64,10 @@ type ProductResponse struct {
Name string `json:"name"` Name string `json:"name"`
Description *string `json:"description"` Description *string `json:"description"`
Price float64 `json:"price"` Price float64 `json:"price"`
OutletPrice *float64 `json:"outlet_price,omitempty"`
OutletPrices []ProductOutletPriceResponse `json:"outlet_prices,omitempty"`
Cost float64 `json:"cost"` Cost float64 `json:"cost"`
BusinessType string `json:"business_type"` BusinessType string `json:"business_type"`
ImageURL *string `json:"image_url"` ImageURL *string `json:"image_url"`
PrinterType string `json:"printer_type"` PrinterType string `json:"printer_type"`
PrintToChecker bool `json:"print_to_checker"`
Metadata map[string]interface{} `json:"metadata"` Metadata map[string]interface{} `json:"metadata"`
IsActive bool `json:"is_active"` IsActive bool `json:"is_active"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
@ -95,7 +89,6 @@ 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"`

View File

@ -1,46 +0,0 @@
package contract
import (
"time"
"github.com/google/uuid"
)
type CreateProductOutletPriceRequest struct {
ProductID uuid.UUID `json:"product_id" validate:"required"`
OutletID uuid.UUID `json:"outlet_id" validate:"required"`
Price float64 `json:"price" validate:"required,min=0"`
PrintToChecker bool `json:"print_to_checker"`
}
type UpdateProductOutletPriceRequest struct {
Price float64 `json:"price" validate:"required,min=0"`
PrintToChecker *bool `json:"print_to_checker"`
}
type ProductOutletPriceResponse struct {
ID uuid.UUID `json:"id,omitempty"`
ProductID uuid.UUID `json:"product_id,omitempty"`
OutletID uuid.UUID `json:"outlet_id"`
OutletName string `json:"outlet_name,omitempty"`
Price float64 `json:"price"`
PrintToChecker bool `json:"print_to_checker"`
CreatedAt time.Time `json:"created_at,omitempty"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
}
type ListProductOutletPricesResponse struct {
Prices []ProductOutletPriceResponse `json:"prices"`
TotalCount int `json:"total_count"`
}
type BulkCreateProductOutletPriceRequest struct {
ProductID uuid.UUID `json:"product_id" validate:"required"`
Prices []CreateProductOutletPricePerOutletRequest `json:"prices" validate:"required,dive"`
}
type CreateProductOutletPricePerOutletRequest struct {
OutletID uuid.UUID `json:"outlet_id" validate:"required"`
Price float64 `json:"price" validate:"required,min=0"`
PrintToChecker bool `json:"print_to_checker"`
}

View File

@ -1,57 +0,0 @@
package contract
import (
"time"
"github.com/google/uuid"
)
type CreatePurchaseCategoryRequest struct {
ParentID *uuid.UUID `json:"parent_id,omitempty"`
Code *string `json:"code,omitempty"`
Name string `json:"name" validate:"required,min=1,max=255"`
Type string `json:"type" validate:"required,oneof=raw_material expense"`
SortOrder *int `json:"sort_order,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
}
type UpdatePurchaseCategoryRequest struct {
ParentID *uuid.UUID `json:"parent_id,omitempty"`
Code *string `json:"code,omitempty"`
Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"`
Type *string `json:"type,omitempty" validate:"omitempty,oneof=raw_material expense"`
SortOrder *int `json:"sort_order,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
}
type ListPurchaseCategoriesRequest struct {
ParentID *uuid.UUID `json:"parent_id,omitempty"`
Type string `json:"type,omitempty" validate:"omitempty,oneof=raw_material expense"`
Search string `json:"search,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
Page int `json:"page" validate:"required,min=1"`
Limit int `json:"limit" validate:"required,min=1,max=100"`
}
type PurchaseCategoryResponse struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
PresetID *uuid.UUID `json:"preset_id"`
ParentID *uuid.UUID `json:"parent_id"`
Code string `json:"code"`
Name string `json:"name"`
Type string `json:"type"`
SortOrder int `json:"sort_order"`
IsSystem bool `json:"is_system"`
IsActive bool `json:"is_active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ListPurchaseCategoriesResponse struct {
PurchaseCategories []PurchaseCategoryResponse `json:"purchase_categories"`
TotalCount int `json:"total_count"`
Page int `json:"page"`
Limit int `json:"limit"`
TotalPages int `json:"total_pages"`
}

View File

@ -7,10 +7,10 @@ import (
) )
type CreatePurchaseOrderRequest struct { type CreatePurchaseOrderRequest struct {
VendorID *uuid.UUID `json:"vendor_id,omitempty" validate:"omitempty"` VendorID uuid.UUID `json:"vendor_id" validate:"required"`
PONumber string `json:"po_number" validate:"required,min=1,max=50"` PONumber string `json:"po_number" validate:"required,min=1,max=50"`
TransactionDate string `json:"transaction_date" validate:"required"` // Format: YYYY-MM-DD TransactionDate string `json:"transaction_date" validate:"required"` // Format: YYYY-MM-DD
DueDate *string `json:"due_date,omitempty" validate:"omitempty"` // Format: YYYY-MM-DD DueDate string `json:"due_date" validate:"required"` // Format: YYYY-MM-DD
Reference *string `json:"reference,omitempty" validate:"omitempty,max=100"` Reference *string `json:"reference,omitempty" validate:"omitempty,max=100"`
Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved received cancelled"` Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved received cancelled"`
Message *string `json:"message,omitempty" validate:"omitempty"` Message *string `json:"message,omitempty" validate:"omitempty"`
@ -19,11 +19,10 @@ type CreatePurchaseOrderRequest struct {
} }
type CreatePurchaseOrderItemRequest struct { type CreatePurchaseOrderItemRequest struct {
IngredientID *uuid.UUID `json:"ingredient_id,omitempty" validate:"omitempty"` IngredientID uuid.UUID `json:"ingredient_id" validate:"required"`
PurchaseCategoryID uuid.UUID `json:"purchase_category_id" validate:"required"`
Description *string `json:"description,omitempty" validate:"omitempty"` Description *string `json:"description,omitempty" validate:"omitempty"`
Quantity *float64 `json:"quantity,omitempty" validate:"omitempty,gt=0"` Quantity float64 `json:"quantity" validate:"required,gt=0"`
UnitID *uuid.UUID `json:"unit_id,omitempty" validate:"omitempty"` UnitID uuid.UUID `json:"unit_id" validate:"required"`
Amount float64 `json:"amount" validate:"required,gte=0"` Amount float64 `json:"amount" validate:"required,gte=0"`
} }
@ -42,7 +41,6 @@ type UpdatePurchaseOrderRequest struct {
type UpdatePurchaseOrderItemRequest struct { type UpdatePurchaseOrderItemRequest struct {
ID *uuid.UUID `json:"id,omitempty"` // For existing items ID *uuid.UUID `json:"id,omitempty"` // For existing items
IngredientID *uuid.UUID `json:"ingredient_id,omitempty" validate:"omitempty"` IngredientID *uuid.UUID `json:"ingredient_id,omitempty" validate:"omitempty"`
PurchaseCategoryID *uuid.UUID `json:"purchase_category_id,omitempty" validate:"omitempty"`
Description *string `json:"description,omitempty" validate:"omitempty"` Description *string `json:"description,omitempty" validate:"omitempty"`
Quantity *float64 `json:"quantity,omitempty" validate:"omitempty,gt=0"` Quantity *float64 `json:"quantity,omitempty" validate:"omitempty,gt=0"`
UnitID *uuid.UUID `json:"unit_id,omitempty" validate:"omitempty"` UnitID *uuid.UUID `json:"unit_id,omitempty" validate:"omitempty"`
@ -52,11 +50,10 @@ type UpdatePurchaseOrderItemRequest struct {
type PurchaseOrderResponse struct { type PurchaseOrderResponse 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"` VendorID uuid.UUID `json:"vendor_id"`
VendorID *uuid.UUID `json:"vendor_id"`
PONumber string `json:"po_number"` PONumber string `json:"po_number"`
TransactionDate time.Time `json:"transaction_date"` TransactionDate time.Time `json:"transaction_date"`
DueDate *time.Time `json:"due_date"` DueDate time.Time `json:"due_date"`
Reference *string `json:"reference"` Reference *string `json:"reference"`
Status string `json:"status"` Status string `json:"status"`
Message *string `json:"message"` Message *string `json:"message"`
@ -71,16 +68,14 @@ type PurchaseOrderResponse struct {
type PurchaseOrderItemResponse struct { type PurchaseOrderItemResponse struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
PurchaseOrderID uuid.UUID `json:"purchase_order_id"` PurchaseOrderID uuid.UUID `json:"purchase_order_id"`
IngredientID *uuid.UUID `json:"ingredient_id"` IngredientID uuid.UUID `json:"ingredient_id"`
PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
Description *string `json:"description"` Description *string `json:"description"`
Quantity *float64 `json:"quantity"` Quantity float64 `json:"quantity"`
UnitID *uuid.UUID `json:"unit_id"` UnitID uuid.UUID `json:"unit_id"`
Amount float64 `json:"amount"` Amount float64 `json:"amount"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
Ingredient *IngredientResponse `json:"ingredient,omitempty"` Ingredient *IngredientResponse `json:"ingredient,omitempty"`
PurchaseCategory *PurchaseCategoryResponse `json:"purchase_category,omitempty"`
Unit *UnitResponse `json:"unit,omitempty"` Unit *UnitResponse `json:"unit,omitempty"`
} }

View File

@ -1,6 +1,8 @@
package contract package contract
import ( import (
"time"
"github.com/google/uuid" "github.com/google/uuid"
) )
@ -49,8 +51,6 @@ 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:"organization_id" validate:"required"` OrganizationID string `form:"organisasi_id" validate:"required"`
OutletID string `form:"outlet_id" validate:"required"` OutletID string `form:"outlet_id" validate:"required"`
} }
@ -78,5 +78,35 @@ type SelfOrderListCategoriesResponse struct {
} }
type SelfOrderListOrdersResponse struct { type SelfOrderListOrdersResponse struct {
Orders []OrderResponse `json:"orders"` Orders []SelfOrderOrderItem `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"`
} }

View File

@ -12,14 +12,14 @@ type CreateUserRequest struct {
Name string `json:"name" validate:"required,min=1,max=255"` Name string `json:"name" validate:"required,min=1,max=255"`
Email string `json:"email" validate:"required,email"` Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required,min=6"` Password string `json:"password" validate:"required,min=6"`
Role string `json:"role" validate:"required,oneof=admin manager cashier waiter owner purchasing"` Role string `json:"role" validate:"required,oneof=admin manager cashier waiter"`
Permissions map[string]interface{} `json:"permissions,omitempty"` Permissions map[string]interface{} `json:"permissions,omitempty"`
} }
type UpdateUserRequest struct { type UpdateUserRequest struct {
Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"` Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"`
Email *string `json:"email,omitempty" validate:"omitempty,email"` Email *string `json:"email,omitempty" validate:"omitempty,email"`
Role *string `json:"role,omitempty" validate:"omitempty,oneof=admin manager cashier waiter owner purchasing"` Role *string `json:"role,omitempty" validate:"omitempty,oneof=admin manager cashier waiter"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
IsActive *bool `json:"is_active,omitempty"` IsActive *bool `json:"is_active,omitempty"`
Permissions *map[string]interface{} `json:"permissions,omitempty"` Permissions *map[string]interface{} `json:"permissions,omitempty"`
@ -37,13 +37,7 @@ type UpdateUserOutletRequest struct {
type LoginRequest struct { type LoginRequest struct {
Email string `json:"email" validate:"required,email"` Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required"` Password string `json:"password" validate:"required"`
DeviceID string `json:"device_id,omitempty"` FcmToken *string `json:"fcm_token,omitempty"`
DeviceName string `json:"device_name,omitempty"`
DeviceType string `json:"device_type,omitempty"`
Platform string `json:"platform,omitempty"`
FCMToken string `json:"fcm_token,omitempty"`
AppVersion string `json:"app_version,omitempty"`
OsVersion string `json:"os_version,omitempty"`
} }
type LoginResponse struct { type LoginResponse struct {

View File

@ -1,59 +0,0 @@
package contract
import (
"time"
"apskel-pos-be/internal/entities"
"github.com/google/uuid"
)
type RegisterUserDeviceRequest struct {
DeviceID string `json:"device_id" validate:"required,min=1,max=255"`
DeviceName string `json:"device_name,omitempty" validate:"omitempty,max=255"`
DeviceType entities.DeviceType `json:"device_type,omitempty" validate:"omitempty,oneof=mobile tablet desktop"`
Platform entities.DevicePlatform `json:"platform,omitempty" validate:"omitempty,oneof=android ios web"`
FCMToken string `json:"fcm_token,omitempty" validate:"omitempty,max=512"`
AppVersion string `json:"app_version,omitempty" validate:"omitempty,max=50"`
OsVersion string `json:"os_version,omitempty" validate:"omitempty,max=50"`
}
type UpdateUserDeviceRequest struct {
DeviceName string `json:"device_name,omitempty" validate:"omitempty,max=255"`
DeviceType entities.DeviceType `json:"device_type,omitempty" validate:"omitempty,oneof=mobile tablet desktop"`
Platform entities.DevicePlatform `json:"platform,omitempty" validate:"omitempty,oneof=android ios web"`
FCMToken string `json:"fcm_token,omitempty" validate:"omitempty,max=512"`
AppVersion string `json:"app_version,omitempty" validate:"omitempty,max=50"`
OsVersion string `json:"os_version,omitempty" validate:"omitempty,max=50"`
}
type UserDeviceResponse struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
DeviceID string `json:"device_id"`
DeviceName string `json:"device_name"`
DeviceType entities.DeviceType `json:"device_type"`
Platform entities.DevicePlatform `json:"platform"`
FCMToken string `json:"fcm_token"`
AppVersion string `json:"app_version"`
OsVersion string `json:"os_version"`
IPAddress string `json:"ip_address"`
LastActiveAt *time.Time `json:"last_active_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ListUserDevicesRequest struct {
Page int `json:"page" validate:"min=1"`
Limit int `json:"limit" validate:"min=1,max=100"`
UserID string `json:"user_id,omitempty"`
Platform string `json:"platform,omitempty"`
}
type ListUserDevicesResponse struct {
Devices []UserDeviceResponse `json:"devices"`
TotalCount int `json:"total_count"`
Page int `json:"page"`
Limit int `json:"limit"`
TotalPages int `json:"total_pages"`
}

View File

@ -27,64 +27,10 @@ type SalesAnalytics struct {
NetSales float64 `json:"net_sales"` NetSales float64 `json:"net_sales"`
} }
// PurchasingAnalytics represents purchasing analytics data
type PurchasingAnalytics struct {
OutletName *string `json:"outlet_name,omitempty"`
Summary PurchasingSummary `json:"summary"`
Data []PurchasingAnalyticsData `json:"data"`
IngredientData []PurchasingIngredientData `json:"ingredient_data"`
VendorData []PurchasingVendorData `json:"vendor_data"`
}
type PurchasingSummary struct {
TotalPurchases float64 `json:"total_purchases"`
RawMaterialPurchases float64 `json:"raw_material_purchases"`
ExpensePurchases float64 `json:"expense_purchases"`
TotalPurchaseOrders int64 `json:"total_purchase_orders"`
RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"`
ExpenseCount int64 `json:"expense_count"`
TotalQuantity float64 `json:"total_quantity"`
AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"`
TotalIngredients int64 `json:"total_ingredients"`
TotalVendors int64 `json:"total_vendors"`
}
type PurchasingAnalyticsData struct {
Date time.Time `json:"date"`
Purchases float64 `json:"purchases"`
RawMaterialPurchases float64 `json:"raw_material_purchases"`
ExpensePurchases float64 `json:"expense_purchases"`
PurchaseOrders int64 `json:"purchase_orders"`
RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"`
ExpenseCount int64 `json:"expense_count"`
Quantity float64 `json:"quantity"`
Ingredients int64 `json:"ingredients"`
Vendors int64 `json:"vendors"`
}
type PurchasingIngredientData struct {
IngredientID uuid.UUID `json:"ingredient_id"`
IngredientName string `json:"ingredient_name"`
Quantity float64 `json:"quantity"`
TotalCost float64 `json:"total_cost"`
AverageUnitCost float64 `json:"average_unit_cost"`
PurchaseOrderCount int64 `json:"purchase_order_count"`
}
type PurchasingVendorData struct {
VendorID *uuid.UUID `json:"vendor_id"`
VendorName string `json:"vendor_name"`
TotalCost float64 `json:"total_cost"`
PurchaseOrderCount int64 `json:"purchase_order_count"`
IngredientCount int64 `json:"ingredient_count"`
Quantity float64 `json:"quantity"`
}
type ProductAnalytics struct { type ProductAnalytics struct {
ProductID uuid.UUID `json:"product_id"` ProductID uuid.UUID `json:"product_id"`
ProductName string `json:"product_name"` ProductName string `json:"product_name"`
ProductSku string `json:"product_sku"` ProductSku string `json:"product_sku"`
ProductPrice float64 `json:"product_price"`
CategoryID uuid.UUID `json:"category_id"` CategoryID uuid.UUID `json:"category_id"`
CategoryName string `json:"category_name"` CategoryName string `json:"category_name"`
CategoryOrder int `json:"category_order"` CategoryOrder int `json:"category_order"`
@ -120,125 +66,56 @@ type DashboardOverview struct {
TotalCustomers int64 `json:"total_customers"` TotalCustomers int64 `json:"total_customers"`
VoidedOrders int64 `json:"voided_orders"` VoidedOrders int64 `json:"voided_orders"`
RefundedOrders int64 `json:"refunded_orders"` RefundedOrders int64 `json:"refunded_orders"`
TotalItemSold int64 `json:"total_item_sold"`
TotalLowStock int64 `json:"total_low_stock"`
TotalProductActive int64 `json:"total_product_active"`
} }
// ProfitLossAnalytics represents profit and loss analytics data
type ProfitLossAnalytics struct { type ProfitLossAnalytics struct {
Summary ProfitLossSummary Summary ProfitLossSummary `json:"summary"`
Data []ProfitLossData Data []ProfitLossData `json:"data"`
ProductData []ProductProfitData ProductData []ProductProfitData `json:"product_data"`
TodayRevenue float64
TodayCost float64
MtdRevenue float64
MtdCost float64
TodayPurchasing float64
MtdPurchasing float64
TodayPurchasingRawMaterial float64
MtdPurchasingRawMaterial float64
TodayPurchasingExpense float64
MtdPurchasingExpense float64
PurchasingItems []PurchasingItemDetail
TodayExpenseByCategory []ExpenseCategoryTotal
MtdExpenseByCategory []ExpenseCategoryTotal
OperationalExpenseItems []OperationalExpenseItem
}
type PurchasingItemDetail struct {
Date time.Time
Item string
Quantity float64
Amount float64
} }
// ProfitLossSummary represents profit and loss summary data
type ProfitLossSummary struct { type ProfitLossSummary struct {
TotalRevenue float64 TotalRevenue float64 `json:"total_revenue"`
TotalCost float64 TotalCost float64 `json:"total_cost"`
GrossProfit float64 GrossProfit float64 `json:"gross_profit"`
GrossProfitMargin float64 GrossProfitMargin float64 `json:"gross_profit_margin"`
TotalTax float64 TotalTax float64 `json:"total_tax"`
TotalDiscount float64 TotalDiscount float64 `json:"total_discount"`
NetProfit float64 NetProfit float64 `json:"net_profit"`
NetProfitMargin float64 NetProfitMargin float64 `json:"net_profit_margin"`
TotalOrders int64 TotalOrders int64 `json:"total_orders"`
AverageProfit float64 AverageProfit float64 `json:"average_profit"`
ProfitabilityRatio float64 ProfitabilityRatio float64 `json:"profitability_ratio"`
} }
// ProfitLossData represents profit and loss data by time period
type ProfitLossData struct { type ProfitLossData struct {
Date time.Time Date time.Time `json:"date"`
Revenue float64 Revenue float64 `json:"revenue"`
Cost float64 Cost float64 `json:"cost"`
GrossProfit float64 GrossProfit float64 `json:"gross_profit"`
GrossProfitMargin float64 GrossProfitMargin float64 `json:"gross_profit_margin"`
Tax float64 Tax float64 `json:"tax"`
Discount float64 Discount float64 `json:"discount"`
NetProfit float64 NetProfit float64 `json:"net_profit"`
NetProfitMargin float64 NetProfitMargin float64 `json:"net_profit_margin"`
Orders int64 Orders int64 `json:"orders"`
} }
// ProductProfitData represents profit data for individual products
type ProductProfitData struct { type ProductProfitData struct {
ProductID uuid.UUID ProductID uuid.UUID `json:"product_id"`
ProductName string ProductName string `json:"product_name"`
CategoryID uuid.UUID CategoryID uuid.UUID `json:"category_id"`
CategoryName string CategoryName string `json:"category_name"`
QuantitySold int64 QuantitySold int64 `json:"quantity_sold"`
Revenue float64 Revenue float64 `json:"revenue"`
Cost float64 Cost float64 `json:"cost"`
GrossProfit float64 GrossProfit float64 `json:"gross_profit"`
GrossProfitMargin float64 GrossProfitMargin float64 `json:"gross_profit_margin"`
AveragePrice float64 AveragePrice float64 `json:"average_price"`
AverageCost float64 AverageCost float64 `json:"average_cost"`
ProfitPerUnit float64 ProfitPerUnit float64 `json:"profit_per_unit"`
}
type ExpenseCategoryTotal struct {
CategoryName string
Amount float64
}
type OperationalExpenseItem struct {
Item string
Amount float64
}
type ExclusiveSummaryAnalytics struct {
SalesTotal float64
SalesCount int64
HPPBreakdown []ExclusiveSummaryCategoryTotal
OperationalExpenseBreakdown []ExclusiveSummaryCategoryTotal
DailySummary []ExclusiveSummaryDailySummary
DailyTransactions []ExclusiveSummaryDailyTransaction
}
type ExclusiveSummaryCategoryTotal struct {
CategoryCode string
CategoryName string
Amount float64
}
type ExclusiveSummaryDailySummary struct {
Date time.Time
TransactionCount int64
TotalCost float64
}
type ExclusiveSummaryDailyTransaction struct {
Date time.Time
CategoryCode string
CategoryName string
Description string
Amount float64
Source string
}
type ExclusiveSummaryBankBalance struct {
Bank string
OpeningBalance *float64
IncomingMutation *float64
OutgoingMutation *float64
ClosingBalance *float64
Notes *string
} }

View File

@ -33,7 +33,6 @@ 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"`
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"`

View File

@ -36,13 +36,6 @@ func GetAllEntities() []interface{} {
&CampaignRule{}, &CampaignRule{},
&OtpSession{}, &OtpSession{},
// Analytics entities are not database tables, they are query results // Analytics entities are not database tables, they are query results
&UserDevice{},
// Notification entities
&Notification{},
&NotificationReceiver{},
&NotificationDelivery{},
&ProductOutletPrice{},
&Expense{},
} }
} }

View File

@ -1,90 +0,0 @@
package entities
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type Expense struct {
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"`
OutletID uuid.UUID `gorm:"type:uuid;not null;index" json:"outlet_id"`
Receiver string `gorm:"not null;size:255" json:"receiver"`
TransactionDate time.Time `gorm:"type:date;not null" json:"transaction_date"`
CodeNumber string `gorm:"not null;size:50" json:"code_number"`
Status string `gorm:"not null;size:20;default:'draft'" json:"status"`
Description *string `gorm:"type:text" json:"description"`
Tax float64 `gorm:"type:decimal(15,2);not null;default:0" json:"tax"`
Total float64 `gorm:"type:decimal(15,2);not null;default:0" json:"total"`
Reserved1 *string `gorm:"type:text" json:"reserved1"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
Organization *Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"`
Outlet *Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"`
Items []ExpenseItem `gorm:"foreignKey:ExpenseID" json:"items,omitempty"`
}
type ExpenseAnalytics struct {
Summary ExpenseAnalyticsSummary
Data []ExpenseAnalyticsData
CategoryData []ExpenseAnalyticsCategoryData
ChartOfAccountData []ExpenseAnalyticsChartOfAccountData
ItemData []ExpenseAnalyticsItemData
}
type ExpenseAnalyticsSummary struct {
TotalExpenses float64
TotalExpenseCount int64
TotalTax float64
AverageExpenseValue float64
TotalCategories int64
TotalItems int64
}
type ExpenseAnalyticsData struct {
Date time.Time
Expenses float64
ExpenseCount int64
Tax float64
Items int64
Categories int64
}
type ExpenseAnalyticsCategoryData struct {
PurchaseCategoryID uuid.UUID
PurchaseCategoryName string
PurchaseCategoryType string
TotalAmount float64
ExpenseCount int64
ItemCount int64
}
type ExpenseAnalyticsChartOfAccountData struct {
ChartOfAccountID uuid.UUID
ChartOfAccountName string
TotalAmount float64
ExpenseCount int64
ItemCount int64
}
type ExpenseAnalyticsItemData struct {
Item string
TotalAmount float64
ExpenseCount int64
ItemCount int64
}
func (e *Expense) BeforeCreate(tx *gorm.DB) error {
if e.ID == uuid.Nil {
e.ID = uuid.New()
}
return nil
}
func (Expense) TableName() string {
return "expenses"
}

View File

@ -1,36 +0,0 @@
package entities
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type ExpenseItem struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
ExpenseID uuid.UUID `gorm:"type:uuid;not null;index" json:"expense_id"`
ChartOfAccountID uuid.UUID `gorm:"type:uuid;not null;index" json:"chart_of_account_id"`
PurchaseCategoryID uuid.UUID `gorm:"type:uuid;not null;index" json:"purchase_category_id"`
Item string `gorm:"not null;size:255" json:"item"`
Description *string `gorm:"type:text" json:"description"`
Amount float64 `gorm:"type:decimal(15,2);not null;default:0" json:"amount"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
Expense *Expense `gorm:"foreignKey:ExpenseID" json:"expense,omitempty"`
ChartOfAccount *ChartOfAccount `gorm:"foreignKey:ChartOfAccountID" json:"chart_of_account,omitempty"`
PurchaseCategory *PurchaseCategory `gorm:"foreignKey:PurchaseCategoryID" json:"purchase_category,omitempty"`
}
func (e *ExpenseItem) BeforeCreate(tx *gorm.DB) error {
if e.ID == uuid.Nil {
e.ID = uuid.New()
}
return nil
}
func (ExpenseItem) TableName() string {
return "expense_items"
}

View File

@ -39,3 +39,4 @@ func (iuc *IngredientUnitConverter) BeforeCreate() error {
} }
return nil return nil
} }

View File

@ -49,7 +49,6 @@ type InventoryMovement struct {
TotalCost float64 `gorm:"type:decimal(12,2);default:0.00" json:"total_cost"` TotalCost float64 `gorm:"type:decimal(12,2);default:0.00" json:"total_cost"`
ReferenceType *InventoryMovementReferenceType `gorm:"size:50" json:"reference_type"` ReferenceType *InventoryMovementReferenceType `gorm:"size:50" json:"reference_type"`
ReferenceID *uuid.UUID `gorm:"type:uuid;index" json:"reference_id"` ReferenceID *uuid.UUID `gorm:"type:uuid;index" json:"reference_id"`
PurchaseOrderItemID *uuid.UUID `gorm:"type:uuid;index" json:"purchase_order_item_id"`
OrderID *uuid.UUID `gorm:"type:uuid;index" json:"order_id"` OrderID *uuid.UUID `gorm:"type:uuid;index" json:"order_id"`
PaymentID *uuid.UUID `gorm:"type:uuid;index" json:"payment_id"` PaymentID *uuid.UUID `gorm:"type:uuid;index" json:"payment_id"`
UserID uuid.UUID `gorm:"type:uuid;not null;index" json:"user_id" validate:"required"` UserID uuid.UUID `gorm:"type:uuid;not null;index" json:"user_id" validate:"required"`
@ -62,7 +61,6 @@ type InventoryMovement struct {
Outlet Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"` Outlet Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"`
Product *Product `gorm:"foreignKey:ItemID" json:"product,omitempty"` Product *Product `gorm:"foreignKey:ItemID" json:"product,omitempty"`
Ingredient *Ingredient `gorm:"foreignKey:ItemID" json:"ingredient,omitempty"` Ingredient *Ingredient `gorm:"foreignKey:ItemID" json:"ingredient,omitempty"`
PurchaseOrderItem *PurchaseOrderItem `gorm:"foreignKey:PurchaseOrderItemID" json:"purchase_order_item,omitempty"`
Order *Order `gorm:"foreignKey:OrderID" json:"order,omitempty"` Order *Order `gorm:"foreignKey:OrderID" json:"order,omitempty"`
Payment *Payment `gorm:"foreignKey:PaymentID" json:"payment,omitempty"` Payment *Payment `gorm:"foreignKey:PaymentID" json:"payment,omitempty"`
User User `gorm:"foreignKey:UserID" json:"user,omitempty"` User User `gorm:"foreignKey:UserID" json:"user,omitempty"`

View File

@ -1,150 +0,0 @@
package entities
import (
"database/sql/driver"
"encoding/json"
"errors"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type NotificationPriority string
type NotificationDeliveryStatus string
type NotificationChannel string
type NotificationProvider string
const (
NotificationPriorityLow NotificationPriority = "low"
NotificationPriorityNormal NotificationPriority = "normal"
NotificationPriorityHigh NotificationPriority = "high"
NotificationDeliveryStatusPending NotificationDeliveryStatus = "pending"
NotificationDeliveryStatusSent NotificationDeliveryStatus = "sent"
NotificationDeliveryStatusDelivered NotificationDeliveryStatus = "delivered"
NotificationDeliveryStatusFailed NotificationDeliveryStatus = "failed"
NotificationChannelPush NotificationChannel = "push"
NotificationChannelWebsocket NotificationChannel = "websocket"
NotificationChannelEmail NotificationChannel = "email"
NotificationProviderFirebase NotificationProvider = "firebase"
)
// NotificationData is a JSON-serializable map for extra notification payload.
type NotificationData map[string]interface{}
func (d NotificationData) Value() (driver.Value, error) {
if d == nil {
return nil, nil
}
return json.Marshal(d)
}
func (d *NotificationData) Scan(value interface{}) error {
if value == nil {
*d = nil
return nil
}
bytes, ok := value.([]byte)
if !ok {
return errors.New("type assertion to []byte failed")
}
return json.Unmarshal(bytes, d)
}
// Notification is the master notification record.
type Notification struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
Title string `gorm:"not null;size:255" json:"title"`
Body string `gorm:"type:text" json:"body"`
Type string `gorm:"size:100" json:"type"`
Category string `gorm:"size:100" json:"category"`
Priority NotificationPriority `gorm:"size:50;default:'normal'" json:"priority"`
ImageURL string `gorm:"size:512" json:"image_url"`
ActionURL string `gorm:"size:512" json:"action_url"`
NotifiableType string `gorm:"size:100" json:"notifiable_type"`
NotifiableID *uuid.UUID `gorm:"type:uuid" json:"notifiable_id"`
Data NotificationData `gorm:"type:jsonb" json:"data"`
ScheduledAt *time.Time `gorm:"type:timestamptz" json:"scheduled_at"`
SentAt *time.Time `gorm:"type:timestamptz" json:"sent_at"`
ExpiredAt *time.Time `gorm:"type:timestamptz" json:"expired_at"`
CreatedBy *uuid.UUID `gorm:"type:uuid" json:"created_by"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
Creator *User `gorm:"foreignKey:CreatedBy" json:"creator,omitempty"`
Receivers []*NotificationReceiver `gorm:"foreignKey:NotificationID" json:"receivers,omitempty"`
}
func (n *Notification) BeforeCreate(tx *gorm.DB) error {
if n.ID == uuid.Nil {
n.ID = uuid.New()
}
return nil
}
func (Notification) TableName() string {
return "notifications"
}
// NotificationReceiver links a notification to a specific user.
type NotificationReceiver struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
NotificationID uuid.UUID `gorm:"type:uuid;not null;index" json:"notification_id"`
UserID uuid.UUID `gorm:"type:uuid;not null;index" json:"user_id"`
IsRead bool `gorm:"default:false" json:"is_read"`
ReadAt *time.Time `gorm:"type:timestamptz" json:"read_at"`
IsDeleted bool `gorm:"default:false" json:"is_deleted"`
DeletedAt *time.Time `gorm:"type:timestamptz" json:"deleted_at"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
Notification *Notification `gorm:"foreignKey:NotificationID" json:"notification,omitempty"`
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
Deliveries []*NotificationDelivery `gorm:"foreignKey:NotificationReceiverID" json:"deliveries,omitempty"`
}
func (n *NotificationReceiver) BeforeCreate(tx *gorm.DB) error {
if n.ID == uuid.Nil {
n.ID = uuid.New()
}
return nil
}
func (NotificationReceiver) TableName() string {
return "notification_receivers"
}
// NotificationDelivery tracks per-device delivery attempts.
type NotificationDelivery struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
NotificationReceiverID uuid.UUID `gorm:"type:uuid;not null;index" json:"notification_receiver_id"`
UserDeviceID uuid.UUID `gorm:"type:uuid;not null;index" json:"user_device_id"`
Channel NotificationChannel `gorm:"size:50;default:'push'" json:"channel"`
DeliveryStatus NotificationDeliveryStatus `gorm:"size:50;default:'pending'" json:"delivery_status"`
Provider NotificationProvider `gorm:"size:50" json:"provider"`
ProviderMessageID string `gorm:"size:255" json:"provider_message_id"`
SentAt *time.Time `gorm:"type:timestamptz" json:"sent_at"`
DeliveredAt *time.Time `gorm:"type:timestamptz" json:"delivered_at"`
FailedAt *time.Time `gorm:"type:timestamptz" json:"failed_at"`
FailureReason string `gorm:"type:text" json:"failure_reason"`
RetryCount int `gorm:"default:0" json:"retry_count"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
NotificationReceiver *NotificationReceiver `gorm:"foreignKey:NotificationReceiverID" json:"notification_receiver,omitempty"`
UserDevice *UserDevice `gorm:"foreignKey:UserDeviceID" json:"user_device,omitempty"`
}
func (n *NotificationDelivery) BeforeCreate(tx *gorm.DB) error {
if n.ID == uuid.Nil {
n.ID = uuid.New()
}
return nil
}
func (NotificationDelivery) TableName() string {
return "notification_deliveries"
}

View File

@ -33,7 +33,6 @@ type Product struct {
ProductRecipes []ProductRecipe `gorm:"foreignKey:ProductID" json:"product_recipes,omitempty"` ProductRecipes []ProductRecipe `gorm:"foreignKey:ProductID" json:"product_recipes,omitempty"`
Inventory []Inventory `gorm:"foreignKey:ProductID" json:"inventory,omitempty"` Inventory []Inventory `gorm:"foreignKey:ProductID" json:"inventory,omitempty"`
OrderItems []OrderItem `gorm:"foreignKey:ProductID" json:"order_items,omitempty"` OrderItems []OrderItem `gorm:"foreignKey:ProductID" json:"order_items,omitempty"`
ProductOutletPrices []ProductOutletPrice `gorm:"foreignKey:ProductID" json:"product_outlet_prices,omitempty"`
} }
func (p *Product) BeforeCreate(tx *gorm.DB) error { func (p *Product) BeforeCreate(tx *gorm.DB) error {

View File

@ -1,32 +0,0 @@
package entities
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type ProductOutletPrice struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
ProductID uuid.UUID `gorm:"type:uuid;not null;index" json:"product_id"`
OutletID uuid.UUID `gorm:"type:uuid;not null;index" json:"outlet_id"`
Price float64 `gorm:"type:decimal(10,2);not null" json:"price"`
PrintToChecker bool `gorm:"not null;default:true" json:"print_to_checker"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
Product Product `gorm:"foreignKey:ProductID" json:"product,omitempty"`
Outlet Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"`
}
func (p *ProductOutletPrice) BeforeCreate(tx *gorm.DB) error {
if p.ID == uuid.Nil {
p.ID = uuid.New()
}
return nil
}
func (ProductOutletPrice) TableName() string {
return "product_outlet_prices"
}

View File

@ -1,71 +0,0 @@
package entities
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type PurchaseCategoryType string
const (
PurchaseCategoryTypeRawMaterial PurchaseCategoryType = "raw_material"
PurchaseCategoryTypeExpense PurchaseCategoryType = "expense"
)
type PurchaseCategoryPreset struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
ParentID *uuid.UUID `gorm:"type:uuid;index" json:"parent_id"`
Code string `gorm:"not null;unique;size:100" json:"code"`
Name string `gorm:"not null;size:255" json:"name"`
Type PurchaseCategoryType `gorm:"not null;size:20" json:"type"`
SortOrder int `gorm:"not null;default:0" json:"sort_order"`
IsActive bool `gorm:"not null;default:true" json:"is_active"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
Parent *PurchaseCategoryPreset `gorm:"foreignKey:ParentID" json:"parent,omitempty"`
}
func (p *PurchaseCategoryPreset) BeforeCreate(tx *gorm.DB) error {
if p.ID == uuid.Nil {
p.ID = uuid.New()
}
return nil
}
func (PurchaseCategoryPreset) TableName() string {
return "purchase_category_presets"
}
type PurchaseCategory struct {
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"`
PresetID *uuid.UUID `gorm:"type:uuid;index" json:"preset_id"`
ParentID *uuid.UUID `gorm:"type:uuid;index" json:"parent_id"`
Code string `gorm:"not null;size:100" json:"code"`
Name string `gorm:"not null;size:255" json:"name"`
Type PurchaseCategoryType `gorm:"not null;size:20" json:"type"`
SortOrder int `gorm:"not null;default:0" json:"sort_order"`
IsSystem bool `gorm:"not null;default:false" json:"is_system"`
IsActive bool `gorm:"not null;default:true" json:"is_active"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
Organization *Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"`
Preset *PurchaseCategoryPreset `gorm:"foreignKey:PresetID" json:"preset,omitempty"`
Parent *PurchaseCategory `gorm:"foreignKey:ParentID" json:"parent,omitempty"`
Children []PurchaseCategory `gorm:"foreignKey:ParentID" json:"children,omitempty"`
}
func (c *PurchaseCategory) BeforeCreate(tx *gorm.DB) error {
if c.ID == uuid.Nil {
c.ID = uuid.New()
}
return nil
}
func (PurchaseCategory) TableName() string {
return "purchase_categories"
}

View File

@ -11,11 +11,10 @@ import (
type PurchaseOrder struct { type PurchaseOrder 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" json:"organization_id" validate:"required"` OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id" validate:"required"`
OutletID *uuid.UUID `gorm:"type:uuid;index" json:"outlet_id" validate:"omitempty"` VendorID uuid.UUID `gorm:"type:uuid;not null" json:"vendor_id" validate:"required"`
VendorID *uuid.UUID `gorm:"type:uuid" json:"vendor_id" validate:"omitempty"`
PONumber string `gorm:"not null;size:50" json:"po_number" validate:"required,min=1,max=50"` PONumber string `gorm:"not null;size:50" json:"po_number" validate:"required,min=1,max=50"`
TransactionDate time.Time `gorm:"type:date;not null" json:"transaction_date" validate:"required"` TransactionDate time.Time `gorm:"type:date;not null" json:"transaction_date" validate:"required"`
DueDate *time.Time `gorm:"type:date" json:"due_date" validate:"omitempty"` DueDate time.Time `gorm:"type:date;not null" json:"due_date" validate:"required"`
Reference *string `gorm:"size:100" json:"reference" validate:"omitempty,max=100"` Reference *string `gorm:"size:100" json:"reference" validate:"omitempty,max=100"`
Status string `gorm:"not null;size:20;default:'draft'" json:"status" validate:"required,oneof=draft sent approved received cancelled"` Status string `gorm:"not null;size:20;default:'draft'" json:"status" validate:"required,oneof=draft sent approved received cancelled"`
Message *string `gorm:"type:text" json:"message" validate:"omitempty"` Message *string `gorm:"type:text" json:"message" validate:"omitempty"`
@ -24,7 +23,6 @@ type PurchaseOrder 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"`
Vendor *Vendor `gorm:"foreignKey:VendorID" json:"vendor,omitempty"` Vendor *Vendor `gorm:"foreignKey:VendorID" json:"vendor,omitempty"`
Items []PurchaseOrderItem `gorm:"foreignKey:PurchaseOrderID" json:"items,omitempty"` Items []PurchaseOrderItem `gorm:"foreignKey:PurchaseOrderID" json:"items,omitempty"`
Attachments []PurchaseOrderAttachment `gorm:"foreignKey:PurchaseOrderID" json:"attachments,omitempty"` Attachments []PurchaseOrderAttachment `gorm:"foreignKey:PurchaseOrderID" json:"attachments,omitempty"`
@ -45,18 +43,16 @@ func (PurchaseOrder) TableName() string {
type PurchaseOrderItem struct { type PurchaseOrderItem 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"`
PurchaseOrderID uuid.UUID `gorm:"type:uuid;not null" json:"purchase_order_id" validate:"required"` PurchaseOrderID uuid.UUID `gorm:"type:uuid;not null" json:"purchase_order_id" validate:"required"`
IngredientID *uuid.UUID `gorm:"type:uuid" json:"ingredient_id" validate:"omitempty"` IngredientID uuid.UUID `gorm:"type:uuid;not null" json:"ingredient_id" validate:"required"`
PurchaseCategoryID uuid.UUID `gorm:"type:uuid;not null;index" json:"purchase_category_id" validate:"required"`
Description *string `gorm:"type:text" json:"description" validate:"omitempty"` Description *string `gorm:"type:text" json:"description" validate:"omitempty"`
Quantity *float64 `gorm:"type:decimal(10,3)" json:"quantity" validate:"omitempty,gt=0"` Quantity float64 `gorm:"type:decimal(10,3);not null" json:"quantity" validate:"required,gt=0"`
UnitID *uuid.UUID `gorm:"type:uuid" json:"unit_id" validate:"omitempty"` UnitID uuid.UUID `gorm:"type:uuid;not null" json:"unit_id" validate:"required"`
Amount float64 `gorm:"type:decimal(15,2);not null" json:"amount" validate:"required,gte=0"` Amount float64 `gorm:"type:decimal(15,2);not null" json:"amount" validate:"required,gte=0"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
PurchaseOrder *PurchaseOrder `gorm:"foreignKey:PurchaseOrderID" json:"purchase_order,omitempty"` PurchaseOrder *PurchaseOrder `gorm:"foreignKey:PurchaseOrderID" json:"purchase_order,omitempty"`
Ingredient *Ingredient `gorm:"foreignKey:IngredientID" json:"ingredient,omitempty"` Ingredient *Ingredient `gorm:"foreignKey:IngredientID" json:"ingredient,omitempty"`
PurchaseCategory *PurchaseCategory `gorm:"foreignKey:PurchaseCategoryID" json:"purchase_category,omitempty"`
Unit *Unit `gorm:"foreignKey:UnitID" json:"unit,omitempty"` Unit *Unit `gorm:"foreignKey:UnitID" json:"unit,omitempty"`
} }

View File

@ -17,8 +17,6 @@ const (
RoleManager UserRole = "manager" RoleManager UserRole = "manager"
RoleCashier UserRole = "cashier" RoleCashier UserRole = "cashier"
RoleWaiter UserRole = "waiter" RoleWaiter UserRole = "waiter"
RoleOwner UserRole = "owner"
RolePurchasing UserRole = "purchasing"
) )
type Permissions map[string]interface{} type Permissions map[string]interface{}
@ -48,9 +46,10 @@ type User struct {
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"`
Email string `gorm:"uniqueIndex;not null;size:255" json:"email" validate:"required,email"` Email string `gorm:"uniqueIndex;not null;size:255" json:"email" validate:"required,email"`
PasswordHash string `gorm:"not null;size:255" json:"-"` PasswordHash string `gorm:"not null;size:255" json:"-"`
Role UserRole `gorm:"not null;size:50" json:"role" validate:"required,oneof=admin manager cashier waiter owner purchasing"` Role UserRole `gorm:"not null;size:50" json:"role" validate:"required,oneof=admin manager cashier waiter"`
Permissions Permissions `gorm:"type:jsonb;default:'{}'" json:"permissions"` Permissions Permissions `gorm:"type:jsonb;default:'{}'" json:"permissions"`
IsActive bool `gorm:"default:true" json:"is_active"` IsActive bool `gorm:"default:true" json:"is_active"`
FcmToken *string `gorm:"size:512" json:"fcm_token,omitempty"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
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"`

View File

@ -1,50 +0,0 @@
package entities
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type DeviceType string
type DevicePlatform string
const (
DeviceTypeMobile DeviceType = "mobile"
DeviceTypeTablet DeviceType = "tablet"
DeviceTypeDesktop DeviceType = "desktop"
DevicePlatformAndroid DevicePlatform = "android"
DevicePlatformIOS DevicePlatform = "ios"
DevicePlatformWeb DevicePlatform = "web"
)
type UserDevice struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
UserID uuid.UUID `gorm:"type:uuid;not null;index" json:"user_id"`
DeviceID string `gorm:"not null;size:255;index" json:"device_id"`
DeviceName string `gorm:"size:255" json:"device_name"`
DeviceType DeviceType `gorm:"size:50" json:"device_type"`
Platform DevicePlatform `gorm:"size:50" json:"platform"`
FCMToken string `gorm:"size:512" json:"fcm_token"`
AppVersion string `gorm:"size:50" json:"app_version"`
OsVersion string `gorm:"size:50" json:"os_version"`
IPAddress string `gorm:"size:45" json:"ip_address"`
LastActiveAt *time.Time `gorm:"type:timestamptz" json:"last_active_at"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
}
func (u *UserDevice) BeforeCreate(tx *gorm.DB) error {
if u.ID == uuid.Nil {
u.ID = uuid.New()
}
return nil
}
func (UserDevice) TableName() string {
return "user_devices"
}

View File

@ -8,7 +8,6 @@ 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 {
@ -26,17 +25,6 @@ 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)
@ -48,7 +36,7 @@ func (h *AnalyticsHandler) GetPaymentMethodAnalytics(c *gin.Context) {
} }
req.OrganizationID = contextInfo.OrganizationID req.OrganizationID = contextInfo.OrganizationID
req.OutletID = h.resolveOutletID(c, contextInfo.OutletID) req.OutletID = &contextInfo.OutletID
modelReq := transformer.PaymentMethodAnalyticsContractToModel(&req) modelReq := transformer.PaymentMethodAnalyticsContractToModel(&req)
response, err := h.analyticsService.GetPaymentMethodAnalytics(ctx, modelReq) response, err := h.analyticsService.GetPaymentMethodAnalytics(ctx, modelReq)
@ -72,7 +60,7 @@ func (h *AnalyticsHandler) GetSalesAnalytics(c *gin.Context) {
} }
req.OrganizationID = contextInfo.OrganizationID req.OrganizationID = contextInfo.OrganizationID
req.OutletID = h.resolveOutletID(c, contextInfo.OutletID) req.OutletID = &contextInfo.OutletID
modelReq := transformer.SalesAnalyticsContractToModel(&req) modelReq := transformer.SalesAnalyticsContractToModel(&req)
response, err := h.analyticsService.GetSalesAnalytics(ctx, modelReq) response, err := h.analyticsService.GetSalesAnalytics(ctx, modelReq)
@ -85,30 +73,6 @@ func (h *AnalyticsHandler) GetSalesAnalytics(c *gin.Context) {
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetSalesAnalytics") util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetSalesAnalytics")
} }
func (h *AnalyticsHandler) GetPurchasingAnalytics(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
var req contract.PurchasingAnalyticsRequest
if err := c.ShouldBindQuery(&req); err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetPurchasingAnalytics", err.Error())}), "AnalyticsHandler::GetPurchasingAnalytics")
return
}
req.OrganizationID = contextInfo.OrganizationID
req.OutletID = h.resolveOutletID(c, contextInfo.OutletID)
modelReq := transformer.PurchasingAnalyticsContractToModel(&req)
response, err := h.analyticsService.GetPurchasingAnalytics(ctx, modelReq)
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "AnalyticsHandler::GetPurchasingAnalytics", err.Error())}), "AnalyticsHandler::GetPurchasingAnalytics")
return
}
contractResp := transformer.PurchasingAnalyticsModelToContract(response)
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetPurchasingAnalytics")
}
func (h *AnalyticsHandler) GetProductAnalytics(c *gin.Context) { func (h *AnalyticsHandler) GetProductAnalytics(c *gin.Context) {
ctx := c.Request.Context() ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx) contextInfo := appcontext.FromGinContext(ctx)
@ -120,7 +84,7 @@ func (h *AnalyticsHandler) GetProductAnalytics(c *gin.Context) {
} }
req.OrganizationID = contextInfo.OrganizationID req.OrganizationID = contextInfo.OrganizationID
req.OutletID = h.resolveOutletID(c, contextInfo.OutletID) req.OutletID = &contextInfo.OutletID
modelReq := transformer.ProductAnalyticsContractToModel(&req) modelReq := transformer.ProductAnalyticsContractToModel(&req)
response, err := h.analyticsService.GetProductAnalytics(ctx, modelReq) response, err := h.analyticsService.GetProductAnalytics(ctx, modelReq)
@ -144,7 +108,7 @@ func (h *AnalyticsHandler) GetProductAnalyticsPerCategory(c *gin.Context) {
} }
req.OrganizationID = contextInfo.OrganizationID req.OrganizationID = contextInfo.OrganizationID
req.OutletID = h.resolveOutletID(c, contextInfo.OutletID) req.OutletID = &contextInfo.OutletID
modelReq := transformer.ProductAnalyticsPerCategoryContractToModel(&req) modelReq := transformer.ProductAnalyticsPerCategoryContractToModel(&req)
response, err := h.analyticsService.GetProductAnalyticsPerCategory(ctx, modelReq) response, err := h.analyticsService.GetProductAnalyticsPerCategory(ctx, modelReq)
@ -168,7 +132,7 @@ func (h *AnalyticsHandler) GetDashboardAnalytics(c *gin.Context) {
} }
req.OrganizationID = contextInfo.OrganizationID req.OrganizationID = contextInfo.OrganizationID
req.OutletID = h.resolveOutletID(c, contextInfo.OutletID) req.OutletID = &contextInfo.OutletID
modelReq := transformer.DashboardAnalyticsContractToModel(&req) modelReq := transformer.DashboardAnalyticsContractToModel(&req)
response, err := h.analyticsService.GetDashboardAnalytics(ctx, modelReq) response, err := h.analyticsService.GetDashboardAnalytics(ctx, modelReq)
@ -192,7 +156,6 @@ 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")
@ -210,87 +173,3 @@ func (h *AnalyticsHandler) GetProfitLossAnalytics(c *gin.Context) {
contractResp := transformer.ProfitLossAnalyticsModelToContract(response) contractResp := transformer.ProfitLossAnalyticsModelToContract(response)
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetProfitLossAnalytics") util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetProfitLossAnalytics")
} }
func (h *AnalyticsHandler) GetExclusiveSummaryPeriod(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
var req contract.ExclusiveSummaryPeriodRequest
if err := c.ShouldBindQuery(&req); err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetExclusiveSummaryPeriod", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryPeriod")
return
}
req.OrganizationID = contextInfo.OrganizationID
req.OutletID = h.resolveOutletID(c, contextInfo.OutletID)
modelReq, err := transformer.ExclusiveSummaryPeriodContractToModel(&req)
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetExclusiveSummaryPeriod", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryPeriod")
return
}
response, err := h.analyticsService.GetExclusiveSummaryPeriod(ctx, modelReq)
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "AnalyticsHandler::GetExclusiveSummaryPeriod", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryPeriod")
return
}
contractResp := transformer.ExclusiveSummaryPeriodModelToContract(response)
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetExclusiveSummaryPeriod")
}
func (h *AnalyticsHandler) GetExclusiveSummaryMonthly(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
var req contract.ExclusiveSummaryMonthlyRequest
if err := c.ShouldBindQuery(&req); err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetExclusiveSummaryMonthly", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryMonthly")
return
}
req.OrganizationID = contextInfo.OrganizationID
req.OutletID = h.resolveOutletID(c, contextInfo.OutletID)
modelReq, err := transformer.ExclusiveSummaryMonthlyContractToModel(&req)
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetExclusiveSummaryMonthly", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryMonthly")
return
}
response, err := h.analyticsService.GetExclusiveSummaryMonthly(ctx, modelReq)
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "AnalyticsHandler::GetExclusiveSummaryMonthly", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryMonthly")
return
}
contractResp := transformer.ExclusiveSummaryMonthlyModelToContract(response)
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetExclusiveSummaryMonthly")
}
func (h *AnalyticsHandler) GetExclusiveSummaryMTD(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
var req contract.ExclusiveSummaryMTDRequest
if err := c.ShouldBindQuery(&req); err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetExclusiveSummaryMTD", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryMTD")
return
}
req.OrganizationID = contextInfo.OrganizationID
req.OutletID = h.resolveOutletID(c, contextInfo.OutletID)
modelReq, err := transformer.ExclusiveSummaryMTDContractToModel(&req)
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetExclusiveSummaryMTD", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryMTD")
return
}
response, err := h.analyticsService.GetExclusiveSummaryMTD(ctx, modelReq)
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "AnalyticsHandler::GetExclusiveSummaryMTD", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryMTD")
return
}
contractResp := transformer.ExclusiveSummaryPeriodModelToContract(response)
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetExclusiveSummaryMTD")
}

View File

@ -44,11 +44,6 @@ func (h *CategoryHandler) CreateCategory(c *gin.Context) {
return return
} }
// Inject outlet_id from context if user has one and request doesn't provide it
if req.OutletID == nil && contextInfo.OutletID != uuid.Nil {
req.OutletID = &contextInfo.OutletID
}
validationError, validationErrorCode := h.categoryValidator.ValidateCreateCategoryRequest(&req) validationError, validationErrorCode := h.categoryValidator.ValidateCreateCategoryRequest(&req)
if validationError != nil { if validationError != nil {
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
@ -154,11 +149,6 @@ func (h *CategoryHandler) ListCategories(c *gin.Context) {
OrganizationID: &contextInfo.OrganizationID, OrganizationID: &contextInfo.OrganizationID,
} }
// Inject outlet_id from context if user has one
if contextInfo.OutletID != uuid.Nil {
req.OutletID = &contextInfo.OutletID
}
// Parse query parameters // Parse query parameters
if pageStr := c.Query("page"); pageStr != "" { if pageStr := c.Query("page"); pageStr != "" {
if page, err := strconv.Atoi(pageStr); err == nil { if page, err := strconv.Atoi(pageStr); err == nil {
@ -186,11 +176,6 @@ func (h *CategoryHandler) ListCategories(c *gin.Context) {
} }
} }
if outletIDStr := c.Query("outlet_id"); outletIDStr != "" {
if outletID, err := uuid.Parse(outletIDStr); err == nil {
req.OutletID = &outletID
}
}
validationError, validationErrorCode := h.categoryValidator.ValidateListCategoriesRequest(req) validationError, validationErrorCode := h.categoryValidator.ValidateListCategoriesRequest(req)
if validationError != nil { if validationError != nil {
logger.FromContext(ctx).WithError(validationError).Error("CategoryHandler::ListCategories -> request validation failed") logger.FromContext(ctx).WithError(validationError).Error("CategoryHandler::ListCategories -> request validation failed")

View File

@ -1,8 +1,6 @@
package handler package handler
import ( import (
"apskel-pos-be/internal/logger"
"fmt"
"net/http" "net/http"
"time" "time"
) )
@ -49,7 +47,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)
} }
}() }()

View File

@ -1,229 +0,0 @@
package handler
import (
"apskel-pos-be/internal/appcontext"
"apskel-pos-be/internal/util"
"strconv"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/logger"
"apskel-pos-be/internal/service"
"apskel-pos-be/internal/validator"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type ExpenseHandler struct {
expenseService service.ExpenseService
expenseValidator validator.ExpenseValidator
}
func NewExpenseHandler(
expenseService service.ExpenseService,
expenseValidator validator.ExpenseValidator,
) *ExpenseHandler {
return &ExpenseHandler{
expenseService: expenseService,
expenseValidator: expenseValidator,
}
}
func (h *ExpenseHandler) CreateExpense(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
var req contract.CreateExpenseRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.FromContext(ctx).WithError(err).Error("ExpenseHandler::CreateExpense -> request binding failed")
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ExpenseHandler::CreateExpense")
return
}
validationError, validationErrorCode := h.expenseValidator.ValidateCreateExpenseRequest(&req)
if validationError != nil {
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ExpenseHandler::CreateExpense")
return
}
expenseResponse := h.expenseService.CreateExpense(ctx, contextInfo, &req)
if expenseResponse.HasErrors() {
errorResp := expenseResponse.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("ExpenseHandler::CreateExpense -> Failed to create expense from service")
}
util.HandleResponse(c.Writer, c.Request, expenseResponse, "ExpenseHandler::CreateExpense")
}
func (h *ExpenseHandler) UpdateExpense(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
expenseIDStr := c.Param("id")
expenseID, err := uuid.Parse(expenseIDStr)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("ExpenseHandler::UpdateExpense -> Invalid expense ID")
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid expense ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ExpenseHandler::UpdateExpense")
return
}
var req contract.UpdateExpenseRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.FromContext(ctx).WithError(err).Error("ExpenseHandler::UpdateExpense -> request binding failed")
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, "Invalid request body")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ExpenseHandler::UpdateExpense")
return
}
validationError, validationErrorCode := h.expenseValidator.ValidateUpdateExpenseRequest(&req)
if validationError != nil {
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ExpenseHandler::UpdateExpense")
return
}
expenseResponse := h.expenseService.UpdateExpense(ctx, contextInfo, expenseID, &req)
if expenseResponse.HasErrors() {
errorResp := expenseResponse.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("ExpenseHandler::UpdateExpense -> Failed to update expense from service")
}
util.HandleResponse(c.Writer, c.Request, expenseResponse, "ExpenseHandler::UpdateExpense")
}
func (h *ExpenseHandler) DeleteExpense(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
expenseIDStr := c.Param("id")
expenseID, err := uuid.Parse(expenseIDStr)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("ExpenseHandler::DeleteExpense -> Invalid expense ID")
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid expense ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ExpenseHandler::DeleteExpense")
return
}
expenseResponse := h.expenseService.DeleteExpense(ctx, contextInfo, expenseID)
if expenseResponse.HasErrors() {
errorResp := expenseResponse.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("ExpenseHandler::DeleteExpense -> Failed to delete expense from service")
}
util.HandleResponse(c.Writer, c.Request, expenseResponse, "ExpenseHandler::DeleteExpense")
}
func (h *ExpenseHandler) GetExpense(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
expenseIDStr := c.Param("id")
expenseID, err := uuid.Parse(expenseIDStr)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("ExpenseHandler::GetExpense -> Invalid expense ID")
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid expense ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ExpenseHandler::GetExpense")
return
}
expenseResponse := h.expenseService.GetExpenseByID(ctx, contextInfo, expenseID)
if expenseResponse.HasErrors() {
errorResp := expenseResponse.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("ExpenseHandler::GetExpense -> Failed to get expense from service")
}
util.HandleResponse(c.Writer, c.Request, expenseResponse, "ExpenseHandler::GetExpense")
}
func (h *ExpenseHandler) ListExpenses(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
req := &contract.ListExpenseRequest{
Page: 1,
Limit: 10,
}
if pageStr := c.Query("page"); pageStr != "" {
if page, err := strconv.Atoi(pageStr); err == nil {
req.Page = page
}
}
if limitStr := c.Query("limit"); limitStr != "" {
if limit, err := strconv.Atoi(limitStr); err == nil {
req.Limit = limit
}
}
if search := c.Query("search"); search != "" {
req.Search = search
}
if status := c.Query("status"); status != "" {
req.Status = status
}
// Prioritize outlet_id from context (e.g. outlet-scoped user),
// fall back to query param if context has no outlet.
if contextInfo.OutletID != uuid.Nil {
req.OutletID = contextInfo.OutletID.String()
} else if outletID := c.Query("outlet_id"); outletID != "" {
req.OutletID = outletID
}
if startDate := c.Query("start_date"); startDate != "" {
req.StartDate = startDate
}
if endDate := c.Query("end_date"); endDate != "" {
req.EndDate = endDate
}
validationError, validationErrorCode := h.expenseValidator.ValidateListExpenseRequest(req)
if validationError != nil {
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ExpenseHandler::ListExpenses")
return
}
expenseResponse := h.expenseService.ListExpenses(ctx, contextInfo, req)
if expenseResponse.HasErrors() {
errorResp := expenseResponse.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("ExpenseHandler::ListExpenses -> Failed to list expenses from service")
}
util.HandleResponse(c.Writer, c.Request, expenseResponse, "ExpenseHandler::ListExpenses")
}
func (h *ExpenseHandler) GetExpenseAnalytics(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
var req contract.ExpenseAnalyticsRequest
if err := c.ShouldBindQuery(&req); err != nil {
logger.FromContext(ctx).WithError(err).Error("ExpenseHandler::GetExpenseAnalytics -> request binding failed")
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, err.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ExpenseHandler::GetExpenseAnalytics")
return
}
if contextInfo.OutletID != uuid.Nil {
outletID := contextInfo.OutletID.String()
req.OutletID = &outletID
} else if outletID := c.Query("outlet_id"); outletID != "" {
req.OutletID = &outletID
}
expenseResponse := h.expenseService.GetExpenseAnalytics(ctx, contextInfo, &req)
if expenseResponse.HasErrors() {
errorResp := expenseResponse.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("ExpenseHandler::GetExpenseAnalytics -> Failed to get expense analytics from service")
}
util.HandleResponse(c.Writer, c.Request, expenseResponse, "ExpenseHandler::GetExpenseAnalytics")
}

View File

@ -275,3 +275,4 @@ func (h *IngredientUnitConverterHandler) GetUnitsByIngredientID(c *gin.Context)
util.HandleResponse(c.Writer, c.Request, unitsResponse, "IngredientUnitConverterHandler::GetUnitsByIngredientID") util.HandleResponse(c.Writer, c.Request, unitsResponse, "IngredientUnitConverterHandler::GetUnitsByIngredientID")
} }

View File

@ -1,190 +0,0 @@
package handler
import (
"apskel-pos-be/internal/appcontext"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/logger"
"apskel-pos-be/internal/service"
"apskel-pos-be/internal/util"
"apskel-pos-be/internal/validator"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type NotificationHandler struct {
notificationService service.NotificationService
notificationValidator validator.NotificationValidator
}
func NewNotificationHandler(
notificationService service.NotificationService,
notificationValidator validator.NotificationValidator,
) *NotificationHandler {
return &NotificationHandler{
notificationService: notificationService,
notificationValidator: notificationValidator,
}
}
// Send godoc
// POST /api/v1/notifications/send
// Sends a notification to specific users.
func (h *NotificationHandler) Send(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
var req contract.SendNotificationRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.FromContext(ctx).WithError(err).Error("NotificationHandler::Send -> request binding failed")
validationErr := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationErr}), "NotificationHandler::Send")
return
}
if validationErr, errCode := h.notificationValidator.ValidateSendRequest(&req); validationErr != nil {
respErr := contract.NewResponseError(errCode, constants.RequestEntity, validationErr.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{respErr}), "NotificationHandler::Send")
return
}
resp := h.notificationService.Send(ctx, &req, contextInfo.UserID)
if resp.HasErrors() {
logger.FromContext(ctx).WithError(resp.GetErrors()[0]).Error("NotificationHandler::Send -> service error")
}
util.HandleResponse(c.Writer, c.Request, resp, "NotificationHandler::Send")
}
// Broadcast godoc
// POST /api/v1/notifications/broadcast
// Sends a notification to all active users in the caller's organization.
func (h *NotificationHandler) Broadcast(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
var req contract.BroadcastNotificationRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.FromContext(ctx).WithError(err).Error("NotificationHandler::Broadcast -> request binding failed")
validationErr := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationErr}), "NotificationHandler::Broadcast")
return
}
if validationErr, errCode := h.notificationValidator.ValidateBroadcastRequest(&req); validationErr != nil {
respErr := contract.NewResponseError(errCode, constants.RequestEntity, validationErr.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{respErr}), "NotificationHandler::Broadcast")
return
}
resp := h.notificationService.Broadcast(ctx, &req, contextInfo.OrganizationID, contextInfo.UserID)
if resp.HasErrors() {
logger.FromContext(ctx).WithError(resp.GetErrors()[0]).Error("NotificationHandler::Broadcast -> service error")
}
util.HandleResponse(c.Writer, c.Request, resp, "NotificationHandler::Broadcast")
}
// List godoc
// GET /api/v1/notifications
// Returns paginated notifications for the authenticated user.
func (h *NotificationHandler) List(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
req := contract.ListNotificationsRequest{
Page: 1,
Limit: 20,
}
if err := c.ShouldBindQuery(&req); err != nil {
logger.FromContext(ctx).WithError(err).Error("NotificationHandler::List -> query binding failed")
validationErr := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationErr}), "NotificationHandler::List")
return
}
if validationErr, errCode := h.notificationValidator.ValidateListRequest(&req); validationErr != nil {
respErr := contract.NewResponseError(errCode, constants.RequestEntity, validationErr.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{respErr}), "NotificationHandler::List")
return
}
resp := h.notificationService.ListForUser(ctx, &req, contextInfo.UserID)
util.HandleResponse(c.Writer, c.Request, resp, "NotificationHandler::List")
}
// GetByID godoc
// GET /api/v1/notifications/:id
func (h *NotificationHandler) GetByID(c *gin.Context) {
ctx := c.Request.Context()
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("NotificationHandler::GetByID -> invalid notification ID")
validationErr := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid notification ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationErr}), "NotificationHandler::GetByID")
return
}
resp := h.notificationService.GetByID(ctx, id)
util.HandleResponse(c.Writer, c.Request, resp, "NotificationHandler::GetByID")
}
// MarkAsRead godoc
// PUT /api/v1/notifications/:id/read
func (h *NotificationHandler) MarkAsRead(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
idStr := c.Param("id")
receiverID, err := uuid.Parse(idStr)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("NotificationHandler::MarkAsRead -> invalid receiver ID")
validationErr := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid notification receiver ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationErr}), "NotificationHandler::MarkAsRead")
return
}
resp := h.notificationService.MarkAsRead(ctx, receiverID, contextInfo.UserID)
if resp.HasErrors() {
logger.FromContext(ctx).WithError(resp.GetErrors()[0]).Error("NotificationHandler::MarkAsRead -> service error")
}
util.HandleResponse(c.Writer, c.Request, resp, "NotificationHandler::MarkAsRead")
}
// MarkAllAsRead godoc
// PUT /api/v1/notifications/read-all
func (h *NotificationHandler) MarkAllAsRead(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
resp := h.notificationService.MarkAllAsRead(ctx, contextInfo.UserID)
util.HandleResponse(c.Writer, c.Request, resp, "NotificationHandler::MarkAllAsRead")
}
// Delete godoc
// DELETE /api/v1/notifications/:id
func (h *NotificationHandler) Delete(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
idStr := c.Param("id")
receiverID, err := uuid.Parse(idStr)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("NotificationHandler::Delete -> invalid receiver ID")
validationErr := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid notification receiver ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationErr}), "NotificationHandler::Delete")
return
}
resp := h.notificationService.DeleteForUser(ctx, receiverID, contextInfo.UserID)
if resp.HasErrors() {
logger.FromContext(ctx).WithError(resp.GetErrors()[0]).Error("NotificationHandler::Delete -> service error")
}
util.HandleResponse(c.Writer, c.Request, resp, "NotificationHandler::Delete")
}

View File

@ -137,10 +137,6 @@ func (h *OrderHandler) ListOrders(c *gin.Context) {
} }
modelReq.OrganizationID = &contextInfo.OrganizationID modelReq.OrganizationID = &contextInfo.OrganizationID
if modelReq.OutletID == nil && contextInfo.OutletID != uuid.Nil {
modelReq.OutletID = &contextInfo.OutletID
}
response, err := h.orderService.ListOrders(c.Request.Context(), modelReq) response, err := h.orderService.ListOrders(c.Request.Context(), modelReq)
if err != nil { if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "OrderHandler::ListOrders", err.Error())}), "OrderHandler::ListOrders") util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "OrderHandler::ListOrders", err.Error())}), "OrderHandler::ListOrders")

View File

@ -60,7 +60,6 @@ func (h *ProductHandler) CreateProduct(c *gin.Context) {
func (h *ProductHandler) UpdateProduct(c *gin.Context) { func (h *ProductHandler) UpdateProduct(c *gin.Context) {
ctx := c.Request.Context() ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
productIDStr := c.Param("id") productIDStr := c.Param("id")
productID, err := uuid.Parse(productIDStr) productID, err := uuid.Parse(productIDStr)
@ -86,7 +85,7 @@ func (h *ProductHandler) UpdateProduct(c *gin.Context) {
return return
} }
productResponse := h.productService.UpdateProduct(ctx, contextInfo, productID, &req) productResponse := h.productService.UpdateProduct(ctx, productID, &req)
if productResponse.HasErrors() { if productResponse.HasErrors() {
errorResp := productResponse.GetErrors()[0] errorResp := productResponse.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("ProductHandler::UpdateProduct -> Failed to update product from service") logger.FromContext(ctx).WithError(errorResp).Error("ProductHandler::UpdateProduct -> Failed to update product from service")
@ -118,7 +117,6 @@ func (h *ProductHandler) DeleteProduct(c *gin.Context) {
func (h *ProductHandler) GetProduct(c *gin.Context) { func (h *ProductHandler) GetProduct(c *gin.Context) {
ctx := c.Request.Context() ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
productIDStr := c.Param("id") productIDStr := c.Param("id")
productID, err := uuid.Parse(productIDStr) productID, err := uuid.Parse(productIDStr)
@ -129,7 +127,7 @@ func (h *ProductHandler) GetProduct(c *gin.Context) {
return return
} }
productResponse := h.productService.GetProductByID(ctx, productID, contextInfo.OutletID) productResponse := h.productService.GetProductByID(ctx, productID)
if productResponse.HasErrors() { if productResponse.HasErrors() {
errorResp := productResponse.GetErrors()[0] errorResp := productResponse.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("ProductHandler::GetProduct -> Failed to get product from service") logger.FromContext(ctx).WithError(errorResp).Error("ProductHandler::GetProduct -> Failed to get product from service")
@ -186,97 +184,6 @@ 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
}
} else if contextInfo.OutletID != uuid.Nil {
req.OutletID = &contextInfo.OutletID
}
if minPriceStr := c.Query("min_price"); minPriceStr != "" {
if minPrice, err := strconv.ParseFloat(minPriceStr, 64); err == nil {
req.MinPrice = &minPrice
}
}
if maxPriceStr := c.Query("max_price"); maxPriceStr != "" {
if maxPrice, err := strconv.ParseFloat(maxPriceStr, 64); err == nil {
req.MaxPrice = &maxPrice
}
}
validationError, validationErrorCode := h.productValidator.ValidateListProductsRequest(req)
if validationError != nil {
logger.FromContext(ctx).WithError(validationError).Error("ProductHandler::ListProducts -> request validation failed")
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ProductHandler::ListProducts")
return
}
productsResponse := h.productService.ListProducts(ctx, req)
if productsResponse.HasErrors() {
errorResp := productsResponse.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("ProductHandler::ListProducts -> Failed to list products from service")
}
util.HandleResponse(c.Writer, c.Request, productsResponse, "ProductHandler::ListProducts")
}
func (h *ProductHandler) ListProductAll(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
req := &contract.ListProductsRequest{
Page: 1,
Limit: 10,
OrganizationID: &contextInfo.OrganizationID,
}
if pageStr := c.Query("page"); pageStr != "" {
if page, err := strconv.Atoi(pageStr); err == nil {
req.Page = page
}
}
if limitStr := c.Query("limit"); limitStr != "" {
if limit, err := strconv.Atoi(limitStr); err == nil {
req.Limit = limit
}
}
if search := c.Query("search"); search != "" {
req.Search = search
}
if businessType := c.Query("business_type"); businessType != "" {
req.BusinessType = businessType
}
if organizationIDStr := c.Query("organization_id"); organizationIDStr != "" {
if organizationID, err := uuid.Parse(organizationIDStr); err == nil {
req.OrganizationID = &organizationID
}
}
if categoryIDStr := c.Query("category_id"); categoryIDStr != "" {
if categoryID, err := uuid.Parse(categoryIDStr); err == nil {
req.CategoryID = &categoryID
}
}
if isActiveStr := c.Query("is_active"); isActiveStr != "" {
if isActive, err := strconv.ParseBool(isActiveStr); err == nil {
req.IsActive = &isActive
}
}
if outletIDStr := c.Query("outlet_id"); outletIDStr != "" {
if outletID, err := uuid.Parse(outletIDStr); err == nil {
req.OutletID = &outletID
}
}
if minPriceStr := c.Query("min_price"); minPriceStr != "" { if minPriceStr := c.Query("min_price"); minPriceStr != "" {
if minPrice, err := strconv.ParseFloat(minPriceStr, 64); err == nil { if minPrice, err := strconv.ParseFloat(minPriceStr, 64); err == nil {
req.MinPrice = &minPrice req.MinPrice = &minPrice

View File

@ -1,135 +0,0 @@
package handler
import (
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/logger"
"apskel-pos-be/internal/service"
"apskel-pos-be/internal/util"
"apskel-pos-be/internal/validator"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type ProductOutletPriceHandler struct {
service service.ProductOutletPriceService
validator validator.ProductOutletPriceValidator
}
func NewProductOutletPriceHandler(svc service.ProductOutletPriceService, v validator.ProductOutletPriceValidator) *ProductOutletPriceHandler {
return &ProductOutletPriceHandler{
service: svc,
validator: v,
}
}
func (h *ProductOutletPriceHandler) Upsert(c *gin.Context) {
ctx := c.Request.Context()
var req contract.CreateProductOutletPriceRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.FromContext(ctx).WithError(err).Error("ProductOutletPriceHandler::Upsert -> request binding failed")
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ProductOutletPriceHandler::Upsert")
return
}
if validationErr, code := h.validator.ValidateCreateRequest(&req); validationErr != nil {
validationResponseError := contract.NewResponseError(code, constants.RequestEntity, validationErr.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ProductOutletPriceHandler::Upsert")
return
}
resp := h.service.Upsert(ctx, &req)
util.HandleResponse(c.Writer, c.Request, resp, "ProductOutletPriceHandler::Upsert")
}
func (h *ProductOutletPriceHandler) GetByProductAndOutlet(c *gin.Context) {
ctx := c.Request.Context()
productIDStr := c.Param("product_id")
productID, err := uuid.Parse(productIDStr)
if err != nil {
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid product ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ProductOutletPriceHandler::GetByProductAndOutlet")
return
}
outletIDStr := c.Param("outlet_id")
outletID, err := uuid.Parse(outletIDStr)
if err != nil {
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid outlet ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ProductOutletPriceHandler::GetByProductAndOutlet")
return
}
resp := h.service.GetByProductAndOutlet(ctx, productID, outletID)
util.HandleResponse(c.Writer, c.Request, resp, "ProductOutletPriceHandler::GetByProductAndOutlet")
}
func (h *ProductOutletPriceHandler) GetByProduct(c *gin.Context) {
ctx := c.Request.Context()
productIDStr := c.Param("product_id")
productID, err := uuid.Parse(productIDStr)
if err != nil {
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid product ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ProductOutletPriceHandler::GetByProduct")
return
}
resp := h.service.GetByProduct(ctx, productID)
util.HandleResponse(c.Writer, c.Request, resp, "ProductOutletPriceHandler::GetByProduct")
}
func (h *ProductOutletPriceHandler) GetByOutlet(c *gin.Context) {
ctx := c.Request.Context()
outletIDStr := c.Param("outlet_id")
outletID, err := uuid.Parse(outletIDStr)
if err != nil {
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid outlet ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ProductOutletPriceHandler::GetByOutlet")
return
}
resp := h.service.GetByOutlet(ctx, outletID)
util.HandleResponse(c.Writer, c.Request, resp, "ProductOutletPriceHandler::GetByOutlet")
}
func (h *ProductOutletPriceHandler) Delete(c *gin.Context) {
ctx := c.Request.Context()
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ProductOutletPriceHandler::Delete")
return
}
resp := h.service.Delete(ctx, id)
util.HandleResponse(c.Writer, c.Request, resp, "ProductOutletPriceHandler::Delete")
}
func (h *ProductOutletPriceHandler) BulkUpsert(c *gin.Context) {
ctx := c.Request.Context()
var req contract.BulkCreateProductOutletPriceRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.FromContext(ctx).WithError(err).Error("ProductOutletPriceHandler::BulkUpsert -> request binding failed")
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ProductOutletPriceHandler::BulkUpsert")
return
}
if validationErr, code := h.validator.ValidateBulkCreateRequest(&req); validationErr != nil {
validationResponseError := contract.NewResponseError(code, constants.RequestEntity, validationErr.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ProductOutletPriceHandler::BulkUpsert")
return
}
resp := h.service.BulkUpsert(ctx, &req)
util.HandleResponse(c.Writer, c.Request, resp, "ProductOutletPriceHandler::BulkUpsert")
}

View File

@ -1,160 +0,0 @@
package handler
import (
"strconv"
"apskel-pos-be/internal/appcontext"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/logger"
"apskel-pos-be/internal/service"
"apskel-pos-be/internal/util"
"apskel-pos-be/internal/validator"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type PurchaseCategoryHandler struct {
purchaseCategoryService service.PurchaseCategoryService
purchaseCategoryValidator validator.PurchaseCategoryValidator
}
func NewPurchaseCategoryHandler(purchaseCategoryService service.PurchaseCategoryService, purchaseCategoryValidator validator.PurchaseCategoryValidator) *PurchaseCategoryHandler {
return &PurchaseCategoryHandler{
purchaseCategoryService: purchaseCategoryService,
purchaseCategoryValidator: purchaseCategoryValidator,
}
}
func (h *PurchaseCategoryHandler) CreatePurchaseCategory(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
var req contract.CreatePurchaseCategoryRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.FromContext(ctx).WithError(err).Error("PurchaseCategoryHandler::CreatePurchaseCategory -> request binding failed")
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "PurchaseCategoryHandler::CreatePurchaseCategory")
return
}
if validationError, validationErrorCode := h.purchaseCategoryValidator.ValidateCreatePurchaseCategoryRequest(&req); validationError != nil {
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "PurchaseCategoryHandler::CreatePurchaseCategory")
return
}
response := h.purchaseCategoryService.CreatePurchaseCategory(ctx, contextInfo, &req)
util.HandleResponse(c.Writer, c.Request, response, "PurchaseCategoryHandler::CreatePurchaseCategory")
}
func (h *PurchaseCategoryHandler) UpdatePurchaseCategory(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
categoryID, err := uuid.Parse(c.Param("id"))
if err != nil {
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid purchase category ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "PurchaseCategoryHandler::UpdatePurchaseCategory")
return
}
var req contract.UpdatePurchaseCategoryRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.FromContext(ctx).WithError(err).Error("PurchaseCategoryHandler::UpdatePurchaseCategory -> request binding failed")
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "PurchaseCategoryHandler::UpdatePurchaseCategory")
return
}
if validationError, validationErrorCode := h.purchaseCategoryValidator.ValidateUpdatePurchaseCategoryRequest(&req); validationError != nil {
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "PurchaseCategoryHandler::UpdatePurchaseCategory")
return
}
response := h.purchaseCategoryService.UpdatePurchaseCategory(ctx, contextInfo, categoryID, &req)
util.HandleResponse(c.Writer, c.Request, response, "PurchaseCategoryHandler::UpdatePurchaseCategory")
}
func (h *PurchaseCategoryHandler) DeletePurchaseCategory(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
categoryID, err := uuid.Parse(c.Param("id"))
if err != nil {
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid purchase category ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "PurchaseCategoryHandler::DeletePurchaseCategory")
return
}
response := h.purchaseCategoryService.DeletePurchaseCategory(ctx, contextInfo, categoryID)
util.HandleResponse(c.Writer, c.Request, response, "PurchaseCategoryHandler::DeletePurchaseCategory")
}
func (h *PurchaseCategoryHandler) GetPurchaseCategory(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
categoryID, err := uuid.Parse(c.Param("id"))
if err != nil {
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid purchase category ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "PurchaseCategoryHandler::GetPurchaseCategory")
return
}
response := h.purchaseCategoryService.GetPurchaseCategoryByID(ctx, contextInfo, categoryID)
util.HandleResponse(c.Writer, c.Request, response, "PurchaseCategoryHandler::GetPurchaseCategory")
}
func (h *PurchaseCategoryHandler) ListPurchaseCategories(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
req := &contract.ListPurchaseCategoriesRequest{
Page: 1,
Limit: 100,
}
if pageStr := c.Query("page"); pageStr != "" {
if page, err := strconv.Atoi(pageStr); err == nil {
req.Page = page
}
}
if limitStr := c.Query("limit"); limitStr != "" {
if limit, err := strconv.Atoi(limitStr); err == nil {
req.Limit = limit
}
}
if parentIDStr := c.Query("parent_id"); parentIDStr != "" {
if parentID, err := uuid.Parse(parentIDStr); err == nil {
req.ParentID = &parentID
}
}
if categoryType := c.Query("type"); categoryType != "" {
req.Type = categoryType
}
if search := c.Query("search"); search != "" {
req.Search = search
}
if isActiveStr := c.Query("is_active"); isActiveStr != "" {
if isActive, err := strconv.ParseBool(isActiveStr); err == nil {
req.IsActive = &isActive
}
}
if validationError, validationErrorCode := h.purchaseCategoryValidator.ValidateListPurchaseCategoriesRequest(req); validationError != nil {
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "PurchaseCategoryHandler::ListPurchaseCategories")
return
}
response := h.purchaseCategoryService.ListPurchaseCategories(ctx, contextInfo, req)
util.HandleResponse(c.Writer, c.Request, response, "PurchaseCategoryHandler::ListPurchaseCategories")
}

View File

@ -8,7 +8,6 @@ import (
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid"
) )
type ReportHandler struct { type ReportHandler struct {
@ -20,26 +19,11 @@ 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 := h.resolveOutletID(c, ci.OutletID) outletID := c.Param("outlet_id")
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 {
@ -66,35 +50,3 @@ func (h *ReportHandler) GetDailyTransactionReportPDF(c *gin.Context) {
"file_name": fileName, "file_name": fileName,
}), "ReportHandler::GetDailyTransactionReportPDF") }), "ReportHandler::GetDailyTransactionReportPDF")
} }
func (h *ReportHandler) GetProfitLossReportPDF(c *gin.Context) {
ctx := c.Request.Context()
ci := appcontext.FromGinContext(ctx)
outletID := h.resolveOutletID(c, ci.OutletID)
var dayPtr *time.Time
if d := c.Query("date"); d != "" {
if t, err := time.Parse("2006-01-02", d); err == nil {
dayPtr = &t
}
}
user, err := h.userService.GetUserByID(ctx, ci.UserID)
var genBy string
if err != nil {
genBy = ci.UserID.String()
} else {
genBy = user.Name
}
publicURL, fileName, err := h.reportService.GenerateProfitLossPDF(ctx, ci.OrganizationID.String(), outletID, dayPtr, genBy)
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "ReportHandler::GetProfitLossReportPDF", err.Error())}), "ReportHandler::GetProfitLossReportPDF")
return
}
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(map[string]string{
"url": publicURL,
"file_name": fileName,
}), "ReportHandler::GetProfitLossReportPDF")
}

View File

@ -1,11 +1,11 @@
package handler package handler
import ( import (
"apskel-pos-be/internal/client"
"apskel-pos-be/internal/constants" "apskel-pos-be/internal/constants"
"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"
@ -15,6 +15,7 @@ import (
"apskel-pos-be/internal/util" "apskel-pos-be/internal/util"
"context" "context"
"fmt" "fmt"
"log"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
@ -29,7 +30,7 @@ type SelfOrderHandler struct {
userRepo processor.UserRepository userRepo processor.UserRepository
sessionRepo repository.SessionRepository sessionRepo repository.SessionRepository
orderRepo repository.OrderRepository orderRepo repository.OrderRepository
productOutletPriceService service.ProductOutletPriceService fcmClient client.FcmClient
} }
func NewSelfOrderHandler( func NewSelfOrderHandler(
@ -41,7 +42,7 @@ func NewSelfOrderHandler(
userRepo processor.UserRepository, userRepo processor.UserRepository,
sessionRepo repository.SessionRepository, sessionRepo repository.SessionRepository,
orderRepo repository.OrderRepository, orderRepo repository.OrderRepository,
productOutletPriceService service.ProductOutletPriceService, fcmClient client.FcmClient,
) *SelfOrderHandler { ) *SelfOrderHandler {
return &SelfOrderHandler{ return &SelfOrderHandler{
orderService: orderService, orderService: orderService,
@ -52,7 +53,7 @@ func NewSelfOrderHandler(
userRepo: userRepo, userRepo: userRepo,
sessionRepo: sessionRepo, sessionRepo: sessionRepo,
orderRepo: orderRepo, orderRepo: orderRepo,
productOutletPriceService: productOutletPriceService, fcmClient: fcmClient,
} }
} }
@ -219,29 +220,16 @@ func (h *SelfOrderHandler) GetMenu(c *gin.Context) {
return return
} }
menu := h.buildMenuResponse(ctx, outlet, table, catList.Categories, prodList.Products) menu := h.buildMenuResponse(outlet, table, catList.Categories, prodList.Products)
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(menu), "SelfOrderHandler::GetMenu") util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(menu), "SelfOrderHandler::GetMenu")
} }
func (h *SelfOrderHandler) buildMenuResponse( func (h *SelfOrderHandler) buildMenuResponse(
ctx context.Context,
outlet *entities.Outlet, outlet *entities.Outlet,
table *entities.Table, table *entities.Table,
categories []contract.CategoryResponse, categories []contract.CategoryResponse,
products []contract.ProductResponse, products []contract.ProductResponse,
) *contract.SelfOrderMenuResponse { ) *contract.SelfOrderMenuResponse {
outletPriceMap := make(map[uuid.UUID]float64)
if h.productOutletPriceService != nil {
priceResp := h.productOutletPriceService.GetByOutlet(ctx, outlet.ID)
if priceResp != nil && !priceResp.HasErrors() {
if priceList, ok := priceResp.Data.(*contract.ListProductOutletPricesResponse); ok {
for _, p := range priceList.Prices {
outletPriceMap[p.ProductID] = p.Price
}
}
}
}
productMap := make(map[uuid.UUID][]contract.ProductResponse) productMap := make(map[uuid.UUID][]contract.ProductResponse)
for _, p := range products { for _, p := range products {
productMap[p.CategoryID] = append(productMap[p.CategoryID], p) productMap[p.CategoryID] = append(productMap[p.CategoryID], p)
@ -252,15 +240,11 @@ func (h *SelfOrderHandler) buildMenuResponse(
menuItems := make([]contract.SelfOrderMenuItem, 0) menuItems := make([]contract.SelfOrderMenuItem, 0)
if prods, ok := productMap[cat.ID]; ok { if prods, ok := productMap[cat.ID]; ok {
for _, p := range prods { for _, p := range prods {
price := p.Price
if outletPrice, exists := outletPriceMap[p.ID]; exists {
price = outletPrice
}
item := contract.SelfOrderMenuItem{ item := contract.SelfOrderMenuItem{
ID: p.ID, ID: p.ID,
Name: p.Name, Name: p.Name,
Description: p.Description, Description: p.Description,
Price: price, Price: p.Price,
ImageURL: p.ImageURL, ImageURL: p.ImageURL,
} }
for _, v := range p.Variants { for _, v := range p.Variants {
@ -351,7 +335,6 @@ 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{
@ -359,7 +342,7 @@ func (h *SelfOrderHandler) CreateOrder(c *gin.Context) {
UserID: userID, UserID: userID,
TableID: &tableID, TableID: &tableID,
TableNumber: &table.TableName, TableNumber: &table.TableName,
OrderType: constants.OrderType(req.OrderType), OrderType: constants.OrderTypeDineIn,
OrderItems: orderItems, OrderItems: orderItems,
Metadata: metadata, Metadata: metadata,
} }
@ -373,13 +356,41 @@ func (h *SelfOrderHandler) CreateOrder(c *gin.Context) {
return return
} }
go h.sendNewOrderNotification(context.Background(), table.OrganizationID, table.TableName, len(req.OrderItems))
contractResp := transformer.OrderModelToContract(response) contractResp := transformer.OrderModelToContract(response)
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "SelfOrderHandler::CreateOrder") util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "SelfOrderHandler::CreateOrder")
} }
func (h *SelfOrderHandler) sendNewOrderNotification(ctx context.Context, organizationID uuid.UUID, tableName string, itemCount int) {
users, err := h.userRepo.GetUsersWithFcmTokenByOrganization(ctx, organizationID)
if err != nil {
log.Printf("SelfOrderHandler::sendNewOrderNotification -> failed to get users with FCM token: %v", err)
return
}
tokens := make([]string, 0, len(users))
for _, u := range users {
if u.FcmToken != nil && *u.FcmToken != "" {
tokens = append(tokens, *u.FcmToken)
}
}
if len(tokens) == 0 {
return
}
title := "Order Baru"
body := fmt.Sprintf("Order baru dari Meja %s — %d item", tableName, itemCount)
if err := h.fcmClient.SendMulticastNotification(ctx, tokens, title, body); err != nil {
log.Printf("SelfOrderHandler::sendNewOrderNotification -> failed to send FCM notification: %v", err)
}
}
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("session_id") sessionID := c.Param("sessionId")
if sessionID == "" { if sessionID == "" {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
@ -412,15 +423,47 @@ func (h *SelfOrderHandler) GetOrdersBySession(c *gin.Context) {
return return
} }
modelOrders := mappers.OrderEntitiesToResponses(orders) resp := &contract.SelfOrderListOrdersResponse{
contractOrders := make([]contract.OrderResponse, len(modelOrders)) Orders: make([]contract.SelfOrderOrderItem, 0, len(orders)),
for i := range modelOrders { }
contractOrders[i] = *transformer.OrderModelToContract(&modelOrders[i]) for _, o := range orders {
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")
} }
@ -429,7 +472,6 @@ 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 {
@ -457,7 +499,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, "organization_id is required"), contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, "organisasi_id is required"),
}), "SelfOrderHandler::ListCategories") }), "SelfOrderHandler::ListCategories")
return return
} }
@ -472,7 +514,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 organization_id format"), contract.NewResponseError(constants.ValidationErrorCode, constants.RequestEntity, "invalid organisasi_id format"),
}), "SelfOrderHandler::ListCategories") }), "SelfOrderHandler::ListCategories")
return return
} }

View File

@ -19,14 +19,14 @@ import (
type TableHandler struct { type TableHandler struct {
tableService TableService tableService TableService
tableValidator *validator.TableValidator tableValidator *validator.TableValidator
selfOrderURL string baseURL string
} }
func NewTableHandler(tableService TableService, tableValidator *validator.TableValidator, selfOrderURL string) *TableHandler { func NewTableHandler(tableService TableService, tableValidator *validator.TableValidator, baseURL string) *TableHandler {
return &TableHandler{ return &TableHandler{
tableService: tableService, tableService: tableService,
tableValidator: tableValidator, tableValidator: tableValidator,
selfOrderURL: selfOrderURL, baseURL: baseURL,
} }
} }
@ -150,11 +150,6 @@ func (h *TableHandler) List(c *gin.Context) {
Limit: 100, Limit: 100,
} }
// Fallback to context outlet ID if not provided in query
if query.OutletID == "" && contextInfo.OutletID != uuid.Nil {
query.OutletID = contextInfo.OutletID.String()
}
if pageStr := c.Query("page"); pageStr != "" { if pageStr := c.Query("page"); pageStr != "" {
if page, err := strconv.Atoi(pageStr); err == nil && page > 0 { if page, err := strconv.Atoi(pageStr); err == nil && page > 0 {
query.Page = page query.Page = page
@ -317,7 +312,7 @@ func (h *TableHandler) GenerateQRCode(c *gin.Context) {
return return
} }
selfOrderURLResult := fmt.Sprintf("%s/menu?token=%s", h.selfOrderURL, token) selfOrderURL := fmt.Sprintf("%s/api/v1/self-order/table/%s", h.baseURL, token)
size := 256 size := 256
if sizeStr := c.Query("size"); sizeStr != "" { if sizeStr := c.Query("size"); sizeStr != "" {
@ -326,7 +321,7 @@ func (h *TableHandler) GenerateQRCode(c *gin.Context) {
} }
} }
pngBytes, err := qrcode.GeneratePNG(selfOrderURLResult, size) pngBytes, err := qrcode.GeneratePNG(selfOrderURL, 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")

View File

@ -1,215 +0,0 @@
package handler
import (
"strconv"
"apskel-pos-be/internal/appcontext"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/logger"
"apskel-pos-be/internal/service"
"apskel-pos-be/internal/util"
"apskel-pos-be/internal/validator"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type UserDeviceHandler struct {
userDeviceService service.UserDeviceService
userDeviceValidator validator.UserDeviceValidator
}
func NewUserDeviceHandler(
userDeviceService service.UserDeviceService,
userDeviceValidator validator.UserDeviceValidator,
) *UserDeviceHandler {
return &UserDeviceHandler{
userDeviceService: userDeviceService,
userDeviceValidator: userDeviceValidator,
}
}
func (h *UserDeviceHandler) RegisterDevice(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
var req contract.RegisterUserDeviceRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.FromContext(ctx).WithError(err).Error("UserDeviceHandler::RegisterDevice -> request binding failed")
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "UserDeviceHandler::RegisterDevice")
return
}
validationError, validationErrorCode := h.userDeviceValidator.ValidateRegisterDeviceRequest(&req)
if validationError != nil {
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "UserDeviceHandler::RegisterDevice")
return
}
deviceResponse := h.userDeviceService.RegisterDevice(ctx, contextInfo.UserID, &req)
if deviceResponse.HasErrors() {
errorResp := deviceResponse.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("UserDeviceHandler::RegisterDevice -> Failed to register device from service")
}
util.HandleResponse(c.Writer, c.Request, deviceResponse, "UserDeviceHandler::RegisterDevice")
}
func (h *UserDeviceHandler) UpdateDevice(c *gin.Context) {
ctx := c.Request.Context()
deviceIDStr := c.Param("id")
deviceID, err := uuid.Parse(deviceIDStr)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("UserDeviceHandler::UpdateDevice -> Invalid device ID")
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid device ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "UserDeviceHandler::UpdateDevice")
return
}
var req contract.UpdateUserDeviceRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.FromContext(ctx).WithError(err).Error("UserDeviceHandler::UpdateDevice -> request binding failed")
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, "Invalid request body")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "UserDeviceHandler::UpdateDevice")
return
}
validationError, validationErrorCode := h.userDeviceValidator.ValidateUpdateDeviceRequest(&req)
if validationError != nil {
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "UserDeviceHandler::UpdateDevice")
return
}
deviceResponse := h.userDeviceService.UpdateDevice(ctx, deviceID, &req)
if deviceResponse.HasErrors() {
errorResp := deviceResponse.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("UserDeviceHandler::UpdateDevice -> Failed to update device from service")
}
util.HandleResponse(c.Writer, c.Request, deviceResponse, "UserDeviceHandler::UpdateDevice")
}
func (h *UserDeviceHandler) DeleteDevice(c *gin.Context) {
ctx := c.Request.Context()
deviceIDStr := c.Param("id")
deviceID, err := uuid.Parse(deviceIDStr)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("UserDeviceHandler::DeleteDevice -> Invalid device ID")
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid device ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "UserDeviceHandler::DeleteDevice")
return
}
deviceResponse := h.userDeviceService.DeleteDevice(ctx, deviceID)
if deviceResponse.HasErrors() {
errorResp := deviceResponse.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("UserDeviceHandler::DeleteDevice -> Failed to delete device from service")
}
util.HandleResponse(c.Writer, c.Request, deviceResponse, "UserDeviceHandler::DeleteDevice")
}
func (h *UserDeviceHandler) GetDevice(c *gin.Context) {
ctx := c.Request.Context()
deviceIDStr := c.Param("id")
deviceID, err := uuid.Parse(deviceIDStr)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("UserDeviceHandler::GetDevice -> Invalid device ID")
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid device ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "UserDeviceHandler::GetDevice")
return
}
deviceResponse := h.userDeviceService.GetDeviceByID(ctx, deviceID)
if deviceResponse.HasErrors() {
errorResp := deviceResponse.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("UserDeviceHandler::GetDevice -> Failed to get device from service")
}
util.HandleResponse(c.Writer, c.Request, deviceResponse, "UserDeviceHandler::GetDevice")
}
func (h *UserDeviceHandler) GetMyDevices(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
deviceResponse := h.userDeviceService.GetDevicesByUserID(ctx, contextInfo.UserID)
if deviceResponse.HasErrors() {
errorResp := deviceResponse.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("UserDeviceHandler::GetMyDevices -> Failed to get devices from service")
}
util.HandleResponse(c.Writer, c.Request, deviceResponse, "UserDeviceHandler::GetMyDevices")
}
func (h *UserDeviceHandler) GetDevicesByUser(c *gin.Context) {
ctx := c.Request.Context()
userIDStr := c.Param("user_id")
userID, err := uuid.Parse(userIDStr)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("UserDeviceHandler::GetDevicesByUser -> Invalid user ID")
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid user ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "UserDeviceHandler::GetDevicesByUser")
return
}
deviceResponse := h.userDeviceService.GetDevicesByUserID(ctx, userID)
if deviceResponse.HasErrors() {
errorResp := deviceResponse.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("UserDeviceHandler::GetDevicesByUser -> Failed to get devices from service")
}
util.HandleResponse(c.Writer, c.Request, deviceResponse, "UserDeviceHandler::GetDevicesByUser")
}
func (h *UserDeviceHandler) ListDevices(c *gin.Context) {
ctx := c.Request.Context()
req := &contract.ListUserDevicesRequest{
Page: 1,
Limit: 10,
}
if pageStr := c.Query("page"); pageStr != "" {
if page, err := strconv.Atoi(pageStr); err == nil {
req.Page = page
}
}
if limitStr := c.Query("limit"); limitStr != "" {
if limit, err := strconv.Atoi(limitStr); err == nil {
req.Limit = limit
}
}
if userID := c.Query("user_id"); userID != "" {
req.UserID = userID
}
if platform := c.Query("platform"); platform != "" {
req.Platform = platform
}
validationError, validationErrorCode := h.userDeviceValidator.ValidateListDevicesRequest(req)
if validationError != nil {
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "UserDeviceHandler::ListDevices")
return
}
deviceResponse := h.userDeviceService.ListDevices(ctx, req)
if deviceResponse.HasErrors() {
errorResp := deviceResponse.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("UserDeviceHandler::ListDevices -> Failed to list devices from service")
}
util.HandleResponse(c.Writer, c.Request, deviceResponse, "UserDeviceHandler::ListDevices")
}

View File

@ -13,12 +13,11 @@ func CategoryEntityToModel(entity *entities.Category) *models.Category {
return &models.Category{ return &models.Category{
ID: entity.ID, ID: entity.ID,
OrganizationID: entity.OrganizationID, OrganizationID: entity.OrganizationID,
OutletID: entity.OutletID,
Name: entity.Name, Name: entity.Name,
Description: entity.Description, Description: entity.Description,
ImageURL: nil, ImageURL: nil, // Entity doesn't have ImageURL, model does
Order: entity.Order, Order: entity.Order, // Entity doesn't have SortOrder, model does
IsActive: true, IsActive: true, // Entity doesn't have IsActive, default to true
CreatedAt: entity.CreatedAt, CreatedAt: entity.CreatedAt,
UpdatedAt: entity.UpdatedAt, UpdatedAt: entity.UpdatedAt,
} }
@ -33,14 +32,14 @@ func CategoryModelToEntity(model *models.Category) *entities.Category {
if model.ImageURL != nil { if model.ImageURL != nil {
metadata["image_url"] = *model.ImageURL metadata["image_url"] = *model.ImageURL
} }
// metadata["sort_order"] = model.SortOrder
return &entities.Category{ return &entities.Category{
ID: model.ID, ID: model.ID,
OrganizationID: model.OrganizationID, OrganizationID: model.OrganizationID,
OutletID: model.OutletID,
Name: model.Name, Name: model.Name,
Description: model.Description, Description: model.Description,
BusinessType: "restaurant", BusinessType: "restaurant", // Default business type
Order: model.Order, Order: model.Order,
Metadata: metadata, Metadata: metadata,
CreatedAt: model.CreatedAt, CreatedAt: model.CreatedAt,
@ -57,14 +56,14 @@ func CreateCategoryRequestToEntity(req *models.CreateCategoryRequest) *entities.
if req.ImageURL != nil { if req.ImageURL != nil {
metadata["image_url"] = *req.ImageURL metadata["image_url"] = *req.ImageURL
} }
// metadata["sort_order"] = req.SortOrder
return &entities.Category{ return &entities.Category{
OrganizationID: req.OrganizationID, OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
Name: req.Name, Name: req.Name,
Description: req.Description, Description: req.Description,
Order: req.Order, Order: req.Order,
BusinessType: "restaurant", BusinessType: "restaurant", // Default business type
Metadata: metadata, Metadata: metadata,
} }
} }
@ -88,12 +87,11 @@ func CategoryEntityToResponse(entity *entities.Category) *models.CategoryRespons
return &models.CategoryResponse{ return &models.CategoryResponse{
ID: entity.ID, ID: entity.ID,
OrganizationID: entity.OrganizationID, OrganizationID: entity.OrganizationID,
OutletID: entity.OutletID,
Name: entity.Name, Name: entity.Name,
Description: entity.Description, Description: entity.Description,
ImageURL: imageURL, ImageURL: imageURL,
Order: entity.Order, Order: entity.Order,
IsActive: true, IsActive: true, // Default to true since entity doesn't have this field
CreatedAt: entity.CreatedAt, CreatedAt: entity.CreatedAt,
UpdatedAt: entity.UpdatedAt, UpdatedAt: entity.UpdatedAt,
} }
@ -123,10 +121,6 @@ func UpdateCategoryEntityFromRequest(entity *entities.Category, req *models.Upda
if req.Order != nil { if req.Order != nil {
entity.Order = *req.Order entity.Order = *req.Order
} }
if req.OutletID != nil {
entity.OutletID = req.OutletID
}
} }
func CategoryEntitiesToModels(entities []*entities.Category) []*models.Category { func CategoryEntitiesToModels(entities []*entities.Category) []*models.Category {

View File

@ -1,135 +0,0 @@
package mappers
import (
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
)
func ExpenseEntityToModel(entity *entities.Expense) *models.Expense {
if entity == nil {
return nil
}
return &models.Expense{
ID: entity.ID,
OrganizationID: entity.OrganizationID,
OutletID: entity.OutletID,
Receiver: entity.Receiver,
TransactionDate: entity.TransactionDate,
CodeNumber: entity.CodeNumber,
Status: entity.Status,
Description: entity.Description,
Tax: entity.Tax,
Total: entity.Total,
Reserved1: entity.Reserved1,
CreatedAt: entity.CreatedAt,
UpdatedAt: entity.UpdatedAt,
}
}
func ExpenseModelToEntity(model *models.Expense) *entities.Expense {
if model == nil {
return nil
}
return &entities.Expense{
ID: model.ID,
OrganizationID: model.OrganizationID,
OutletID: model.OutletID,
Receiver: model.Receiver,
TransactionDate: model.TransactionDate,
CodeNumber: model.CodeNumber,
Status: model.Status,
Description: model.Description,
Tax: model.Tax,
Total: model.Total,
Reserved1: model.Reserved1,
CreatedAt: model.CreatedAt,
UpdatedAt: model.UpdatedAt,
}
}
func ExpenseEntityToResponse(entity *entities.Expense) *models.ExpenseResponse {
if entity == nil {
return nil
}
resp := &models.ExpenseResponse{
ID: entity.ID,
OrganizationID: entity.OrganizationID,
OutletID: entity.OutletID,
Receiver: entity.Receiver,
TransactionDate: entity.TransactionDate,
CodeNumber: entity.CodeNumber,
Status: entity.Status,
Description: entity.Description,
Tax: entity.Tax,
Total: entity.Total,
Reserved1: entity.Reserved1,
CreatedAt: entity.CreatedAt,
UpdatedAt: entity.UpdatedAt,
}
if entity.Items != nil {
resp.Items = ExpenseItemEntitiesToResponses(entity.Items)
}
return resp
}
func ExpenseEntitiesToResponses(entities []*entities.Expense) []*models.ExpenseResponse {
if entities == nil {
return nil
}
responses := make([]*models.ExpenseResponse, len(entities))
for i, entity := range entities {
responses[i] = ExpenseEntityToResponse(entity)
}
return responses
}
func ExpenseItemEntityToResponse(entity *entities.ExpenseItem) *models.ExpenseItemResponse {
if entity == nil {
return nil
}
response := &models.ExpenseItemResponse{
ID: entity.ID,
ExpenseID: entity.ExpenseID,
ChartOfAccountID: entity.ChartOfAccountID,
PurchaseCategoryID: entity.PurchaseCategoryID,
Item: entity.Item,
Description: entity.Description,
Amount: entity.Amount,
CreatedAt: entity.CreatedAt,
UpdatedAt: entity.UpdatedAt,
}
if entity.ChartOfAccount != nil {
response.ChartOfAccountName = entity.ChartOfAccount.Name
}
if entity.PurchaseCategory != nil {
response.PurchaseCategoryName = entity.PurchaseCategory.Name
response.PurchaseCategoryType = string(entity.PurchaseCategory.Type)
response.PurchaseCategory = PurchaseCategoryEntityToResponse(entity.PurchaseCategory)
}
return response
}
func ExpenseItemEntitiesToResponses(entities []entities.ExpenseItem) []models.ExpenseItemResponse {
if entities == nil {
return nil
}
responses := make([]models.ExpenseItemResponse, len(entities))
for i, entity := range entities {
response := ExpenseItemEntityToResponse(&entity)
if response != nil {
responses[i] = *response
}
}
return responses
}

View File

@ -1,85 +0,0 @@
package mappers
import (
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
)
func NotificationEntityToResponse(e *entities.Notification) *models.NotificationResponse {
if e == nil {
return nil
}
return &models.NotificationResponse{
ID: e.ID,
Title: e.Title,
Body: e.Body,
Type: e.Type,
Category: e.Category,
Priority: e.Priority,
ImageURL: e.ImageURL,
ActionURL: e.ActionURL,
NotifiableType: e.NotifiableType,
NotifiableID: e.NotifiableID,
Data: e.Data,
ScheduledAt: e.ScheduledAt,
SentAt: e.SentAt,
ExpiredAt: e.ExpiredAt,
CreatedBy: e.CreatedBy,
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt,
}
}
func NotificationReceiverEntityToResponse(e *entities.NotificationReceiver) *models.NotificationReceiverResponse {
if e == nil {
return nil
}
resp := &models.NotificationReceiverResponse{
ID: e.ID,
NotificationID: e.NotificationID,
UserID: e.UserID,
IsRead: e.IsRead,
ReadAt: e.ReadAt,
IsDeleted: e.IsDeleted,
DeletedAt: e.DeletedAt,
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt,
}
if e.Notification != nil {
resp.Notification = NotificationEntityToResponse(e.Notification)
}
return resp
}
func NotificationReceiverEntitiesToResponses(entities []*entities.NotificationReceiver) []*models.NotificationReceiverResponse {
if entities == nil {
return nil
}
responses := make([]*models.NotificationReceiverResponse, len(entities))
for i, e := range entities {
responses[i] = NotificationReceiverEntityToResponse(e)
}
return responses
}
func NotificationDeliveryEntityToResponse(e *entities.NotificationDelivery) *models.NotificationDeliveryResponse {
if e == nil {
return nil
}
return &models.NotificationDeliveryResponse{
ID: e.ID,
NotificationReceiverID: e.NotificationReceiverID,
UserDeviceID: e.UserDeviceID,
Channel: e.Channel,
DeliveryStatus: e.DeliveryStatus,
Provider: e.Provider,
ProviderMessageID: e.ProviderMessageID,
SentAt: e.SentAt,
DeliveredAt: e.DeliveredAt,
FailedAt: e.FailedAt,
FailureReason: e.FailureReason,
RetryCount: e.RetryCount,
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt,
}
}

View File

@ -82,7 +82,7 @@ func OrderEntityToResponse(order *entities.Order) *models.OrderResponse {
} }
for i, item := range order.OrderItems { for i, item := range order.OrderItems {
resp := OrderItemEntityToResponse(&item, order.OutletID) resp := OrderItemEntityToResponse(&item)
if resp != nil { if resp != nil {
resp.PaidQuantity = paidQtyByOrderItem[item.ID] resp.PaidQuantity = paidQtyByOrderItem[item.ID]
response.OrderItems[i] = *resp response.OrderItems[i] = *resp
@ -101,20 +101,11 @@ func OrderEntityToResponse(order *entities.Order) *models.OrderResponse {
return response return response
} }
func OrderItemEntityToResponse(item *entities.OrderItem, outletID uuid.UUID) *models.OrderItemResponse { func OrderItemEntityToResponse(item *entities.OrderItem) *models.OrderItemResponse {
if item == nil { if item == nil {
return nil return nil
} }
// Resolve print_to_checker from preloaded outlet prices
printToChecker := true // default
for _, op := range item.Product.ProductOutletPrices {
if op.OutletID == outletID {
printToChecker = op.PrintToChecker
break
}
}
response := &models.OrderItemResponse{ response := &models.OrderItemResponse{
ID: item.ID, ID: item.ID,
OrderID: item.OrderID, OrderID: item.OrderID,
@ -139,19 +130,10 @@ func OrderItemEntityToResponse(item *entities.OrderItem, outletID uuid.UUID) *mo
CreatedAt: item.CreatedAt, CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt, UpdatedAt: item.UpdatedAt,
PrinterType: item.Product.PrinterType, PrinterType: item.Product.PrinterType,
PrintToChecker: printToChecker,
} }
if item.Product.ID != uuid.Nil { if item.Product.ID != uuid.Nil {
response.ProductName = item.Product.Name response.ProductName = item.Product.Name
if item.Product.CategoryID != uuid.Nil {
categoryID := item.Product.CategoryID
response.CategoryID = &categoryID
}
if item.Product.Category.ID != uuid.Nil {
categoryName := item.Product.Category.Name
response.CategoryName = &categoryName
}
} }
if item.ProductVariant != nil { if item.ProductVariant != nil {
@ -334,14 +316,14 @@ func OrderEntitiesToResponses(orders []*entities.Order) []models.OrderResponse {
return responses return responses
} }
func OrderItemEntitiesToResponses(items []*entities.OrderItem, outletID uuid.UUID) []models.OrderItemResponse { func OrderItemEntitiesToResponses(items []*entities.OrderItem) []models.OrderItemResponse {
if items == nil { if items == nil {
return nil return nil
} }
responses := make([]models.OrderItemResponse, len(items)) responses := make([]models.OrderItemResponse, len(items))
for i, item := range items { for i, item := range items {
response := OrderItemEntityToResponse(item, outletID) response := OrderItemEntityToResponse(item)
if response != nil { if response != nil {
responses[i] = *response responses[i] = *response
} }

View File

@ -45,7 +45,7 @@ func TestOrderItemEntityToResponse_WithProductNames(t *testing.T) {
} }
// Act // Act
result := OrderItemEntityToResponse(orderItem, uuid.Nil) result := OrderItemEntityToResponse(orderItem)
// Assert // Assert
assert.NotNil(t, result) assert.NotNil(t, result)
@ -89,7 +89,7 @@ func TestOrderItemEntityToResponse_WithoutProductVariant(t *testing.T) {
} }
// Act // Act
result := OrderItemEntityToResponse(orderItem, uuid.Nil) result := OrderItemEntityToResponse(orderItem)
// Assert // Assert
assert.NotNil(t, result) assert.NotNil(t, result)
@ -129,7 +129,7 @@ func TestOrderItemEntityToResponse_WithoutProductPreload(t *testing.T) {
} }
// Act // Act
result := OrderItemEntityToResponse(orderItem, uuid.Nil) result := OrderItemEntityToResponse(orderItem)
// Assert // Assert
assert.NotNil(t, result) assert.NotNil(t, result)

View File

@ -135,7 +135,6 @@ func ProductEntityToResponse(entity *entities.Product) *models.ProductResponse {
Name: entity.Name, Name: entity.Name,
Description: entity.Description, Description: entity.Description,
Price: entity.Price, Price: entity.Price,
OutletPrice: nil, // populated by processor when outletID is available
Cost: entity.Cost, Cost: entity.Cost,
BusinessType: constants.BusinessType(entity.BusinessType), BusinessType: constants.BusinessType(entity.BusinessType),
ImageURL: entity.ImageURL, ImageURL: entity.ImageURL,

View File

@ -1,50 +0,0 @@
package mappers
import (
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
)
func ProductOutletPriceEntityToModel(entity *entities.ProductOutletPrice) *models.ProductOutletPrice {
if entity == nil {
return nil
}
return &models.ProductOutletPrice{
ID: entity.ID,
ProductID: entity.ProductID,
OutletID: entity.OutletID,
Price: entity.Price,
PrintToChecker: entity.PrintToChecker,
CreatedAt: entity.CreatedAt,
UpdatedAt: entity.UpdatedAt,
}
}
func ProductOutletPriceModelToEntity(model *models.ProductOutletPrice) *entities.ProductOutletPrice {
if model == nil {
return nil
}
return &entities.ProductOutletPrice{
ID: model.ID,
ProductID: model.ProductID,
OutletID: model.OutletID,
Price: model.Price,
PrintToChecker: model.PrintToChecker,
CreatedAt: model.CreatedAt,
UpdatedAt: model.UpdatedAt,
}
}
func ProductOutletPriceEntitiesToModels(entities []*entities.ProductOutletPrice) []*models.ProductOutletPrice {
if entities == nil {
return nil
}
models := make([]*models.ProductOutletPrice, len(entities))
for i, entity := range entities {
models[i] = ProductOutletPriceEntityToModel(entity)
}
return models
}

View File

@ -1,53 +0,0 @@
package mappers
import (
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
)
func CreatePurchaseCategoryRequestToEntity(req *models.CreatePurchaseCategoryRequest) *entities.PurchaseCategory {
if req == nil {
return nil
}
return &entities.PurchaseCategory{
OrganizationID: req.OrganizationID,
ParentID: req.ParentID,
Name: req.Name,
Type: entities.PurchaseCategoryType(req.Type),
SortOrder: req.SortOrder,
IsActive: req.IsActive,
}
}
func PurchaseCategoryEntityToResponse(entity *entities.PurchaseCategory) *models.PurchaseCategoryResponse {
if entity == nil {
return nil
}
return &models.PurchaseCategoryResponse{
ID: entity.ID,
OrganizationID: entity.OrganizationID,
PresetID: entity.PresetID,
ParentID: entity.ParentID,
Code: entity.Code,
Name: entity.Name,
Type: string(entity.Type),
SortOrder: entity.SortOrder,
IsSystem: entity.IsSystem,
IsActive: entity.IsActive,
CreatedAt: entity.CreatedAt,
UpdatedAt: entity.UpdatedAt,
}
}
func PurchaseCategoryEntitiesToResponses(categoryEntities []*entities.PurchaseCategory) []models.PurchaseCategoryResponse {
responses := make([]models.PurchaseCategoryResponse, len(categoryEntities))
for i, entity := range categoryEntities {
response := PurchaseCategoryEntityToResponse(entity)
if response != nil {
responses[i] = *response
}
}
return responses
}

View File

@ -13,7 +13,6 @@ func PurchaseOrderEntityToModel(entity *entities.PurchaseOrder) *models.Purchase
return &models.PurchaseOrder{ return &models.PurchaseOrder{
ID: entity.ID, ID: entity.ID,
OrganizationID: entity.OrganizationID, OrganizationID: entity.OrganizationID,
OutletID: entity.OutletID,
VendorID: entity.VendorID, VendorID: entity.VendorID,
PONumber: entity.PONumber, PONumber: entity.PONumber,
TransactionDate: entity.TransactionDate, TransactionDate: entity.TransactionDate,
@ -35,7 +34,6 @@ func PurchaseOrderModelToEntity(model *models.PurchaseOrder) *entities.PurchaseO
return &entities.PurchaseOrder{ return &entities.PurchaseOrder{
ID: model.ID, ID: model.ID,
OrganizationID: model.OrganizationID, OrganizationID: model.OrganizationID,
OutletID: model.OutletID,
VendorID: model.VendorID, VendorID: model.VendorID,
PONumber: model.PONumber, PONumber: model.PONumber,
TransactionDate: model.TransactionDate, TransactionDate: model.TransactionDate,
@ -57,7 +55,6 @@ func PurchaseOrderEntityToResponse(entity *entities.PurchaseOrder) *models.Purch
response := &models.PurchaseOrderResponse{ response := &models.PurchaseOrderResponse{
ID: entity.ID, ID: entity.ID,
OrganizationID: entity.OrganizationID, OrganizationID: entity.OrganizationID,
OutletID: entity.OutletID,
VendorID: entity.VendorID, VendorID: entity.VendorID,
PONumber: entity.PONumber, PONumber: entity.PONumber,
TransactionDate: entity.TransactionDate, TransactionDate: entity.TransactionDate,
@ -97,7 +94,6 @@ func PurchaseOrderItemEntityToModel(entity *entities.PurchaseOrderItem) *models.
ID: entity.ID, ID: entity.ID,
PurchaseOrderID: entity.PurchaseOrderID, PurchaseOrderID: entity.PurchaseOrderID,
IngredientID: entity.IngredientID, IngredientID: entity.IngredientID,
PurchaseCategoryID: entity.PurchaseCategoryID,
Description: entity.Description, Description: entity.Description,
Quantity: entity.Quantity, Quantity: entity.Quantity,
UnitID: entity.UnitID, UnitID: entity.UnitID,
@ -116,7 +112,6 @@ func PurchaseOrderItemModelToEntity(model *models.PurchaseOrderItem) *entities.P
ID: model.ID, ID: model.ID,
PurchaseOrderID: model.PurchaseOrderID, PurchaseOrderID: model.PurchaseOrderID,
IngredientID: model.IngredientID, IngredientID: model.IngredientID,
PurchaseCategoryID: model.PurchaseCategoryID,
Description: model.Description, Description: model.Description,
Quantity: model.Quantity, Quantity: model.Quantity,
UnitID: model.UnitID, UnitID: model.UnitID,
@ -135,7 +130,6 @@ func PurchaseOrderItemEntityToResponse(entity *entities.PurchaseOrderItem) *mode
ID: entity.ID, ID: entity.ID,
PurchaseOrderID: entity.PurchaseOrderID, PurchaseOrderID: entity.PurchaseOrderID,
IngredientID: entity.IngredientID, IngredientID: entity.IngredientID,
PurchaseCategoryID: entity.PurchaseCategoryID,
Description: entity.Description, Description: entity.Description,
Quantity: entity.Quantity, Quantity: entity.Quantity,
UnitID: entity.UnitID, UnitID: entity.UnitID,
@ -152,10 +146,6 @@ func PurchaseOrderItemEntityToResponse(entity *entities.PurchaseOrderItem) *mode
} }
} }
if entity.PurchaseCategory != nil {
response.PurchaseCategory = PurchaseCategoryEntityToResponse(entity.PurchaseCategory)
}
// Map unit if present // Map unit if present
if entity.Unit != nil { if entity.Unit != nil {
response.Unit = &models.UnitResponse{ response.Unit = &models.UnitResponse{

View File

@ -1,62 +0,0 @@
package mappers
import (
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
)
func UserDeviceEntityToModel(entity *entities.UserDevice) *models.UserDevice {
if entity == nil {
return nil
}
return &models.UserDevice{
ID: entity.ID,
UserID: entity.UserID,
DeviceID: entity.DeviceID,
DeviceName: entity.DeviceName,
DeviceType: entity.DeviceType,
Platform: entity.Platform,
FCMToken: entity.FCMToken,
AppVersion: entity.AppVersion,
OsVersion: entity.OsVersion,
IPAddress: entity.IPAddress,
LastActiveAt: entity.LastActiveAt,
CreatedAt: entity.CreatedAt,
UpdatedAt: entity.UpdatedAt,
}
}
func UserDeviceEntityToResponse(entity *entities.UserDevice) *models.UserDeviceResponse {
if entity == nil {
return nil
}
return &models.UserDeviceResponse{
ID: entity.ID,
UserID: entity.UserID,
DeviceID: entity.DeviceID,
DeviceName: entity.DeviceName,
DeviceType: entity.DeviceType,
Platform: entity.Platform,
FCMToken: entity.FCMToken,
AppVersion: entity.AppVersion,
OsVersion: entity.OsVersion,
IPAddress: entity.IPAddress,
LastActiveAt: entity.LastActiveAt,
CreatedAt: entity.CreatedAt,
UpdatedAt: entity.UpdatedAt,
}
}
func UserDeviceEntitiesToResponses(entities []*entities.UserDevice) []*models.UserDeviceResponse {
if entities == nil {
return nil
}
responses := make([]*models.UserDeviceResponse, len(entities))
for i, entity := range entities {
responses[i] = UserDeviceEntityToResponse(entity)
}
return responses
}

View File

@ -11,7 +11,6 @@ import (
"apskel-pos-be/internal/service" "apskel-pos-be/internal/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid"
) )
type AuthMiddleware struct { type AuthMiddleware struct {
@ -46,13 +45,9 @@ func (m *AuthMiddleware) RequireAuth() gin.HandlerFunc {
setKeyInContext(c, appcontext.OrganizationIDKey, userResponse.OrganizationID.String()) setKeyInContext(c, appcontext.OrganizationIDKey, userResponse.OrganizationID.String())
setKeyInContext(c, appcontext.UserIDKey, userResponse.ID.String()) setKeyInContext(c, appcontext.UserIDKey, userResponse.ID.String())
// Always override OutletID from token to prevent header injection. if userResponse.Role != "superadmin" {
// Set empty string if user has no outlet, so PopulateContext header value is ignored. setKeyInContext(c, appcontext.OutletIDKey, userResponse.OutletID.String())
outletIDStr := ""
if userResponse.OutletID != nil && *userResponse.OutletID != uuid.Nil {
outletIDStr = userResponse.OutletID.String()
} }
setKeyInContext(c, appcontext.OutletIDKey, outletIDStr)
logger.FromContext(c.Request.Context()).Infof("AuthMiddleware::RequireAuth -> User authenticated: %s", userResponse.Email) logger.FromContext(c.Request.Context()).Infof("AuthMiddleware::RequireAuth -> User authenticated: %s", userResponse.Email)
c.Next() c.Next()
@ -82,11 +77,7 @@ func (m *AuthMiddleware) RequireRole(allowedRoles ...string) gin.HandlerFunc {
} }
func (m *AuthMiddleware) RequireAdminOrManager() gin.HandlerFunc { func (m *AuthMiddleware) RequireAdminOrManager() gin.HandlerFunc {
return m.RequireRole("superadmin", "admin", "manager", "owner", "purchasing") return m.RequireRole("superadmin", "admin", "manager")
}
func (m *AuthMiddleware) RequireAdminOrManagerOrPurchasing() gin.HandlerFunc {
return m.RequireRole("superadmin", "admin", "manager", "owner", "purchasing")
} }
func (m *AuthMiddleware) RequireAdmin() gin.HandlerFunc { func (m *AuthMiddleware) RequireAdmin() gin.HandlerFunc {

View File

@ -1,137 +0,0 @@
package middleware
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
)
const (
IdempotencyKeyHeader = "X-Idempotency-Key"
idempotencyTTL = 24 * time.Hour
idempotencyPrefix = "idempotency:"
)
type cachedResponse struct {
StatusCode int `json:"status_code"`
Headers map[string]string `json:"headers"`
Body string `json:"body"`
}
// IdempotencyMiddleware returns a Gin middleware that ensures idempotent processing
// for mutating operations. Client must send X-Idempotency-Key header.
func IdempotencyMiddleware(redisClient *redis.Client) gin.HandlerFunc {
return func(c *gin.Context) {
key := c.GetHeader(IdempotencyKeyHeader)
if key == "" {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
"success": false,
"errors": []gin.H{
{
"code": "missing_idempotency_key",
"entity": "IdempotencyMiddleware",
"cause": "X-Idempotency-Key header is required",
},
},
})
return
}
redisKey := fmt.Sprintf("%s%s", idempotencyPrefix, key)
ctx := context.Background()
fmt.Printf("[DEBUG] IdempotencyMiddleware: key=%s redisKey=%s\n", key, redisKey)
// Check if key already exists (request was already processed)
cached, err := redisClient.Get(ctx, redisKey).Result()
if err == nil {
// Key exists — return cached response
fmt.Printf("[DEBUG] IdempotencyMiddleware: cache HIT for key=%s\n", key)
var resp cachedResponse
if err := json.Unmarshal([]byte(cached), &resp); err == nil {
for k, v := range resp.Headers {
c.Writer.Header().Set(k, v)
}
c.Writer.Header().Set("X-Idempotent-Replay", "true")
c.Data(resp.StatusCode, "application/json", []byte(resp.Body))
c.Abort()
return
}
} else {
fmt.Printf("[DEBUG] IdempotencyMiddleware: cache MISS for key=%s err=%v\n", key, err)
}
// Mark key as in-progress to prevent concurrent duplicates
set, err := redisClient.SetNX(ctx, redisKey, "processing", idempotencyTTL).Result()
if err != nil {
// Redis error — proceed without idempotency (fail open)
c.Next()
return
}
if !set {
// Another request with the same key is being processed
c.AbortWithStatusJSON(http.StatusConflict, gin.H{
"success": false,
"errors": []gin.H{
{
"code": "request_in_progress",
"entity": "IdempotencyMiddleware",
"cause": "A request with this idempotency key is already being processed",
},
},
})
return
}
// Capture response using a custom writer
writer := &responseCapture{
ResponseWriter: c.Writer,
body: &bytes.Buffer{},
}
c.Writer = writer
c.Next()
// After handler completes, cache the response only if successful (2xx)
statusCode := writer.Status()
if statusCode >= 200 && statusCode < 300 {
resp := cachedResponse{
StatusCode: statusCode,
Headers: map[string]string{
"Content-Type": writer.Header().Get("Content-Type"),
},
Body: writer.body.String(),
}
respJSON, err := json.Marshal(resp)
if err == nil {
redisClient.Set(ctx, redisKey, string(respJSON), idempotencyTTL)
}
} else {
// Remove the in-progress key so the client can retry with the same key
redisClient.Del(ctx, redisKey)
}
}
}
// responseCapture wraps gin.ResponseWriter to capture the response body
type responseCapture struct {
gin.ResponseWriter
body *bytes.Buffer
}
func (w *responseCapture) Write(b []byte) (int, error) {
w.body.Write(b)
return w.ResponseWriter.Write(b)
}
func (w *responseCapture) WriteString(s string) (int, error) {
w.body.WriteString(s)
return w.ResponseWriter.WriteString(s)
}

View File

@ -19,7 +19,6 @@ type PaymentMethodAnalyticsRequest struct {
type PaymentMethodAnalyticsResponse struct { type PaymentMethodAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"` DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"` DateTo time.Time `json:"date_to"`
GroupBy string `json:"group_by"` GroupBy string `json:"group_by"`
@ -59,7 +58,6 @@ type SalesAnalyticsRequest struct {
type SalesAnalyticsResponse struct { type SalesAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"` DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"` DateTo time.Time `json:"date_to"`
GroupBy string `json:"group_by"` GroupBy string `json:"group_by"`
@ -89,77 +87,6 @@ type SalesAnalyticsData struct {
NetSales float64 `json:"net_sales"` NetSales float64 `json:"net_sales"`
} }
// PurchasingAnalyticsRequest represents the request for purchasing analytics
type PurchasingAnalyticsRequest struct {
OrganizationID uuid.UUID `validate:"required"`
OutletID *uuid.UUID `validate:"omitempty"`
DateFrom time.Time `validate:"required"`
DateTo time.Time `validate:"required"`
GroupBy string `validate:"omitempty,oneof=day hour week month"`
}
// PurchasingAnalyticsResponse represents the response for purchasing analytics
type PurchasingAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"`
GroupBy string `json:"group_by"`
Summary PurchasingSummary `json:"summary"`
Data []PurchasingAnalyticsData `json:"data"`
IngredientData []PurchasingIngredientData `json:"ingredient_data"`
VendorData []PurchasingVendorData `json:"vendor_data"`
}
// PurchasingSummary represents the summary of purchasing analytics
type PurchasingSummary struct {
TotalPurchases float64 `json:"total_purchases"`
RawMaterialPurchases float64 `json:"raw_material_purchases"`
ExpensePurchases float64 `json:"expense_purchases"`
TotalPurchaseOrders int64 `json:"total_purchase_orders"`
RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"`
ExpenseCount int64 `json:"expense_count"`
TotalQuantity float64 `json:"total_quantity"`
AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"`
TotalIngredients int64 `json:"total_ingredients"`
TotalVendors int64 `json:"total_vendors"`
}
// PurchasingAnalyticsData represents purchasing analytics by time period
type PurchasingAnalyticsData struct {
Date time.Time `json:"date"`
Purchases float64 `json:"purchases"`
RawMaterialPurchases float64 `json:"raw_material_purchases"`
ExpensePurchases float64 `json:"expense_purchases"`
PurchaseOrders int64 `json:"purchase_orders"`
RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"`
ExpenseCount int64 `json:"expense_count"`
Quantity float64 `json:"quantity"`
Ingredients int64 `json:"ingredients"`
Vendors int64 `json:"vendors"`
}
// PurchasingIngredientData represents purchasing analytics for an ingredient
type PurchasingIngredientData struct {
IngredientID uuid.UUID `json:"ingredient_id"`
IngredientName string `json:"ingredient_name"`
Quantity float64 `json:"quantity"`
TotalCost float64 `json:"total_cost"`
AverageUnitCost float64 `json:"average_unit_cost"`
PurchaseOrderCount int64 `json:"purchase_order_count"`
}
// PurchasingVendorData represents purchasing analytics for a vendor
type PurchasingVendorData struct {
VendorID *uuid.UUID `json:"vendor_id"`
VendorName string `json:"vendor_name"`
TotalCost float64 `json:"total_cost"`
PurchaseOrderCount int64 `json:"purchase_order_count"`
IngredientCount int64 `json:"ingredient_count"`
Quantity float64 `json:"quantity"`
}
// ProductAnalyticsRequest represents the request for product analytics // ProductAnalyticsRequest represents the request for product analytics
type ProductAnalyticsRequest struct { type ProductAnalyticsRequest struct {
OrganizationID uuid.UUID `validate:"required"` OrganizationID uuid.UUID `validate:"required"`
@ -173,7 +100,6 @@ type ProductAnalyticsRequest struct {
type ProductAnalyticsResponse struct { type ProductAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"` DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"` DateTo time.Time `json:"date_to"`
Data []ProductAnalyticsData `json:"data"` Data []ProductAnalyticsData `json:"data"`
@ -183,7 +109,6 @@ type ProductAnalyticsData struct {
ProductID uuid.UUID `json:"product_id"` ProductID uuid.UUID `json:"product_id"`
ProductName string `json:"product_name"` ProductName string `json:"product_name"`
ProductSku string `json:"product_sku"` ProductSku string `json:"product_sku"`
ProductPrice float64 `json:"product_price"`
CategoryID uuid.UUID `json:"category_id"` CategoryID uuid.UUID `json:"category_id"`
CategoryName string `json:"category_name"` CategoryName string `json:"category_name"`
CategoryOrder int `json:"category_order"` CategoryOrder int `json:"category_order"`
@ -211,7 +136,6 @@ type ProductAnalyticsPerCategoryRequest struct {
type ProductAnalyticsPerCategoryResponse struct { type ProductAnalyticsPerCategoryResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"` DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"` DateTo time.Time `json:"date_to"`
Data []ProductAnalyticsPerCategoryData `json:"data"` Data []ProductAnalyticsPerCategoryData `json:"data"`
@ -241,7 +165,6 @@ type DashboardAnalyticsRequest struct {
type DashboardAnalyticsResponse struct { type DashboardAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"` DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"` DateTo time.Time `json:"date_to"`
Overview DashboardOverview `json:"overview"` Overview DashboardOverview `json:"overview"`
@ -258,11 +181,9 @@ type DashboardOverview struct {
TotalCustomers int64 `json:"total_customers"` TotalCustomers int64 `json:"total_customers"`
VoidedOrders int64 `json:"voided_orders"` VoidedOrders int64 `json:"voided_orders"`
RefundedOrders int64 `json:"refunded_orders"` RefundedOrders int64 `json:"refunded_orders"`
TotalItemSold int64 `json:"total_item_sold"`
TotalLowStock int64 `json:"total_low_stock"`
TotalProductActive int64 `json:"total_product_active"`
} }
// ProfitLossAnalyticsRequest represents the request for profit and loss analytics
type ProfitLossAnalyticsRequest struct { type ProfitLossAnalyticsRequest struct {
OrganizationID uuid.UUID `validate:"required"` OrganizationID uuid.UUID `validate:"required"`
OutletID *uuid.UUID `validate:"omitempty"` OutletID *uuid.UUID `validate:"omitempty"`
@ -271,39 +192,19 @@ type ProfitLossAnalyticsRequest struct {
GroupBy string `validate:"omitempty,oneof=day hour week month"` GroupBy string `validate:"omitempty,oneof=day hour week month"`
} }
// ProfitLossAnalyticsResponse represents the response for profit and loss analytics
type ProfitLossAnalyticsResponse struct { type ProfitLossAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"` DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"` DateTo time.Time `json:"date_to"`
GroupBy string `json:"group_by"` GroupBy string `json:"group_by"`
Summary ProfitLossSummary `json:"summary"` Summary ProfitLossSummary `json:"summary"`
Data []ProfitLossData `json:"data"` Data []ProfitLossData `json:"data"`
ProductData []ProductProfitData `json:"product_data"` ProductData []ProductProfitData `json:"product_data"`
MainSummary []ProfitLossSummaryRow `json:"main_summary"`
Purchasing ProfitLossPurchasing `json:"purchasing"`
OperationalExpenses []OperationalExpenseItem `json:"operational_expenses"`
OperationalExpensesTotal float64 `json:"operational_expenses_total"`
}
type ProfitLossPurchasing struct {
TodayTotal float64 `json:"today_total"`
MtdTotal float64 `json:"mtd_total"`
TodayRawMaterial float64 `json:"today_raw_material"`
MtdRawMaterial float64 `json:"mtd_raw_material"`
TodayExpense float64 `json:"today_expense"`
MtdExpense float64 `json:"mtd_expense"`
Items []ProfitLossPurchasingItem `json:"items"`
}
type ProfitLossPurchasingItem struct {
Date time.Time `json:"date"`
Item string `json:"item"`
Quantity float64 `json:"quantity"`
Nominal float64 `json:"nominal"`
} }
// ProfitLossSummary represents the summary of profit and loss analytics
type ProfitLossSummary struct { type ProfitLossSummary struct {
TotalRevenue float64 `json:"total_revenue"` TotalRevenue float64 `json:"total_revenue"`
TotalCost float64 `json:"total_cost"` TotalCost float64 `json:"total_cost"`
@ -318,6 +219,7 @@ type ProfitLossSummary struct {
ProfitabilityRatio float64 `json:"profitability_ratio"` ProfitabilityRatio float64 `json:"profitability_ratio"`
} }
// ProfitLossData represents individual profit and loss data point by time period
type ProfitLossData struct { type ProfitLossData struct {
Date time.Time `json:"date"` Date time.Time `json:"date"`
Revenue float64 `json:"revenue"` Revenue float64 `json:"revenue"`
@ -331,6 +233,7 @@ type ProfitLossData struct {
Orders int64 `json:"orders"` Orders int64 `json:"orders"`
} }
// ProductProfitData represents profit data for individual products
type ProductProfitData struct { type ProductProfitData struct {
ProductID uuid.UUID `json:"product_id"` ProductID uuid.UUID `json:"product_id"`
ProductName string `json:"product_name"` ProductName string `json:"product_name"`
@ -345,139 +248,3 @@ type ProductProfitData struct {
AverageCost float64 `json:"average_cost"` AverageCost float64 `json:"average_cost"`
ProfitPerUnit float64 `json:"profit_per_unit"` ProfitPerUnit float64 `json:"profit_per_unit"`
} }
type ProfitLossSummaryRow struct {
ID string `json:"id"`
Label string `json:"label"`
IsBold bool `json:"is_bold"`
TodayNominal float64 `json:"today_nominal"`
TodayPct float64 `json:"today_pct"`
MtdNominal float64 `json:"mtd_nominal"`
MtdPct float64 `json:"mtd_pct"`
SubItems []ProfitLossSummaryRow `json:"sub_items,omitempty"`
}
type OperationalExpenseItem struct {
Item string `json:"item"`
Nominal float64 `json:"nominal"`
}
type ExclusiveSummaryPeriodRequest struct {
OrganizationID uuid.UUID `validate:"required"`
OutletID *uuid.UUID `validate:"omitempty"`
DateFrom time.Time `validate:"required"`
DateTo time.Time `validate:"required"`
ExcludeGajiStaffFromReimburse bool `validate:"omitempty"`
}
type ExclusiveSummaryMonthlyRequest struct {
OrganizationID uuid.UUID `validate:"required"`
OutletID *uuid.UUID `validate:"omitempty"`
Month time.Time `validate:"required"`
}
type ExclusiveSummaryMTDRequest struct {
OrganizationID uuid.UUID `validate:"required"`
OutletID *uuid.UUID `validate:"omitempty"`
DateTo time.Time `validate:"required"`
ExcludeGajiStaffFromReimburse bool `validate:"omitempty"`
}
type ExclusiveSummaryPeriodResponse struct {
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
Period ExclusiveSummaryPeriodRange `json:"period"`
Summary ExclusiveSummaryPeriodSummary `json:"summary"`
Reimburse ExclusiveSummaryReimburse `json:"reimburse"`
HPPBreakdown []ExclusiveSummaryCategoryBreakdown `json:"hpp_breakdown"`
OperationalExpenseBreakdown []ExclusiveSummaryCategoryBreakdown `json:"operational_expense_breakdown"`
DailySummary []ExclusiveSummaryDailySummary `json:"daily_summary"`
DailyTransactions []ExclusiveSummaryDailyTransaction `json:"daily_transactions"`
}
type ExclusiveSummaryPeriodRange struct {
DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"`
}
type ExclusiveSummaryPeriodSummary struct {
Sales float64 `json:"sales"`
HPP float64 `json:"hpp"`
GrossProfit float64 `json:"gross_profit"`
SalaryTotal float64 `json:"salary_total"`
SalaryDW float64 `json:"salary_dw"`
SalaryStaff float64 `json:"salary_staff"`
SalaryOther float64 `json:"salary_other"`
OtherOperationalExpenses float64 `json:"other_operational_expenses"`
OperationalExpensesTotal float64 `json:"operational_expenses_total"`
TotalCost float64 `json:"total_cost"`
NetProfit float64 `json:"net_profit"`
}
type ExclusiveSummaryReimburse struct {
TotalCost float64 `json:"total_cost"`
ExcludedSalaryStaff float64 `json:"excluded_salary_staff"`
TotalReimburse float64 `json:"total_reimburse"`
}
type ExclusiveSummaryCategoryBreakdown struct {
CategoryCode string `json:"category_code"`
CategoryName string `json:"category_name"`
Amount float64 `json:"amount"`
Percentage float64 `json:"percentage"`
}
type ExclusiveSummaryDailySummary struct {
Date time.Time `json:"date"`
TransactionCount int64 `json:"transaction_count"`
TotalCost float64 `json:"total_cost"`
}
type ExclusiveSummaryDailyTransaction struct {
Date time.Time `json:"date"`
CategoryCode string `json:"category_code"`
CategoryName string `json:"category_name"`
Description string `json:"description"`
Amount float64 `json:"amount"`
Source string `json:"source"`
}
type ExclusiveSummaryMonthlyResponse struct {
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
Month string `json:"month"`
Summary ExclusiveSummaryMonthlySummary `json:"summary"`
Periods []ExclusiveSummaryMonthlyPeriod `json:"periods"`
BankBalance []ExclusiveSummaryBankBalance `json:"bank_balance"`
}
type ExclusiveSummaryMonthlySummary struct {
TotalSales float64 `json:"total_sales"`
HPP float64 `json:"hpp"`
GrossProfit float64 `json:"gross_profit"`
OperationalExpensesTotal float64 `json:"operational_expenses_total"`
TotalCost float64 `json:"total_cost"`
NetProfit float64 `json:"net_profit"`
NetProfitMargin float64 `json:"net_profit_margin"`
}
type ExclusiveSummaryMonthlyPeriod struct {
Label string `json:"label"`
DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"`
Sales float64 `json:"sales"`
HPP float64 `json:"hpp"`
GrossProfit float64 `json:"gross_profit"`
GrossMargin float64 `json:"gross_margin"`
}
type ExclusiveSummaryBankBalance struct {
Bank string `json:"bank"`
OpeningBalance *float64 `json:"opening_balance"`
IncomingMutation *float64 `json:"incoming_mutation"`
OutgoingMutation *float64 `json:"outgoing_mutation"`
ClosingBalance *float64 `json:"closing_balance"`
Notes *string `json:"notes"`
}

View File

@ -9,7 +9,6 @@ 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
@ -21,7 +20,6 @@ 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"`
@ -32,7 +30,6 @@ type UpdateCategoryRequest struct {
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"`
OutletID *uuid.UUID
Order *int `validate:"omitempty,min=0"` Order *int `validate:"omitempty,min=0"`
IsActive *bool IsActive *bool
} }
@ -40,7 +37,6 @@ 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

View File

@ -1,190 +0,0 @@
package models
import (
"time"
"github.com/google/uuid"
)
type Expense struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
OutletID uuid.UUID `json:"outlet_id"`
Receiver string `json:"receiver"`
TransactionDate time.Time `json:"transaction_date"`
CodeNumber string `json:"code_number"`
Status string `json:"status"`
Description *string `json:"description"`
Tax float64 `json:"tax"`
Total float64 `json:"total"`
Reserved1 *string `json:"reserved1"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ExpenseItem struct {
ID uuid.UUID `json:"id"`
ExpenseID uuid.UUID `json:"expense_id"`
ChartOfAccountID uuid.UUID `json:"chart_of_account_id"`
PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
Item string `json:"item"`
Description *string `json:"description"`
Amount float64 `json:"amount"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ExpenseResponse struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
OutletID uuid.UUID `json:"outlet_id"`
Receiver string `json:"receiver"`
TransactionDate time.Time `json:"transaction_date"`
CodeNumber string `json:"code_number"`
Status string `json:"status"`
Description *string `json:"description"`
Tax float64 `json:"tax"`
Total float64 `json:"total"`
Reserved1 *string `json:"reserved1"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Items []ExpenseItemResponse `json:"items,omitempty"`
}
type ExpenseItemResponse struct {
ID uuid.UUID `json:"id"`
ExpenseID uuid.UUID `json:"expense_id"`
ChartOfAccountID uuid.UUID `json:"chart_of_account_id"`
ChartOfAccountName string `json:"chart_of_account_name,omitempty"`
PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
PurchaseCategoryName string `json:"purchase_category_name,omitempty"`
PurchaseCategoryType string `json:"purchase_category_type,omitempty"`
PurchaseCategory *PurchaseCategoryResponse `json:"purchase_category,omitempty"`
Item string `json:"item"`
Description *string `json:"description"`
Amount float64 `json:"amount"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type CreateExpenseRequest struct {
Receiver string `json:"receiver"`
TransactionDate string `json:"transaction_date"`
CodeNumber string `json:"code_number"`
OutletID string `json:"outlet_id"`
Status *string `json:"status,omitempty"`
Description *string `json:"description"`
Tax float64 `json:"tax"`
Total float64 `json:"total"`
Items []CreateExpenseItemRequest `json:"items"`
}
type CreateExpenseItemRequest struct {
ChartOfAccountID string `json:"chart_of_account_id"`
PurchaseCategoryID string `json:"purchase_category_id"`
Item string `json:"item"`
Description *string `json:"description,omitempty"`
Amount float64 `json:"amount"`
}
type UpdateExpenseRequest struct {
Receiver *string `json:"receiver,omitempty"`
TransactionDate *string `json:"transaction_date,omitempty"`
CodeNumber *string `json:"code_number,omitempty"`
OutletID *string `json:"outlet_id,omitempty"`
Status *string `json:"status,omitempty"`
Description *string `json:"description,omitempty"`
Tax *float64 `json:"tax,omitempty"`
Total *float64 `json:"total,omitempty"`
Reserved1 *string `json:"reserved1,omitempty"`
Items []UpdateExpenseItemRequest `json:"items,omitempty"`
}
type UpdateExpenseItemRequest struct {
ChartOfAccountID *string `json:"chart_of_account_id,omitempty"`
PurchaseCategoryID *string `json:"purchase_category_id,omitempty"`
Item *string `json:"item,omitempty"`
Description *string `json:"description,omitempty"`
Amount *float64 `json:"amount,omitempty"`
}
type ListExpenseRequest struct {
Page int `json:"page"`
Limit int `json:"limit"`
Search string `json:"search,omitempty"`
OutletID string `json:"outlet_id,omitempty"`
Status string `json:"status,omitempty"`
StartDate string `json:"start_date,omitempty"`
EndDate string `json:"end_date,omitempty"`
}
type ListExpenseResponse struct {
Expenses []*ExpenseResponse `json:"expenses"`
TotalCount int `json:"total_count"`
Page int `json:"page"`
Limit int `json:"limit"`
TotalPages int `json:"total_pages"`
}
type ExpenseAnalyticsRequest struct {
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"`
GroupBy string `json:"group_by"`
}
type ExpenseAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"`
GroupBy string `json:"group_by"`
Summary ExpenseAnalyticsSummary `json:"summary"`
Data []ExpenseAnalyticsData `json:"data"`
CategoryData []ExpenseAnalyticsCategoryData `json:"category_data"`
ChartOfAccountData []ExpenseAnalyticsChartOfAccountData `json:"chart_of_account_data"`
ItemData []ExpenseAnalyticsItemData `json:"item_data"`
}
type ExpenseAnalyticsSummary struct {
TotalExpenses float64 `json:"total_expenses"`
TotalExpenseCount int64 `json:"total_expense_count"`
TotalTax float64 `json:"total_tax"`
AverageExpenseValue float64 `json:"average_expense_value"`
TotalCategories int64 `json:"total_categories"`
TotalItems int64 `json:"total_items"`
}
type ExpenseAnalyticsData struct {
Date time.Time `json:"date"`
Expenses float64 `json:"expenses"`
ExpenseCount int64 `json:"expense_count"`
Tax float64 `json:"tax"`
Items int64 `json:"items"`
Categories int64 `json:"categories"`
}
type ExpenseAnalyticsCategoryData struct {
PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
PurchaseCategoryName string `json:"purchase_category_name"`
PurchaseCategoryType string `json:"purchase_category_type"`
TotalAmount float64 `json:"total_amount"`
ExpenseCount int64 `json:"expense_count"`
ItemCount int64 `json:"item_count"`
}
type ExpenseAnalyticsChartOfAccountData struct {
ChartOfAccountID uuid.UUID `json:"chart_of_account_id"`
ChartOfAccountName string `json:"chart_of_account_name"`
TotalAmount float64 `json:"total_amount"`
ExpenseCount int64 `json:"expense_count"`
ItemCount int64 `json:"item_count"`
}
type ExpenseAnalyticsItemData struct {
Item string `json:"item"`
TotalAmount float64 `json:"total_amount"`
ExpenseCount int64 `json:"expense_count"`
ItemCount int64 `json:"item_count"`
}

View File

@ -101,3 +101,4 @@ type IngredientUnitsResponse struct {
BaseUnitName string `json:"base_unit_name"` BaseUnitName string `json:"base_unit_name"`
Units []*UnitResponse `json:"units"` Units []*UnitResponse `json:"units"`
} }

View File

@ -1,118 +0,0 @@
package models
import (
"time"
"apskel-pos-be/internal/entities"
"github.com/google/uuid"
)
// ---- Request models ----
type SendNotificationRequest struct {
Title string `json:"title"`
Body string `json:"body"`
Type string `json:"type"`
Category string `json:"category"`
Priority entities.NotificationPriority `json:"priority"`
ImageURL string `json:"image_url"`
ActionURL string `json:"action_url"`
NotifiableType string `json:"notifiable_type"`
NotifiableID *uuid.UUID `json:"notifiable_id"`
Data map[string]interface{} `json:"data"`
ReceiverIDs []uuid.UUID `json:"receiver_ids"`
ScheduledAt *time.Time `json:"scheduled_at"`
ExpiredAt *time.Time `json:"expired_at"`
CreatedBy *uuid.UUID `json:"created_by"`
}
type BroadcastNotificationRequest struct {
Title string `json:"title"`
Body string `json:"body"`
Type string `json:"type"`
Category string `json:"category"`
Priority entities.NotificationPriority `json:"priority"`
ImageURL string `json:"image_url"`
ActionURL string `json:"action_url"`
NotifiableType string `json:"notifiable_type"`
NotifiableID *uuid.UUID `json:"notifiable_id"`
Data map[string]interface{} `json:"data"`
OrganizationID uuid.UUID `json:"organization_id"`
ScheduledAt *time.Time `json:"scheduled_at"`
ExpiredAt *time.Time `json:"expired_at"`
CreatedBy *uuid.UUID `json:"created_by"`
}
type MarkNotificationReadRequest struct {
NotificationReceiverID uuid.UUID `json:"notification_receiver_id"`
UserID uuid.UUID `json:"user_id"`
}
type ListNotificationsRequest struct {
Page int `json:"page"`
Limit int `json:"limit"`
UserID uuid.UUID `json:"user_id"`
IsRead *bool `json:"is_read"`
}
// ---- Response models ----
type NotificationResponse struct {
ID uuid.UUID `json:"id"`
Title string `json:"title"`
Body string `json:"body"`
Type string `json:"type"`
Category string `json:"category"`
Priority entities.NotificationPriority `json:"priority"`
ImageURL string `json:"image_url"`
ActionURL string `json:"action_url"`
NotifiableType string `json:"notifiable_type"`
NotifiableID *uuid.UUID `json:"notifiable_id"`
Data map[string]interface{} `json:"data"`
ScheduledAt *time.Time `json:"scheduled_at"`
SentAt *time.Time `json:"sent_at"`
ExpiredAt *time.Time `json:"expired_at"`
CreatedBy *uuid.UUID `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type NotificationReceiverResponse struct {
ID uuid.UUID `json:"id"`
NotificationID uuid.UUID `json:"notification_id"`
UserID uuid.UUID `json:"user_id"`
IsRead bool `json:"is_read"`
ReadAt *time.Time `json:"read_at"`
IsDeleted bool `json:"is_deleted"`
DeletedAt *time.Time `json:"deleted_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Notification *NotificationResponse `json:"notification,omitempty"`
}
type NotificationDeliveryResponse struct {
ID uuid.UUID `json:"id"`
NotificationReceiverID uuid.UUID `json:"notification_receiver_id"`
UserDeviceID uuid.UUID `json:"user_device_id"`
Channel entities.NotificationChannel `json:"channel"`
DeliveryStatus entities.NotificationDeliveryStatus `json:"delivery_status"`
Provider entities.NotificationProvider `json:"provider"`
ProviderMessageID string `json:"provider_message_id"`
SentAt *time.Time `json:"sent_at"`
DeliveredAt *time.Time `json:"delivered_at"`
FailedAt *time.Time `json:"failed_at"`
FailureReason string `json:"failure_reason"`
RetryCount int `json:"retry_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ListNotificationsResponse struct {
Notifications []*NotificationReceiverResponse `json:"notifications"`
TotalCount int `json:"total_count"`
UnreadCount int `json:"unread_count"`
Page int `json:"page"`
Limit int `json:"limit"`
TotalPages int `json:"total_pages"`
}

View File

@ -188,8 +188,6 @@ type OrderItemResponse struct {
ProductName string ProductName string
ProductVariantID *uuid.UUID ProductVariantID *uuid.UUID
ProductVariantName *string ProductVariantName *string
CategoryID *uuid.UUID
CategoryName *string
Quantity int Quantity int
UnitPrice float64 UnitPrice float64
TotalPrice float64 TotalPrice float64
@ -209,7 +207,6 @@ type OrderItemResponse struct {
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
PrinterType string PrinterType string
PrintToChecker bool
PaidQuantity int PaidQuantity int
} }

View File

@ -40,7 +40,6 @@ type ProductVariant struct {
type CreateProductRequest struct { type CreateProductRequest struct {
OrganizationID uuid.UUID `validate:"required"` OrganizationID uuid.UUID `validate:"required"`
OutletID uuid.UUID `validate:"omitempty"` // If set, upsert product_outlet_prices on create
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"`
@ -50,7 +49,6 @@ type CreateProductRequest struct {
BusinessType constants.BusinessType `validate:"required"` BusinessType constants.BusinessType `validate:"required"`
ImageURL *string `validate:"omitempty,max=500"` ImageURL *string `validate:"omitempty,max=500"`
PrinterType *string `validate:"omitempty,max=50"` PrinterType *string `validate:"omitempty,max=50"`
PrintToChecker *bool `validate:"omitempty"`
UnitID *uuid.UUID `validate:"omitempty"` UnitID *uuid.UUID `validate:"omitempty"`
HasIngredients bool `validate:"omitempty"` HasIngredients bool `validate:"omitempty"`
Metadata map[string]interface{} Metadata map[string]interface{}
@ -62,7 +60,6 @@ type CreateProductRequest struct {
} }
type UpdateProductRequest struct { type UpdateProductRequest struct {
OutletID uuid.UUID `validate:"omitempty"` // If set, upsert product_outlet_prices on update
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"`
@ -71,7 +68,6 @@ type UpdateProductRequest struct {
Cost *float64 `validate:"omitempty,min=0"` Cost *float64 `validate:"omitempty,min=0"`
ImageURL *string `validate:"omitempty,max=500"` ImageURL *string `validate:"omitempty,max=500"`
PrinterType *string `validate:"omitempty,max=50"` PrinterType *string `validate:"omitempty,max=50"`
PrintToChecker *bool `validate:"omitempty"`
UnitID *uuid.UUID `validate:"omitempty"` UnitID *uuid.UUID `validate:"omitempty"`
HasIngredients *bool `validate:"omitempty"` HasIngredients *bool `validate:"omitempty"`
Metadata map[string]interface{} Metadata map[string]interface{}
@ -104,13 +100,10 @@ type ProductResponse struct {
Name string Name string
Description *string Description *string
Price float64 Price float64
OutletPrice *float64 // outlet-specific price, nil if not set
OutletPrices []OutletPrice // all outlet prices, populated when no outletID in context
Cost float64 Cost float64
BusinessType constants.BusinessType BusinessType constants.BusinessType
ImageURL *string ImageURL *string
PrinterType string PrinterType string
PrintToChecker bool
UnitID *uuid.UUID UnitID *uuid.UUID
HasIngredients bool HasIngredients bool
Metadata map[string]interface{} Metadata map[string]interface{}
@ -120,13 +113,6 @@ type ProductResponse struct {
Variants []ProductVariantResponse Variants []ProductVariantResponse
} }
type OutletPrice struct {
OutletID uuid.UUID
OutletName string
Price float64
PrintToChecker bool
}
type ProductVariantResponse struct { type ProductVariantResponse struct {
ID uuid.UUID ID uuid.UUID
ProductID uuid.UUID ProductID uuid.UUID

View File

@ -1,38 +0,0 @@
package models
import (
"time"
"github.com/google/uuid"
)
type ProductOutletPrice struct {
ID uuid.UUID
ProductID uuid.UUID
OutletID uuid.UUID
Price float64
PrintToChecker bool
CreatedAt time.Time
UpdatedAt time.Time
}
type CreateProductOutletPriceRequest struct {
ProductID uuid.UUID `validate:"required"`
OutletID uuid.UUID `validate:"required"`
Price float64 `validate:"required,min=0"`
PrintToChecker bool
}
type UpdateProductOutletPriceRequest struct {
Price *float64 `validate:"required,min=0"`
PrintToChecker *bool
}
type ProductOutletPriceResponse struct {
ID uuid.UUID `json:"id"`
ProductID uuid.UUID `json:"product_id"`
OutletID uuid.UUID `json:"outlet_id"`
Price float64 `json:"price"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

View File

@ -1,51 +0,0 @@
package models
import (
"time"
"github.com/google/uuid"
)
type PurchaseCategoryResponse struct {
ID uuid.UUID
OrganizationID uuid.UUID
PresetID *uuid.UUID
ParentID *uuid.UUID
Code string
Name string
Type string
SortOrder int
IsSystem bool
IsActive bool
CreatedAt time.Time
UpdatedAt time.Time
}
type CreatePurchaseCategoryRequest struct {
OrganizationID uuid.UUID
ParentID *uuid.UUID
Code *string
Name string
Type string
SortOrder int
IsActive bool
}
type UpdatePurchaseCategoryRequest struct {
ParentID *uuid.UUID
Code *string
Name *string
Type *string
SortOrder *int
IsActive *bool
}
type ListPurchaseCategoriesRequest struct {
OrganizationID uuid.UUID
ParentID *uuid.UUID
Type string
Search string
IsActive *bool
Page int
Limit int
}

View File

@ -9,11 +9,10 @@ import (
type PurchaseOrder struct { type PurchaseOrder 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"` VendorID uuid.UUID `json:"vendor_id"`
VendorID *uuid.UUID `json:"vendor_id"`
PONumber string `json:"po_number"` PONumber string `json:"po_number"`
TransactionDate time.Time `json:"transaction_date"` TransactionDate time.Time `json:"transaction_date"`
DueDate *time.Time `json:"due_date"` DueDate time.Time `json:"due_date"`
Reference *string `json:"reference"` Reference *string `json:"reference"`
Status string `json:"status"` Status string `json:"status"`
Message *string `json:"message"` Message *string `json:"message"`
@ -25,11 +24,10 @@ type PurchaseOrder struct {
type PurchaseOrderItem struct { type PurchaseOrderItem struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
PurchaseOrderID uuid.UUID `json:"purchase_order_id"` PurchaseOrderID uuid.UUID `json:"purchase_order_id"`
IngredientID *uuid.UUID `json:"ingredient_id"` IngredientID uuid.UUID `json:"ingredient_id"`
PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
Description *string `json:"description"` Description *string `json:"description"`
Quantity *float64 `json:"quantity"` Quantity float64 `json:"quantity"`
UnitID *uuid.UUID `json:"unit_id"` UnitID uuid.UUID `json:"unit_id"`
Amount float64 `json:"amount"` Amount float64 `json:"amount"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
@ -45,11 +43,10 @@ type PurchaseOrderAttachment struct {
type PurchaseOrderResponse struct { type PurchaseOrderResponse 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"` VendorID uuid.UUID `json:"vendor_id"`
VendorID *uuid.UUID `json:"vendor_id"`
PONumber string `json:"po_number"` PONumber string `json:"po_number"`
TransactionDate time.Time `json:"transaction_date"` TransactionDate time.Time `json:"transaction_date"`
DueDate *time.Time `json:"due_date"` DueDate time.Time `json:"due_date"`
Reference *string `json:"reference"` Reference *string `json:"reference"`
Status string `json:"status"` Status string `json:"status"`
Message *string `json:"message"` Message *string `json:"message"`
@ -64,16 +61,14 @@ type PurchaseOrderResponse struct {
type PurchaseOrderItemResponse struct { type PurchaseOrderItemResponse struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
PurchaseOrderID uuid.UUID `json:"purchase_order_id"` PurchaseOrderID uuid.UUID `json:"purchase_order_id"`
IngredientID *uuid.UUID `json:"ingredient_id"` IngredientID uuid.UUID `json:"ingredient_id"`
PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
Description *string `json:"description"` Description *string `json:"description"`
Quantity *float64 `json:"quantity"` Quantity float64 `json:"quantity"`
UnitID *uuid.UUID `json:"unit_id"` UnitID uuid.UUID `json:"unit_id"`
Amount float64 `json:"amount"` Amount float64 `json:"amount"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
Ingredient *IngredientResponse `json:"ingredient,omitempty"` Ingredient *IngredientResponse `json:"ingredient,omitempty"`
PurchaseCategory *PurchaseCategoryResponse `json:"purchase_category,omitempty"`
Unit *UnitResponse `json:"unit,omitempty"` Unit *UnitResponse `json:"unit,omitempty"`
} }
@ -86,11 +81,10 @@ type PurchaseOrderAttachmentResponse struct {
} }
type CreatePurchaseOrderRequest struct { type CreatePurchaseOrderRequest struct {
VendorID *uuid.UUID `json:"vendor_id,omitempty"` VendorID uuid.UUID `json:"vendor_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
PONumber string `json:"po_number"` PONumber string `json:"po_number"`
TransactionDate time.Time `json:"transaction_date"` TransactionDate time.Time `json:"transaction_date"`
DueDate *time.Time `json:"due_date,omitempty"` DueDate time.Time `json:"due_date"`
Reference *string `json:"reference,omitempty"` Reference *string `json:"reference,omitempty"`
Status *string `json:"status,omitempty"` Status *string `json:"status,omitempty"`
Message *string `json:"message,omitempty"` Message *string `json:"message,omitempty"`
@ -99,11 +93,10 @@ type CreatePurchaseOrderRequest struct {
} }
type CreatePurchaseOrderItemRequest struct { type CreatePurchaseOrderItemRequest struct {
IngredientID *uuid.UUID `json:"ingredient_id,omitempty"` IngredientID uuid.UUID `json:"ingredient_id"`
PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`
Quantity *float64 `json:"quantity,omitempty"` Quantity float64 `json:"quantity"`
UnitID *uuid.UUID `json:"unit_id,omitempty"` UnitID uuid.UUID `json:"unit_id"`
Amount float64 `json:"amount"` Amount float64 `json:"amount"`
} }
@ -122,7 +115,6 @@ type UpdatePurchaseOrderRequest struct {
type UpdatePurchaseOrderItemRequest struct { type UpdatePurchaseOrderItemRequest struct {
ID *uuid.UUID `json:"id,omitempty"` // For existing items ID *uuid.UUID `json:"id,omitempty"` // For existing items
IngredientID *uuid.UUID `json:"ingredient_id,omitempty"` IngredientID *uuid.UUID `json:"ingredient_id,omitempty"`
PurchaseCategoryID *uuid.UUID `json:"purchase_category_id,omitempty"`
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`
Quantity *float64 `json:"quantity,omitempty"` Quantity *float64 `json:"quantity,omitempty"`
UnitID *uuid.UUID `json:"unit_id,omitempty"` UnitID *uuid.UUID `json:"unit_id,omitempty"`

View File

@ -65,10 +65,8 @@ func (u *User) HasPermission(requiredRole constants.UserRole) bool {
roleHierarchy := map[constants.UserRole]int{ roleHierarchy := map[constants.UserRole]int{
constants.RoleWaiter: 1, constants.RoleWaiter: 1,
constants.RoleCashier: 2, constants.RoleCashier: 2,
constants.RolePurchasing: 3, constants.RoleManager: 3,
constants.RoleManager: 4, constants.RoleAdmin: 4,
constants.RoleAdmin: 5,
constants.RoleOwner: 6,
} }
userLevel := roleHierarchy[u.Role] userLevel := roleHierarchy[u.Role]

View File

@ -1,77 +0,0 @@
package models
import (
"time"
"apskel-pos-be/internal/entities"
"github.com/google/uuid"
)
type UserDevice struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
DeviceID string `json:"device_id"`
DeviceName string `json:"device_name"`
DeviceType entities.DeviceType `json:"device_type"`
Platform entities.DevicePlatform `json:"platform"`
FCMToken string `json:"fcm_token"`
AppVersion string `json:"app_version"`
OsVersion string `json:"os_version"`
IPAddress string `json:"ip_address"`
LastActiveAt *time.Time `json:"last_active_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type UserDeviceResponse struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
DeviceID string `json:"device_id"`
DeviceName string `json:"device_name"`
DeviceType entities.DeviceType `json:"device_type"`
Platform entities.DevicePlatform `json:"platform"`
FCMToken string `json:"fcm_token"`
AppVersion string `json:"app_version"`
OsVersion string `json:"os_version"`
IPAddress string `json:"ip_address"`
LastActiveAt *time.Time `json:"last_active_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type RegisterUserDeviceRequest struct {
UserID uuid.UUID `json:"user_id"`
DeviceID string `json:"device_id"`
DeviceName string `json:"device_name"`
DeviceType entities.DeviceType `json:"device_type"`
Platform entities.DevicePlatform `json:"platform"`
FCMToken string `json:"fcm_token"`
AppVersion string `json:"app_version"`
OsVersion string `json:"os_version"`
IPAddress string `json:"ip_address"`
}
type UpdateUserDeviceRequest struct {
DeviceName string `json:"device_name"`
DeviceType entities.DeviceType `json:"device_type"`
Platform entities.DevicePlatform `json:"platform"`
FCMToken string `json:"fcm_token"`
AppVersion string `json:"app_version"`
OsVersion string `json:"os_version"`
}
type ListUserDevicesRequest struct {
Page int `json:"page"`
Limit int `json:"limit"`
UserID string `json:"user_id,omitempty"`
Platform string `json:"platform,omitempty"`
}
type ListUserDevicesResponse struct {
Devices []*UserDeviceResponse `json:"devices"`
TotalCount int `json:"total_count"`
Page int `json:"page"`
Limit int `json:"limit"`
TotalPages int `json:"total_pages"`
}

View File

@ -3,53 +3,31 @@ package processor
import ( import (
"context" "context"
"fmt" "fmt"
"strings"
"time" "time"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models" "apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository" "apskel-pos-be/internal/repository"
"github.com/google/uuid"
) )
type AnalyticsProcessor interface { type AnalyticsProcessor interface {
GetPaymentMethodAnalytics(ctx context.Context, req *models.PaymentMethodAnalyticsRequest) (*models.PaymentMethodAnalyticsResponse, error) GetPaymentMethodAnalytics(ctx context.Context, req *models.PaymentMethodAnalyticsRequest) (*models.PaymentMethodAnalyticsResponse, error)
GetSalesAnalytics(ctx context.Context, req *models.SalesAnalyticsRequest) (*models.SalesAnalyticsResponse, error) GetSalesAnalytics(ctx context.Context, req *models.SalesAnalyticsRequest) (*models.SalesAnalyticsResponse, error)
GetPurchasingAnalytics(ctx context.Context, req *models.PurchasingAnalyticsRequest) (*models.PurchasingAnalyticsResponse, error)
GetProductAnalytics(ctx context.Context, req *models.ProductAnalyticsRequest) (*models.ProductAnalyticsResponse, error) GetProductAnalytics(ctx context.Context, req *models.ProductAnalyticsRequest) (*models.ProductAnalyticsResponse, error)
GetProductAnalyticsPerCategory(ctx context.Context, req *models.ProductAnalyticsPerCategoryRequest) (*models.ProductAnalyticsPerCategoryResponse, error) GetProductAnalyticsPerCategory(ctx context.Context, req *models.ProductAnalyticsPerCategoryRequest) (*models.ProductAnalyticsPerCategoryResponse, error)
GetDashboardAnalytics(ctx context.Context, req *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error) GetDashboardAnalytics(ctx context.Context, req *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error)
GetProfitLossAnalytics(ctx context.Context, req *models.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsResponse, error) GetProfitLossAnalytics(ctx context.Context, req *models.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsResponse, error)
GetExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error)
GetExclusiveSummaryMonthly(ctx context.Context, req *models.ExclusiveSummaryMonthlyRequest) (*models.ExclusiveSummaryMonthlyResponse, error)
GetExclusiveSummaryMTD(ctx context.Context, req *models.ExclusiveSummaryMTDRequest) (*models.ExclusiveSummaryPeriodResponse, error)
} }
type AnalyticsProcessorImpl struct { type AnalyticsProcessorImpl struct {
analyticsRepo repository.AnalyticsRepository analyticsRepo repository.AnalyticsRepository
expenseRepo ExpenseRepository
} }
func NewAnalyticsProcessorImpl(analyticsRepo repository.AnalyticsRepository, expenseRepo ExpenseRepository) *AnalyticsProcessorImpl { func NewAnalyticsProcessorImpl(analyticsRepo repository.AnalyticsRepository) *AnalyticsProcessorImpl {
return &AnalyticsProcessorImpl{ return &AnalyticsProcessorImpl{
analyticsRepo: analyticsRepo, analyticsRepo: analyticsRepo,
expenseRepo: expenseRepo,
} }
} }
// resolveOutletName fetches the outlet name from the database if outletID is provided
func (p *AnalyticsProcessorImpl) resolveOutletName(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) *string {
if outletID == nil {
return nil
}
name, err := p.analyticsRepo.GetOutletName(ctx, organizationID, *outletID)
if err != nil || name == "" {
return nil
}
return &name
}
func (p *AnalyticsProcessorImpl) GetPaymentMethodAnalytics(ctx context.Context, req *models.PaymentMethodAnalyticsRequest) (*models.PaymentMethodAnalyticsResponse, error) { func (p *AnalyticsProcessorImpl) GetPaymentMethodAnalytics(ctx context.Context, req *models.PaymentMethodAnalyticsRequest) (*models.PaymentMethodAnalyticsResponse, error) {
if req.DateFrom.After(req.DateTo) { if req.DateFrom.After(req.DateTo) {
return nil, fmt.Errorf("date_from cannot be after date_to") return nil, fmt.Errorf("date_from cannot be after date_to")
@ -104,7 +82,6 @@ func (p *AnalyticsProcessorImpl) GetPaymentMethodAnalytics(ctx context.Context,
return &models.PaymentMethodAnalyticsResponse{ return &models.PaymentMethodAnalyticsResponse{
OrganizationID: req.OrganizationID, OrganizationID: req.OrganizationID,
OutletID: req.OutletID, OutletID: req.OutletID,
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
DateFrom: req.DateFrom, DateFrom: req.DateFrom,
DateTo: req.DateTo, DateTo: req.DateTo,
GroupBy: req.GroupBy, GroupBy: req.GroupBy,
@ -179,7 +156,6 @@ func (p *AnalyticsProcessorImpl) GetSalesAnalytics(ctx context.Context, req *mod
return &models.SalesAnalyticsResponse{ return &models.SalesAnalyticsResponse{
OrganizationID: req.OrganizationID, OrganizationID: req.OrganizationID,
OutletID: req.OutletID, OutletID: req.OutletID,
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
DateFrom: req.DateFrom, DateFrom: req.DateFrom,
DateTo: req.DateTo, DateTo: req.DateTo,
GroupBy: req.GroupBy, GroupBy: req.GroupBy,
@ -188,85 +164,6 @@ func (p *AnalyticsProcessorImpl) GetSalesAnalytics(ctx context.Context, req *mod
}, nil }, nil
} }
func (p *AnalyticsProcessorImpl) GetPurchasingAnalytics(ctx context.Context, req *models.PurchasingAnalyticsRequest) (*models.PurchasingAnalyticsResponse, error) {
if req.DateFrom.After(req.DateTo) {
return nil, fmt.Errorf("date_from cannot be after date_to")
}
if req.GroupBy == "" {
req.GroupBy = "day"
}
result, err := p.analyticsRepo.GetPurchasingAnalytics(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo, req.GroupBy)
if err != nil {
return nil, fmt.Errorf("failed to get purchasing analytics: %w", err)
}
data := make([]models.PurchasingAnalyticsData, len(result.Data))
for i, item := range result.Data {
data[i] = models.PurchasingAnalyticsData{
Date: item.Date,
Purchases: item.Purchases,
RawMaterialPurchases: item.RawMaterialPurchases,
ExpensePurchases: item.ExpensePurchases,
PurchaseOrders: item.PurchaseOrders,
RawMaterialPurchaseOrders: item.RawMaterialPurchaseOrders,
ExpenseCount: item.ExpenseCount,
Quantity: item.Quantity,
Ingredients: item.Ingredients,
Vendors: item.Vendors,
}
}
ingredientData := make([]models.PurchasingIngredientData, len(result.IngredientData))
for i, item := range result.IngredientData {
ingredientData[i] = models.PurchasingIngredientData{
IngredientID: item.IngredientID,
IngredientName: item.IngredientName,
Quantity: item.Quantity,
TotalCost: item.TotalCost,
AverageUnitCost: item.AverageUnitCost,
PurchaseOrderCount: item.PurchaseOrderCount,
}
}
vendorData := make([]models.PurchasingVendorData, len(result.VendorData))
for i, item := range result.VendorData {
vendorData[i] = models.PurchasingVendorData{
VendorID: item.VendorID,
VendorName: item.VendorName,
TotalCost: item.TotalCost,
PurchaseOrderCount: item.PurchaseOrderCount,
IngredientCount: item.IngredientCount,
Quantity: item.Quantity,
}
}
return &models.PurchasingAnalyticsResponse{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
OutletName: result.OutletName,
DateFrom: req.DateFrom,
DateTo: req.DateTo,
GroupBy: req.GroupBy,
Summary: models.PurchasingSummary{
TotalPurchases: result.Summary.TotalPurchases,
RawMaterialPurchases: result.Summary.RawMaterialPurchases,
ExpensePurchases: result.Summary.ExpensePurchases,
TotalPurchaseOrders: result.Summary.TotalPurchaseOrders,
RawMaterialPurchaseOrders: result.Summary.RawMaterialPurchaseOrders,
ExpenseCount: result.Summary.ExpenseCount,
TotalQuantity: result.Summary.TotalQuantity,
AveragePurchaseOrderValue: result.Summary.AveragePurchaseOrderValue,
TotalIngredients: result.Summary.TotalIngredients,
TotalVendors: result.Summary.TotalVendors,
},
Data: data,
IngredientData: ingredientData,
VendorData: vendorData,
}, nil
}
func (p *AnalyticsProcessorImpl) GetProductAnalytics(ctx context.Context, req *models.ProductAnalyticsRequest) (*models.ProductAnalyticsResponse, error) { func (p *AnalyticsProcessorImpl) GetProductAnalytics(ctx context.Context, req *models.ProductAnalyticsRequest) (*models.ProductAnalyticsResponse, error) {
// Validate date range // Validate date range
if req.DateFrom.After(req.DateTo) { if req.DateFrom.After(req.DateTo) {
@ -291,7 +188,6 @@ func (p *AnalyticsProcessorImpl) GetProductAnalytics(ctx context.Context, req *m
ProductID: data.ProductID, ProductID: data.ProductID,
ProductName: data.ProductName, ProductName: data.ProductName,
ProductSku: data.ProductSku, ProductSku: data.ProductSku,
ProductPrice: data.ProductPrice,
CategoryID: data.CategoryID, CategoryID: data.CategoryID,
CategoryName: data.CategoryName, CategoryName: data.CategoryName,
CategoryOrder: data.CategoryOrder, CategoryOrder: data.CategoryOrder,
@ -311,7 +207,6 @@ func (p *AnalyticsProcessorImpl) GetProductAnalytics(ctx context.Context, req *m
return &models.ProductAnalyticsResponse{ return &models.ProductAnalyticsResponse{
OrganizationID: req.OrganizationID, OrganizationID: req.OrganizationID,
OutletID: req.OutletID, OutletID: req.OutletID,
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
DateFrom: req.DateFrom, DateFrom: req.DateFrom,
DateTo: req.DateTo, DateTo: req.DateTo,
Data: resultData, Data: resultData,
@ -349,7 +244,6 @@ func (p *AnalyticsProcessorImpl) GetProductAnalyticsPerCategory(ctx context.Cont
return &models.ProductAnalyticsPerCategoryResponse{ return &models.ProductAnalyticsPerCategoryResponse{
OrganizationID: req.OrganizationID, OrganizationID: req.OrganizationID,
OutletID: req.OutletID, OutletID: req.OutletID,
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
DateFrom: req.DateFrom, DateFrom: req.DateFrom,
DateTo: req.DateTo, DateTo: req.DateTo,
Data: resultData, Data: resultData,
@ -411,7 +305,6 @@ func (p *AnalyticsProcessorImpl) GetDashboardAnalytics(ctx context.Context, req
return &models.DashboardAnalyticsResponse{ return &models.DashboardAnalyticsResponse{
OrganizationID: req.OrganizationID, OrganizationID: req.OrganizationID,
OutletID: req.OutletID, OutletID: req.OutletID,
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
DateFrom: req.DateFrom, DateFrom: req.DateFrom,
DateTo: req.DateTo, DateTo: req.DateTo,
Overview: models.DashboardOverview{ Overview: models.DashboardOverview{
@ -421,9 +314,6 @@ func (p *AnalyticsProcessorImpl) GetDashboardAnalytics(ctx context.Context, req
TotalCustomers: overview.TotalCustomers, TotalCustomers: overview.TotalCustomers,
VoidedOrders: overview.VoidedOrders, VoidedOrders: overview.VoidedOrders,
RefundedOrders: overview.RefundedOrders, RefundedOrders: overview.RefundedOrders,
TotalItemSold: overview.TotalItemSold,
TotalLowStock: overview.TotalLowStock,
TotalProductActive: overview.TotalProductActive,
}, },
TopProducts: topProducts.Data, TopProducts: topProducts.Data,
PaymentMethods: paymentMethods.Data, PaymentMethods: paymentMethods.Data,
@ -432,27 +322,17 @@ func (p *AnalyticsProcessorImpl) GetDashboardAnalytics(ctx context.Context, req
} }
func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req *models.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsResponse, error) { func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req *models.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsResponse, error) {
if req.DateFrom.IsZero() {
return nil, fmt.Errorf("date_from is required")
}
if req.DateTo.IsZero() {
return nil, fmt.Errorf("date_to is required")
}
if req.DateFrom.After(req.DateTo) { if req.DateFrom.After(req.DateTo) {
return nil, fmt.Errorf("date_from cannot be after date_to") return nil, fmt.Errorf("date_from cannot be after date_to")
} }
if req.GroupBy == "" { // Get analytics data from repository
req.GroupBy = "day"
}
result, err := p.analyticsRepo.GetProfitLossAnalytics(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo, req.GroupBy) result, err := p.analyticsRepo.GetProfitLossAnalytics(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo, req.GroupBy)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get profit/loss analytics: %w", err) return nil, fmt.Errorf("failed to get profit/loss analytics: %w", err)
} }
// Transform entities to models
data := make([]models.ProfitLossData, len(result.Data)) data := make([]models.ProfitLossData, len(result.Data))
for i, item := range result.Data { for i, item := range result.Data {
data[i] = models.ProfitLossData{ data[i] = models.ProfitLossData{
@ -487,159 +367,9 @@ func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req
} }
} }
type categoryAmount struct {
Name string
TodayAmt float64
MtdAmt float64
}
categoryMap := make(map[string]*categoryAmount)
var categoryOrder []string
for _, cat := range result.TodayExpenseByCategory {
name := cat.CategoryName
if _, exists := categoryMap[name]; !exists {
categoryMap[name] = &categoryAmount{Name: name}
categoryOrder = append(categoryOrder, name)
}
categoryMap[name].TodayAmt = cat.Amount
}
for _, cat := range result.MtdExpenseByCategory {
name := cat.CategoryName
if _, exists := categoryMap[name]; !exists {
categoryMap[name] = &categoryAmount{Name: name}
categoryOrder = append(categoryOrder, name)
}
categoryMap[name].MtdAmt = cat.Amount
}
var todayTotalOps float64
var mtdTotalOps float64
var todayGaji float64
var mtdGaji float64
for _, cat := range categoryMap {
if isSalaryExpenseCategory(cat.Name) {
todayGaji += cat.TodayAmt
mtdGaji += cat.MtdAmt
continue
}
todayTotalOps += cat.TodayAmt
mtdTotalOps += cat.MtdAmt
}
todayGrossProfit := result.TodayRevenue - result.TodayCost
mtdGrossProfit := result.MtdRevenue - result.MtdCost
todayProfitBeforeGaji := todayGrossProfit - todayTotalOps
mtdProfitBeforeGaji := mtdGrossProfit - mtdTotalOps
todayNetProfit := todayProfitBeforeGaji - todayGaji
mtdNetProfit := mtdProfitBeforeGaji - mtdGaji
todayPct := func(nominal float64) float64 {
if result.TodayRevenue == 0 {
return 0
}
return (nominal / result.TodayRevenue) * 100
}
mtdPct := func(nominal float64) float64 {
if result.MtdRevenue == 0 {
return 0
}
return (nominal / result.MtdRevenue) * 100
}
opsSubItems := make([]models.ProfitLossSummaryRow, 0, len(categoryOrder)+1)
opsCategoryCount := 0
for _, name := range categoryOrder {
cat := categoryMap[name]
if isSalaryExpenseCategory(cat.Name) {
continue
}
opsCategoryCount++
opsSubItems = append(opsSubItems, models.ProfitLossSummaryRow{
ID: fmt.Sprintf("by_%s", slugify(name)),
Label: fmt.Sprintf("%d. %s", opsCategoryCount, cat.Name),
TodayNominal: cat.TodayAmt,
TodayPct: todayPct(cat.TodayAmt),
MtdNominal: cat.MtdAmt,
MtdPct: mtdPct(cat.MtdAmt),
})
}
opsSubItems = append(opsSubItems, models.ProfitLossSummaryRow{
ID: "total_biaya_ops",
Label: fmt.Sprintf("Total Biaya OPS (%d kategori)", opsCategoryCount),
IsBold: true,
TodayNominal: todayTotalOps,
TodayPct: todayPct(todayTotalOps),
MtdNominal: mtdTotalOps,
MtdPct: mtdPct(mtdTotalOps),
})
mainSummary := []models.ProfitLossSummaryRow{
{
ID: "total_omset", Label: "TOTAL OMSET",
TodayNominal: result.TodayRevenue, TodayPct: todayPct(result.TodayRevenue),
MtdNominal: result.MtdRevenue, MtdPct: mtdPct(result.MtdRevenue),
},
{
ID: "hpp", Label: "HPP",
TodayNominal: result.TodayCost, TodayPct: todayPct(result.TodayCost),
MtdNominal: result.MtdCost, MtdPct: mtdPct(result.MtdCost),
},
{
ID: "laba_kotor", Label: "Laba Kotor (1-2)",
TodayNominal: todayGrossProfit, TodayPct: todayPct(todayGrossProfit),
MtdNominal: mtdGrossProfit, MtdPct: mtdPct(mtdGrossProfit),
},
{
ID: "biaya_ops", Label: "BIAYA OPS",
TodayNominal: todayTotalOps, TodayPct: todayPct(todayTotalOps),
MtdNominal: mtdTotalOps, MtdPct: mtdPct(mtdTotalOps),
SubItems: opsSubItems,
},
{
ID: "laba_rugi_sblm_gaji", Label: "Laba/Rugi sblm Gaji (3-4)",
TodayNominal: todayProfitBeforeGaji, TodayPct: todayPct(todayProfitBeforeGaji),
MtdNominal: mtdProfitBeforeGaji, MtdPct: mtdPct(mtdProfitBeforeGaji),
},
{
ID: "biaya_gaji", Label: "BIAYA GAJI",
TodayNominal: todayGaji, TodayPct: todayPct(todayGaji),
MtdNominal: mtdGaji, MtdPct: mtdPct(mtdGaji),
},
{
ID: "laba_rugi", Label: "Laba/Rugi (5-6)", IsBold: true,
TodayNominal: todayNetProfit, TodayPct: todayPct(todayNetProfit),
MtdNominal: mtdNetProfit, MtdPct: mtdPct(mtdNetProfit),
},
}
opsItems := make([]models.OperationalExpenseItem, len(result.OperationalExpenseItems))
var opsTotal float64
for i, item := range result.OperationalExpenseItems {
opsItems[i] = models.OperationalExpenseItem{
Item: item.Item,
Nominal: item.Amount,
}
opsTotal += item.Amount
}
purchasingItems := make([]models.ProfitLossPurchasingItem, len(result.PurchasingItems))
for i, item := range result.PurchasingItems {
purchasingItems[i] = models.ProfitLossPurchasingItem{
Date: item.Date,
Item: item.Item,
Quantity: item.Quantity,
Nominal: item.Amount,
}
}
return &models.ProfitLossAnalyticsResponse{ return &models.ProfitLossAnalyticsResponse{
OrganizationID: req.OrganizationID, OrganizationID: req.OrganizationID,
OutletID: req.OutletID, OutletID: req.OutletID,
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
DateFrom: req.DateFrom, DateFrom: req.DateFrom,
DateTo: req.DateTo, DateTo: req.DateTo,
GroupBy: req.GroupBy, GroupBy: req.GroupBy,
@ -658,319 +388,5 @@ func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req
}, },
Data: data, Data: data,
ProductData: productData, ProductData: productData,
MainSummary: mainSummary,
Purchasing: models.ProfitLossPurchasing{
TodayTotal: result.TodayPurchasing,
MtdTotal: result.MtdPurchasing,
TodayRawMaterial: result.TodayPurchasingRawMaterial,
MtdRawMaterial: result.MtdPurchasingRawMaterial,
TodayExpense: result.TodayPurchasingExpense,
MtdExpense: result.MtdPurchasingExpense,
Items: purchasingItems,
},
OperationalExpenses: opsItems,
OperationalExpensesTotal: opsTotal,
}, nil }, nil
} }
func isSalaryExpenseCategory(name string) bool {
name = strings.ToLower(name)
return strings.Contains(name, "gaji") || strings.Contains(name, "salary")
}
func slugify(s string) string {
result := make([]byte, 0, len(s))
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c >= 'a' && c <= 'z':
result = append(result, c)
case c >= 'A' && c <= 'Z':
result = append(result, c+32)
case c >= '0' && c <= '9':
result = append(result, c)
default:
if len(result) == 0 || result[len(result)-1] != '_' {
result = append(result, '_')
}
}
}
return string(result)
}
func (p *AnalyticsProcessorImpl) GetExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error) {
if req.DateFrom.After(req.DateTo) {
return nil, fmt.Errorf("date_from cannot be after date_to")
}
return p.buildExclusiveSummaryPeriod(ctx, req)
}
func (p *AnalyticsProcessorImpl) GetExclusiveSummaryMonthly(ctx context.Context, req *models.ExclusiveSummaryMonthlyRequest) (*models.ExclusiveSummaryMonthlyResponse, error) {
monthStart := time.Date(req.Month.Year(), req.Month.Month(), 1, 0, 0, 0, 0, req.Month.Location())
monthEnd := monthStart.AddDate(0, 1, 0).Add(-time.Nanosecond)
fullPeriod, err := p.buildExclusiveSummaryPeriod(ctx, &models.ExclusiveSummaryPeriodRequest{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
DateFrom: monthStart,
DateTo: monthEnd,
})
if err != nil {
return nil, err
}
periods := make([]models.ExclusiveSummaryMonthlyPeriod, 0)
for _, bucket := range buildExclusiveSummaryMonthlyBuckets(monthStart) {
period, err := p.buildExclusiveSummaryPeriod(ctx, &models.ExclusiveSummaryPeriodRequest{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
DateFrom: bucket.DateFrom,
DateTo: bucket.DateTo,
})
if err != nil {
return nil, err
}
periods = append(periods, models.ExclusiveSummaryMonthlyPeriod{
Label: bucket.Label,
DateFrom: bucket.DateFrom,
DateTo: bucket.DateTo,
Sales: period.Summary.Sales,
HPP: period.Summary.HPP,
GrossProfit: period.Summary.GrossProfit,
GrossMargin: percentage(period.Summary.GrossProfit, period.Summary.Sales),
})
}
bankBalances, err := p.analyticsRepo.GetExclusiveSummaryBankBalances(ctx, req.OrganizationID, req.OutletID)
if err != nil {
return nil, fmt.Errorf("failed to get exclusive summary bank balances: %w", err)
}
bankBalance := make([]models.ExclusiveSummaryBankBalance, len(bankBalances))
for i, item := range bankBalances {
bankBalance[i] = models.ExclusiveSummaryBankBalance{
Bank: item.Bank,
OpeningBalance: item.OpeningBalance,
IncomingMutation: item.IncomingMutation,
OutgoingMutation: item.OutgoingMutation,
ClosingBalance: item.ClosingBalance,
Notes: item.Notes,
}
}
return &models.ExclusiveSummaryMonthlyResponse{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
Month: monthStart.Format("2006-01"),
Summary: models.ExclusiveSummaryMonthlySummary{
TotalSales: fullPeriod.Summary.Sales,
HPP: fullPeriod.Summary.HPP,
GrossProfit: fullPeriod.Summary.GrossProfit,
OperationalExpensesTotal: fullPeriod.Summary.OperationalExpensesTotal,
TotalCost: fullPeriod.Summary.TotalCost,
NetProfit: fullPeriod.Summary.NetProfit,
NetProfitMargin: percentage(fullPeriod.Summary.NetProfit, fullPeriod.Summary.Sales),
},
Periods: periods,
BankBalance: bankBalance,
}, nil
}
func (p *AnalyticsProcessorImpl) GetExclusiveSummaryMTD(ctx context.Context, req *models.ExclusiveSummaryMTDRequest) (*models.ExclusiveSummaryPeriodResponse, error) {
mtdStart := time.Date(req.DateTo.Year(), req.DateTo.Month(), 1, 0, 0, 0, 0, req.DateTo.Location())
return p.buildExclusiveSummaryPeriod(ctx, &models.ExclusiveSummaryPeriodRequest{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
DateFrom: mtdStart,
DateTo: req.DateTo,
ExcludeGajiStaffFromReimburse: req.ExcludeGajiStaffFromReimburse,
})
}
func (p *AnalyticsProcessorImpl) buildExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error) {
result, err := p.analyticsRepo.GetExclusiveSummaryAnalytics(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo)
if err != nil {
return nil, fmt.Errorf("failed to get exclusive summary analytics: %w", err)
}
hppBreakdown, hppTotal := exclusiveSummaryCategoryBreakdown(result.HPPBreakdown)
operationalBreakdown, operationalTotal := exclusiveSummaryCategoryBreakdown(result.OperationalExpenseBreakdown)
salaryDW, salaryStaff, salaryOther := exclusiveSummarySalaryBreakdown(result.DailyTransactions)
salaryTotal := salaryDW + salaryStaff + salaryOther
otherOperationalExpenses := operationalTotal - salaryTotal
if otherOperationalExpenses < 0 {
otherOperationalExpenses = 0
}
grossProfit := result.SalesTotal - hppTotal
totalCost := hppTotal + operationalTotal
netProfit := result.SalesTotal - totalCost
excludedSalaryStaff := 0.0
if req.ExcludeGajiStaffFromReimburse {
excludedSalaryStaff = salaryStaff
}
dailySummary := make([]models.ExclusiveSummaryDailySummary, len(result.DailySummary))
for i, item := range result.DailySummary {
dailySummary[i] = models.ExclusiveSummaryDailySummary{
Date: item.Date,
TransactionCount: item.TransactionCount,
TotalCost: item.TotalCost,
}
}
dailyTransactions := make([]models.ExclusiveSummaryDailyTransaction, len(result.DailyTransactions))
for i, item := range result.DailyTransactions {
dailyTransactions[i] = models.ExclusiveSummaryDailyTransaction{
Date: item.Date,
CategoryCode: item.CategoryCode,
CategoryName: item.CategoryName,
Description: item.Description,
Amount: item.Amount,
Source: item.Source,
}
}
return &models.ExclusiveSummaryPeriodResponse{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
Period: models.ExclusiveSummaryPeriodRange{
DateFrom: req.DateFrom,
DateTo: req.DateTo,
},
Summary: models.ExclusiveSummaryPeriodSummary{
Sales: result.SalesTotal,
HPP: hppTotal,
GrossProfit: grossProfit,
SalaryTotal: salaryTotal,
SalaryDW: salaryDW,
SalaryStaff: salaryStaff,
SalaryOther: salaryOther,
OtherOperationalExpenses: otherOperationalExpenses,
OperationalExpensesTotal: operationalTotal,
TotalCost: totalCost,
NetProfit: netProfit,
},
Reimburse: models.ExclusiveSummaryReimburse{
TotalCost: totalCost,
ExcludedSalaryStaff: excludedSalaryStaff,
TotalReimburse: totalCost - excludedSalaryStaff,
},
HPPBreakdown: hppBreakdown,
OperationalExpenseBreakdown: operationalBreakdown,
DailySummary: dailySummary,
DailyTransactions: dailyTransactions,
}, nil
}
func exclusiveSummaryCategoryBreakdown(items []entities.ExclusiveSummaryCategoryTotal) ([]models.ExclusiveSummaryCategoryBreakdown, float64) {
var total float64
for _, item := range items {
total += item.Amount
}
breakdown := make([]models.ExclusiveSummaryCategoryBreakdown, len(items))
for i, item := range items {
breakdown[i] = models.ExclusiveSummaryCategoryBreakdown{
CategoryCode: item.CategoryCode,
CategoryName: item.CategoryName,
Amount: item.Amount,
Percentage: percentage(item.Amount, total),
}
}
return breakdown, total
}
func exclusiveSummarySalaryBreakdown(transactions []entities.ExclusiveSummaryDailyTransaction) (float64, float64, float64) {
var salaryDW float64
var salaryStaff float64
var salaryOther float64
for _, transaction := range transactions {
if !isExclusiveSummarySalary(transaction.CategoryCode, transaction.CategoryName, transaction.Description) {
continue
}
classification := strings.ToLower(transaction.CategoryCode + " " + transaction.CategoryName + " " + transaction.Description)
switch {
case strings.Contains(classification, "staff") || strings.Contains(classification, "kary") || strings.Contains(classification, "karyawan"):
salaryStaff += transaction.Amount
case strings.Contains(classification, "dw"):
salaryDW += transaction.Amount
default:
salaryOther += transaction.Amount
}
}
return salaryDW, salaryStaff, salaryOther
}
func isExclusiveSummarySalary(parts ...string) bool {
text := strings.ToLower(strings.Join(parts, " "))
return strings.Contains(text, "gaji") || strings.Contains(text, "salary")
}
func percentage(numerator, denominator float64) float64 {
if denominator == 0 {
return 0
}
return (numerator / denominator) * 100
}
type exclusiveSummaryMonthlyBucket struct {
Label string
DateFrom time.Time
DateTo time.Time
}
func buildExclusiveSummaryMonthlyBuckets(monthStart time.Time) []exclusiveSummaryMonthlyBucket {
monthEnd := monthStart.AddDate(0, 1, 0).Add(-time.Nanosecond)
buckets := make([]exclusiveSummaryMonthlyBucket, 0, 6)
currentStart := monthStart
for !currentStart.After(monthEnd) {
currentEnd := currentStart
for currentEnd.Weekday() != time.Sunday && currentEnd.Day() < monthEnd.Day() {
currentEnd = currentEnd.AddDate(0, 0, 1)
}
bucketEnd := time.Date(currentEnd.Year(), currentEnd.Month(), currentEnd.Day(), 23, 59, 59, int(time.Second-time.Nanosecond), currentEnd.Location())
if bucketEnd.After(monthEnd) {
bucketEnd = monthEnd
}
buckets = append(buckets, exclusiveSummaryMonthlyBucket{
Label: fmt.Sprintf("%d - %d %s", currentStart.Day(), bucketEnd.Day(), indonesianMonthName(currentStart.Month())),
DateFrom: currentStart,
DateTo: bucketEnd,
})
currentStart = time.Date(bucketEnd.Year(), bucketEnd.Month(), bucketEnd.Day(), 0, 0, 0, 0, bucketEnd.Location()).AddDate(0, 0, 1)
}
return buckets
}
func indonesianMonthName(month time.Month) string {
names := map[time.Month]string{
time.January: "Januari",
time.February: "Februari",
time.March: "Maret",
time.April: "April",
time.May: "Mei",
time.June: "Juni",
time.July: "Juli",
time.August: "Agustus",
time.September: "September",
time.October: "Oktober",
time.November: "November",
time.December: "Desember",
}
return names[month]
}

View File

@ -1,452 +0,0 @@
package processor
import (
"context"
"testing"
"time"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
)
type analyticsRepositoryStub struct {
purchasingResult *entities.PurchasingAnalytics
profitLossResult *entities.ProfitLossAnalytics
exclusiveSummaryResults []*entities.ExclusiveSummaryAnalytics
bankBalances []entities.ExclusiveSummaryBankBalance
profitLossGroup string
exclusiveSummaryCalls int
exclusiveSummaryFrom []time.Time
exclusiveSummaryTo []time.Time
}
func (analyticsRepositoryStub) GetPaymentMethodAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.PaymentMethodAnalytics, error) {
return nil, nil
}
func (analyticsRepositoryStub) GetSalesAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time, string) ([]*entities.SalesAnalytics, error) {
return nil, nil
}
func (s analyticsRepositoryStub) GetPurchasingAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time, string) (*entities.PurchasingAnalytics, error) {
return s.purchasingResult, nil
}
func (analyticsRepositoryStub) GetProductAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time, int) ([]*entities.ProductAnalytics, error) {
return nil, nil
}
func (analyticsRepositoryStub) GetProductAnalyticsPerCategory(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.ProductAnalyticsPerCategory, error) {
return nil, nil
}
func (analyticsRepositoryStub) GetDashboardOverview(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) (*entities.DashboardOverview, error) {
return nil, nil
}
func (s analyticsRepositoryStub) GetProfitLossAnalytics(_ context.Context, _ uuid.UUID, _ *uuid.UUID, _, _ time.Time, groupBy string) (*entities.ProfitLossAnalytics, error) {
s.profitLossGroup = groupBy
return s.profitLossResult, nil
}
func (s *analyticsRepositoryStub) GetExclusiveSummaryAnalytics(_ context.Context, _ uuid.UUID, _ *uuid.UUID, dateFrom, dateTo time.Time) (*entities.ExclusiveSummaryAnalytics, error) {
s.exclusiveSummaryFrom = append(s.exclusiveSummaryFrom, dateFrom)
s.exclusiveSummaryTo = append(s.exclusiveSummaryTo, dateTo)
if s.exclusiveSummaryCalls < len(s.exclusiveSummaryResults) {
result := s.exclusiveSummaryResults[s.exclusiveSummaryCalls]
s.exclusiveSummaryCalls++
return result, nil
}
s.exclusiveSummaryCalls++
return &entities.ExclusiveSummaryAnalytics{}, nil
}
func (s *analyticsRepositoryStub) GetExclusiveSummaryBankBalances(context.Context, uuid.UUID, *uuid.UUID) ([]entities.ExclusiveSummaryBankBalance, error) {
return s.bankBalances, nil
}
func (analyticsRepositoryStub) GetOutletName(context.Context, uuid.UUID, uuid.UUID) (string, error) {
return "", nil
}
type expenseRepositoryStub struct{}
func (expenseRepositoryStub) Create(context.Context, *entities.Expense) error { return nil }
func (expenseRepositoryStub) GetByID(context.Context, uuid.UUID) (*entities.Expense, error) {
return nil, nil
}
func (expenseRepositoryStub) GetByIDAndOrganizationID(context.Context, uuid.UUID, uuid.UUID) (*entities.Expense, error) {
return nil, nil
}
func (expenseRepositoryStub) Update(context.Context, *entities.Expense) error { return nil }
func (expenseRepositoryStub) Delete(context.Context, uuid.UUID) error { return nil }
func (expenseRepositoryStub) List(context.Context, uuid.UUID, map[string]interface{}, int, int) ([]*entities.Expense, int64, error) {
return nil, 0, nil
}
func (expenseRepositoryStub) GetAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time, string) (*entities.ExpenseAnalytics, error) {
return nil, nil
}
func (expenseRepositoryStub) CreateItem(context.Context, *entities.ExpenseItem) error { return nil }
func (expenseRepositoryStub) DeleteItemsByExpenseID(context.Context, uuid.UUID) error { return nil }
func TestAnalyticsProcessorGetPurchasingAnalyticsPassesOutletName(t *testing.T) {
outletID := uuid.New()
outletName := "Main Outlet"
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{
purchasingResult: &entities.PurchasingAnalytics{
OutletName: &outletName,
Summary: entities.PurchasingSummary{
TotalPurchases: 300,
RawMaterialPurchases: 125,
ExpensePurchases: 175,
TotalPurchaseOrders: 3,
RawMaterialPurchaseOrders: 1,
ExpenseCount: 2,
},
Data: []entities.PurchasingAnalyticsData{
{
Date: now,
Purchases: 300,
RawMaterialPurchases: 125,
ExpensePurchases: 175,
PurchaseOrders: 3,
RawMaterialPurchaseOrders: 1,
ExpenseCount: 2,
},
},
},
}, expenseRepositoryStub{})
result, err := processor.GetPurchasingAnalytics(context.Background(), &models.PurchasingAnalyticsRequest{
OrganizationID: uuid.New(),
OutletID: &outletID,
DateFrom: now,
DateTo: now,
})
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, &outletID, result.OutletID)
require.NotNil(t, result.OutletName)
require.Equal(t, outletName, *result.OutletName)
require.Equal(t, float64(300), result.Summary.TotalPurchases)
require.Equal(t, float64(125), result.Summary.RawMaterialPurchases)
require.Equal(t, float64(175), result.Summary.ExpensePurchases)
require.Equal(t, int64(3), result.Summary.TotalPurchaseOrders)
require.Equal(t, int64(1), result.Summary.RawMaterialPurchaseOrders)
require.Equal(t, int64(2), result.Summary.ExpenseCount)
require.Len(t, result.Data, 1)
require.Equal(t, float64(300), result.Data[0].Purchases)
require.Equal(t, float64(125), result.Data[0].RawMaterialPurchases)
require.Equal(t, float64(175), result.Data[0].ExpensePurchases)
}
func TestAnalyticsProcessorGetProfitLossAnalyticsMapsOverviewAndReportFields(t *testing.T) {
productID := uuid.New()
categoryID := uuid.New()
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{
profitLossResult: &entities.ProfitLossAnalytics{
Summary: entities.ProfitLossSummary{
TotalRevenue: 1000,
TotalCost: 400,
GrossProfit: 600,
GrossProfitMargin: 60,
TotalTax: 50,
TotalDiscount: 25,
NetProfit: 575,
NetProfitMargin: 57.5,
TotalOrders: 10,
AverageProfit: 57.5,
ProfitabilityRatio: 150,
},
Data: []entities.ProfitLossData{
{
Date: now,
Revenue: 1000,
Cost: 400,
GrossProfit: 600,
GrossProfitMargin: 60,
Tax: 50,
Discount: 25,
NetProfit: 575,
NetProfitMargin: 57.5,
Orders: 10,
},
},
ProductData: []entities.ProductProfitData{
{
ProductID: productID,
ProductName: "Nasi",
CategoryID: categoryID,
CategoryName: "Food",
QuantitySold: 5,
Revenue: 500,
Cost: 200,
GrossProfit: 300,
GrossProfitMargin: 60,
AveragePrice: 100,
AverageCost: 40,
ProfitPerUnit: 60,
},
},
TodayRevenue: 1000,
TodayCost: 400,
MtdRevenue: 2000,
MtdCost: 800,
},
}, expenseRepositoryStub{})
result, err := processor.GetProfitLossAnalytics(context.Background(), &models.ProfitLossAnalyticsRequest{
OrganizationID: uuid.New(),
DateFrom: now,
DateTo: now,
})
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, "day", result.GroupBy)
require.Equal(t, float64(1000), result.Summary.TotalRevenue)
require.Len(t, result.Data, 1)
require.Equal(t, float64(575), result.Data[0].NetProfit)
require.Len(t, result.ProductData, 1)
require.Equal(t, productID, result.ProductData[0].ProductID)
require.NotEmpty(t, result.MainSummary)
require.Equal(t, "total_omset", result.MainSummary[0].ID)
}
func TestAnalyticsProcessorGetProfitLossAnalyticsDynamicExpenseCategories(t *testing.T) {
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{
profitLossResult: &entities.ProfitLossAnalytics{
Summary: entities.ProfitLossSummary{
TotalRevenue: 10000,
TotalCost: 4000,
},
TodayRevenue: 10000,
TodayCost: 4000,
MtdRevenue: 20000,
MtdCost: 8000,
TodayExpenseByCategory: []entities.ExpenseCategoryTotal{
{CategoryName: "Gaji", Amount: 1500},
{CategoryName: "Promosi", Amount: 300},
{CategoryName: "Sewa", Amount: 500},
},
MtdExpenseByCategory: []entities.ExpenseCategoryTotal{
{CategoryName: "Gaji", Amount: 3000},
{CategoryName: "Promosi", Amount: 600},
{CategoryName: "Sewa", Amount: 1000},
},
},
}, expenseRepositoryStub{})
result, err := processor.GetProfitLossAnalytics(context.Background(), &models.ProfitLossAnalyticsRequest{
OrganizationID: uuid.New(),
DateFrom: now,
DateTo: now,
})
require.NoError(t, err)
require.NotNil(t, result)
require.Len(t, result.MainSummary, 7)
require.Equal(t, "total_omset", result.MainSummary[0].ID)
require.Equal(t, float64(10000), result.MainSummary[0].TodayNominal)
require.Equal(t, float64(20000), result.MainSummary[0].MtdNominal)
require.Equal(t, "hpp", result.MainSummary[1].ID)
require.Equal(t, float64(4000), result.MainSummary[1].TodayNominal)
require.Equal(t, float64(8000), result.MainSummary[1].MtdNominal)
require.Equal(t, "laba_kotor", result.MainSummary[2].ID)
require.Equal(t, float64(6000), result.MainSummary[2].TodayNominal)
require.Equal(t, float64(12000), result.MainSummary[2].MtdNominal)
require.Equal(t, "biaya_ops", result.MainSummary[3].ID)
require.Equal(t, float64(800), result.MainSummary[3].TodayNominal)
require.Equal(t, float64(1600), result.MainSummary[3].MtdNominal)
require.Len(t, result.MainSummary[3].SubItems, 3) // 2 operational categories + 1 total
require.Equal(t, "by_promosi", result.MainSummary[3].SubItems[0].ID)
require.Equal(t, float64(300), result.MainSummary[3].SubItems[0].TodayNominal)
require.Equal(t, float64(600), result.MainSummary[3].SubItems[0].MtdNominal)
require.Equal(t, "by_sewa", result.MainSummary[3].SubItems[1].ID)
require.Equal(t, float64(500), result.MainSummary[3].SubItems[1].TodayNominal)
require.Equal(t, float64(1000), result.MainSummary[3].SubItems[1].MtdNominal)
require.Equal(t, "total_biaya_ops", result.MainSummary[3].SubItems[2].ID)
require.True(t, result.MainSummary[3].SubItems[2].IsBold)
require.Equal(t, float64(800), result.MainSummary[3].SubItems[2].TodayNominal)
require.Equal(t, float64(1600), result.MainSummary[3].SubItems[2].MtdNominal)
require.Equal(t, "laba_rugi_sblm_gaji", result.MainSummary[4].ID)
require.Equal(t, float64(5200), result.MainSummary[4].TodayNominal)
require.Equal(t, float64(10400), result.MainSummary[4].MtdNominal)
require.Equal(t, "biaya_gaji", result.MainSummary[5].ID)
require.Equal(t, float64(1500), result.MainSummary[5].TodayNominal)
require.Equal(t, float64(3000), result.MainSummary[5].MtdNominal)
require.Equal(t, "laba_rugi", result.MainSummary[6].ID)
require.Equal(t, float64(3700), result.MainSummary[6].TodayNominal)
require.Equal(t, float64(7400), result.MainSummary[6].MtdNominal)
require.True(t, result.MainSummary[6].IsBold)
}
func TestAnalyticsProcessorGetExclusiveSummaryPeriodCalculatesTotalsAndReimburse(t *testing.T) {
now := time.Date(2026, 5, 26, 0, 0, 0, 0, time.UTC)
processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{
exclusiveSummaryResults: []*entities.ExclusiveSummaryAnalytics{
{
SalesTotal: 1000,
HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{
{CategoryCode: "RAW", CategoryName: "Raw", Amount: 400},
},
OperationalExpenseBreakdown: []entities.ExclusiveSummaryCategoryTotal{
{CategoryCode: "GAJI", CategoryName: "Gaji", Amount: 250},
{CategoryCode: "OPS", CategoryName: "Operasional", Amount: 100},
},
DailySummary: []entities.ExclusiveSummaryDailySummary{
{Date: now, TransactionCount: 3, TotalCost: 750},
},
DailyTransactions: []entities.ExclusiveSummaryDailyTransaction{
{Date: now, CategoryCode: "RAW", CategoryName: "Raw", Description: "beras", Amount: 400, Source: "purchase_order"},
{Date: now, CategoryCode: "GAJI", CategoryName: "Gaji", Description: "gaji karyawan", Amount: 200, Source: "purchase_order"},
{Date: now, CategoryCode: "GAJI", CategoryName: "Gaji", Description: "DW", Amount: 50, Source: "purchase_order"},
{Date: now, CategoryCode: "OPS", CategoryName: "Operasional", Description: "atk", Amount: 100, Source: "purchase_order"},
},
},
},
}, expenseRepositoryStub{})
result, err := processor.GetExclusiveSummaryPeriod(context.Background(), &models.ExclusiveSummaryPeriodRequest{
OrganizationID: uuid.New(),
DateFrom: now,
DateTo: now,
ExcludeGajiStaffFromReimburse: true,
})
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, float64(1000), result.Summary.Sales)
require.Equal(t, float64(400), result.Summary.HPP)
require.Equal(t, float64(600), result.Summary.GrossProfit)
require.Equal(t, float64(350), result.Summary.OperationalExpensesTotal)
require.Equal(t, float64(750), result.Summary.TotalCost)
require.Equal(t, float64(250), result.Summary.NetProfit)
require.Equal(t, float64(250), result.Summary.SalaryTotal)
require.Equal(t, float64(50), result.Summary.SalaryDW)
require.Equal(t, float64(200), result.Summary.SalaryStaff)
require.Equal(t, float64(100), result.Summary.OtherOperationalExpenses)
require.Equal(t, float64(200), result.Reimburse.ExcludedSalaryStaff)
require.Equal(t, float64(550), result.Reimburse.TotalReimburse)
require.Len(t, result.HPPBreakdown, 1)
require.Equal(t, float64(100), result.HPPBreakdown[0].Percentage)
require.Len(t, result.DailySummary, 1)
require.Len(t, result.DailyTransactions, 4)
}
func TestAnalyticsProcessorGetExclusiveSummaryMonthlyBuildsSummaryAndBuckets(t *testing.T) {
location, err := time.LoadLocation("Asia/Jakarta")
require.NoError(t, err)
month := time.Date(2026, 5, 1, 0, 0, 0, 0, location)
openingBalance := 5000000.0
closingBalance := 5000000.0
notes := "Main cash account for daily transactions"
stub := &analyticsRepositoryStub{
exclusiveSummaryResults: []*entities.ExclusiveSummaryAnalytics{
{SalesTotal: 1000, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 400}}, OperationalExpenseBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 100}}},
{SalesTotal: 100, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 40}}},
{SalesTotal: 200, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 80}}},
{SalesTotal: 300, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 120}}},
{SalesTotal: 400, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 160}}},
{SalesTotal: 500, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 200}}},
},
bankBalances: []entities.ExclusiveSummaryBankBalance{
{Bank: "Cash and Bank", OpeningBalance: &openingBalance, ClosingBalance: &closingBalance, Notes: &notes},
},
}
processor := NewAnalyticsProcessorImpl(stub, expenseRepositoryStub{})
result, err := processor.GetExclusiveSummaryMonthly(context.Background(), &models.ExclusiveSummaryMonthlyRequest{
OrganizationID: uuid.New(),
Month: month,
})
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, "2026-05", result.Month)
require.Equal(t, float64(1000), result.Summary.TotalSales)
require.Equal(t, float64(400), result.Summary.HPP)
require.Equal(t, float64(500), result.Summary.NetProfit)
require.InDelta(t, float64(50), result.Summary.NetProfitMargin, 0.0001)
require.Len(t, result.Periods, 5)
require.Equal(t, "1 - 3 Mei", result.Periods[0].Label)
require.Equal(t, "25 - 31 Mei", result.Periods[4].Label)
require.Len(t, result.BankBalance, 1)
require.Equal(t, "Cash and Bank", result.BankBalance[0].Bank)
require.NotNil(t, result.BankBalance[0].OpeningBalance)
require.Equal(t, openingBalance, *result.BankBalance[0].OpeningBalance)
require.NotNil(t, result.BankBalance[0].ClosingBalance)
require.Equal(t, closingBalance, *result.BankBalance[0].ClosingBalance)
require.Nil(t, result.BankBalance[0].IncomingMutation)
require.Nil(t, result.BankBalance[0].OutgoingMutation)
require.NotNil(t, result.BankBalance[0].Notes)
require.Equal(t, notes, *result.BankBalance[0].Notes)
require.Equal(t, 6, stub.exclusiveSummaryCalls)
}
func TestAnalyticsProcessorGetExclusiveSummaryMTDBuildsMonthToDateBreakdown(t *testing.T) {
location, err := time.LoadLocation("Asia/Jakarta")
require.NoError(t, err)
dateTo := time.Date(2026, 6, 18, 23, 59, 59, int(time.Second-time.Nanosecond), location)
stub := &analyticsRepositoryStub{
exclusiveSummaryResults: []*entities.ExclusiveSummaryAnalytics{
{
SalesTotal: 1000,
HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{
{CategoryCode: "RAW", CategoryName: "Raw Material", Amount: 400},
},
OperationalExpenseBreakdown: []entities.ExclusiveSummaryCategoryTotal{
{CategoryCode: "OPS", CategoryName: "Operational", Amount: 100},
},
DailySummary: []entities.ExclusiveSummaryDailySummary{
{Date: dateTo, TransactionCount: 2, TotalCost: 500},
},
DailyTransactions: []entities.ExclusiveSummaryDailyTransaction{
{Date: dateTo, CategoryCode: "RAW", CategoryName: "Raw Material", Description: "beras", Amount: 400, Source: "purchase_order"},
{Date: dateTo, CategoryCode: "OPS", CategoryName: "Operational", Description: "atk", Amount: 100, Source: "expense"},
},
},
},
}
processor := NewAnalyticsProcessorImpl(stub, expenseRepositoryStub{})
result, err := processor.GetExclusiveSummaryMTD(context.Background(), &models.ExclusiveSummaryMTDRequest{
OrganizationID: uuid.New(),
DateTo: dateTo,
})
require.NoError(t, err)
require.NotNil(t, result)
require.Len(t, stub.exclusiveSummaryFrom, 1)
require.Equal(t, time.Date(2026, 6, 1, 0, 0, 0, 0, location), stub.exclusiveSummaryFrom[0])
require.Equal(t, dateTo, stub.exclusiveSummaryTo[0])
require.Equal(t, stub.exclusiveSummaryFrom[0], result.Period.DateFrom)
require.Equal(t, dateTo, result.Period.DateTo)
require.Equal(t, float64(1000), result.Summary.Sales)
require.Equal(t, float64(400), result.Summary.HPP)
require.Equal(t, float64(500), result.Summary.TotalCost)
require.Equal(t, float64(500), result.Summary.NetProfit)
require.Len(t, result.HPPBreakdown, 1)
require.Equal(t, float64(100), result.HPPBreakdown[0].Percentage)
require.Len(t, result.OperationalExpenseBreakdown, 1)
require.Len(t, result.DailySummary, 1)
require.Len(t, result.DailyTransactions, 2)
}

View File

@ -1,352 +0,0 @@
package processor
import (
"context"
"fmt"
"time"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/mappers"
"apskel-pos-be/internal/models"
"github.com/google/uuid"
)
type ExpenseProcessor interface {
CreateExpense(ctx context.Context, organizationID uuid.UUID, req *models.CreateExpenseRequest) (*models.ExpenseResponse, error)
UpdateExpense(ctx context.Context, id, organizationID uuid.UUID, req *models.UpdateExpenseRequest) (*models.ExpenseResponse, error)
DeleteExpense(ctx context.Context, id, organizationID uuid.UUID) error
GetExpenseByID(ctx context.Context, id, organizationID uuid.UUID) (*models.ExpenseResponse, error)
ListExpenses(ctx context.Context, organizationID uuid.UUID, filters map[string]interface{}, page, limit int) ([]*models.ExpenseResponse, int, error)
GetExpenseAnalytics(ctx context.Context, req *models.ExpenseAnalyticsRequest) (*models.ExpenseAnalyticsResponse, error)
}
type ExpenseProcessorImpl struct {
expenseRepo ExpenseRepository
purchaseCategoryRepo PurchaseCategoryRepository
}
func NewExpenseProcessorImpl(expenseRepo ExpenseRepository, purchaseCategoryRepo PurchaseCategoryRepository) *ExpenseProcessorImpl {
return &ExpenseProcessorImpl{
expenseRepo: expenseRepo,
purchaseCategoryRepo: purchaseCategoryRepo,
}
}
func (p *ExpenseProcessorImpl) CreateExpense(ctx context.Context, organizationID uuid.UUID, req *models.CreateExpenseRequest) (*models.ExpenseResponse, error) {
outletID, err := uuid.Parse(req.OutletID)
if err != nil {
return nil, fmt.Errorf("invalid outlet_id: %w", err)
}
transactionDate, err := time.Parse("2006-01-02", req.TransactionDate)
if err != nil {
return nil, fmt.Errorf("invalid transaction_date format, expected YYYY-MM-DD: %w", err)
}
status := string(constants.ExpenseStatusDraft)
if req.Status != nil {
status = *req.Status
}
items := make([]entities.ExpenseItem, len(req.Items))
for i, itemReq := range req.Items {
chartOfAccountID, err := uuid.Parse(itemReq.ChartOfAccountID)
if err != nil {
return nil, fmt.Errorf("invalid chart_of_account_id for item: %w", err)
}
purchaseCategoryID, err := uuid.Parse(itemReq.PurchaseCategoryID)
if err != nil {
return nil, fmt.Errorf("invalid purchase_category_id for item: %w", err)
}
if err := p.validateExpensePurchaseCategory(ctx, purchaseCategoryID, organizationID); err != nil {
return nil, err
}
items[i] = entities.ExpenseItem{
ChartOfAccountID: chartOfAccountID,
PurchaseCategoryID: purchaseCategoryID,
Item: itemReq.Item,
Description: itemReq.Description,
Amount: itemReq.Amount,
}
}
expenseEntity := &entities.Expense{
OrganizationID: organizationID,
OutletID: outletID,
Receiver: req.Receiver,
TransactionDate: transactionDate,
CodeNumber: req.CodeNumber,
Status: status,
Description: req.Description,
Tax: req.Tax,
Total: req.Total,
}
err = p.expenseRepo.Create(ctx, expenseEntity)
if err != nil {
return nil, fmt.Errorf("failed to create expense: %w", err)
}
for i := range items {
items[i].ExpenseID = expenseEntity.ID
err = p.expenseRepo.CreateItem(ctx, &items[i])
if err != nil {
return nil, fmt.Errorf("failed to create expense item: %w", err)
}
}
created, err := p.expenseRepo.GetByID(ctx, expenseEntity.ID)
if err != nil {
return mappers.ExpenseEntityToResponse(expenseEntity), nil
}
return mappers.ExpenseEntityToResponse(created), nil
}
func (p *ExpenseProcessorImpl) UpdateExpense(ctx context.Context, id, organizationID uuid.UUID, req *models.UpdateExpenseRequest) (*models.ExpenseResponse, error) {
expenseEntity, err := p.expenseRepo.GetByIDAndOrganizationID(ctx, id, organizationID)
if err != nil {
return nil, fmt.Errorf("expense not found: %w", err)
}
if req.Receiver != nil {
expenseEntity.Receiver = *req.Receiver
}
if req.TransactionDate != nil {
parsedDate, err := time.Parse("2006-01-02", *req.TransactionDate)
if err != nil {
return nil, fmt.Errorf("invalid transaction_date format, expected YYYY-MM-DD: %w", err)
}
expenseEntity.TransactionDate = parsedDate
}
if req.CodeNumber != nil {
expenseEntity.CodeNumber = *req.CodeNumber
}
if req.Status != nil {
expenseEntity.Status = *req.Status
}
if req.OutletID != nil {
outletID, err := uuid.Parse(*req.OutletID)
if err != nil {
return nil, fmt.Errorf("invalid outlet_id: %w", err)
}
expenseEntity.OutletID = outletID
}
if req.Description != nil {
expenseEntity.Description = req.Description
}
if req.Tax != nil {
expenseEntity.Tax = *req.Tax
}
if req.Total != nil {
expenseEntity.Total = *req.Total
}
if req.Reserved1 != nil {
expenseEntity.Reserved1 = req.Reserved1
}
var items []entities.ExpenseItem
if req.Items != nil {
items = make([]entities.ExpenseItem, len(req.Items))
for i, itemReq := range req.Items {
chartOfAccountID := uuid.Nil
if itemReq.ChartOfAccountID != nil {
chartOfAccountID, err = uuid.Parse(*itemReq.ChartOfAccountID)
if err != nil {
return nil, fmt.Errorf("invalid chart_of_account_id for item: %w", err)
}
}
if itemReq.PurchaseCategoryID == nil {
return nil, fmt.Errorf("purchase_category_id is required for item")
}
purchaseCategoryID, err := uuid.Parse(*itemReq.PurchaseCategoryID)
if err != nil {
return nil, fmt.Errorf("invalid purchase_category_id for item: %w", err)
}
if err := p.validateExpensePurchaseCategory(ctx, purchaseCategoryID, organizationID); err != nil {
return nil, err
}
amount := 0.0
if itemReq.Amount != nil {
amount = *itemReq.Amount
}
item := ""
if itemReq.Item != nil {
item = *itemReq.Item
}
items[i] = entities.ExpenseItem{
ExpenseID: expenseEntity.ID,
ChartOfAccountID: chartOfAccountID,
PurchaseCategoryID: purchaseCategoryID,
Item: item,
Description: itemReq.Description,
Amount: amount,
}
}
err = p.expenseRepo.DeleteItemsByExpenseID(ctx, expenseEntity.ID)
if err != nil {
return nil, fmt.Errorf("failed to delete existing items: %w", err)
}
for i := range items {
err = p.expenseRepo.CreateItem(ctx, &items[i])
if err != nil {
return nil, fmt.Errorf("failed to create expense item: %w", err)
}
}
}
err = p.expenseRepo.Update(ctx, expenseEntity)
if err != nil {
return nil, fmt.Errorf("failed to update expense: %w", err)
}
updated, err := p.expenseRepo.GetByID(ctx, id)
if err != nil {
return mappers.ExpenseEntityToResponse(expenseEntity), nil
}
return mappers.ExpenseEntityToResponse(updated), nil
}
func (p *ExpenseProcessorImpl) DeleteExpense(ctx context.Context, id, organizationID uuid.UUID) error {
_, err := p.expenseRepo.GetByIDAndOrganizationID(ctx, id, organizationID)
if err != nil {
return fmt.Errorf("expense not found: %w", err)
}
err = p.expenseRepo.Delete(ctx, id)
if err != nil {
return fmt.Errorf("failed to delete expense: %w", err)
}
return nil
}
func (p *ExpenseProcessorImpl) GetExpenseByID(ctx context.Context, id, organizationID uuid.UUID) (*models.ExpenseResponse, error) {
expenseEntity, err := p.expenseRepo.GetByIDAndOrganizationID(ctx, id, organizationID)
if err != nil {
return nil, fmt.Errorf("expense not found: %w", err)
}
return mappers.ExpenseEntityToResponse(expenseEntity), nil
}
func (p *ExpenseProcessorImpl) ListExpenses(ctx context.Context, organizationID uuid.UUID, filters map[string]interface{}, page, limit int) ([]*models.ExpenseResponse, int, error) {
offset := (page - 1) * limit
expenseEntities, total, err := p.expenseRepo.List(ctx, organizationID, filters, limit, offset)
if err != nil {
return nil, 0, fmt.Errorf("failed to list expenses: %w", err)
}
expenseResponses := mappers.ExpenseEntitiesToResponses(expenseEntities)
totalPages := int((total + int64(limit) - 1) / int64(limit))
return expenseResponses, totalPages, nil
}
func (p *ExpenseProcessorImpl) GetExpenseAnalytics(ctx context.Context, req *models.ExpenseAnalyticsRequest) (*models.ExpenseAnalyticsResponse, error) {
if req.DateFrom.After(req.DateTo) {
return nil, fmt.Errorf("date_from cannot be after date_to")
}
if req.GroupBy == "" {
req.GroupBy = "day"
}
result, err := p.expenseRepo.GetAnalytics(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo, req.GroupBy)
if err != nil {
return nil, fmt.Errorf("failed to get expense analytics: %w", err)
}
data := make([]models.ExpenseAnalyticsData, len(result.Data))
for i, item := range result.Data {
data[i] = models.ExpenseAnalyticsData{
Date: item.Date,
Expenses: item.Expenses,
ExpenseCount: item.ExpenseCount,
Tax: item.Tax,
Items: item.Items,
Categories: item.Categories,
}
}
categoryData := make([]models.ExpenseAnalyticsCategoryData, len(result.CategoryData))
for i, item := range result.CategoryData {
categoryData[i] = models.ExpenseAnalyticsCategoryData{
PurchaseCategoryID: item.PurchaseCategoryID,
PurchaseCategoryName: item.PurchaseCategoryName,
PurchaseCategoryType: item.PurchaseCategoryType,
TotalAmount: item.TotalAmount,
ExpenseCount: item.ExpenseCount,
ItemCount: item.ItemCount,
}
}
chartOfAccountData := make([]models.ExpenseAnalyticsChartOfAccountData, len(result.ChartOfAccountData))
for i, item := range result.ChartOfAccountData {
chartOfAccountData[i] = models.ExpenseAnalyticsChartOfAccountData{
ChartOfAccountID: item.ChartOfAccountID,
ChartOfAccountName: item.ChartOfAccountName,
TotalAmount: item.TotalAmount,
ExpenseCount: item.ExpenseCount,
ItemCount: item.ItemCount,
}
}
itemData := make([]models.ExpenseAnalyticsItemData, len(result.ItemData))
for i, item := range result.ItemData {
itemData[i] = models.ExpenseAnalyticsItemData{
Item: item.Item,
TotalAmount: item.TotalAmount,
ExpenseCount: item.ExpenseCount,
ItemCount: item.ItemCount,
}
}
return &models.ExpenseAnalyticsResponse{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
DateFrom: req.DateFrom,
DateTo: req.DateTo,
GroupBy: req.GroupBy,
Summary: models.ExpenseAnalyticsSummary{
TotalExpenses: result.Summary.TotalExpenses,
TotalExpenseCount: result.Summary.TotalExpenseCount,
TotalTax: result.Summary.TotalTax,
AverageExpenseValue: result.Summary.AverageExpenseValue,
TotalCategories: result.Summary.TotalCategories,
TotalItems: result.Summary.TotalItems,
},
Data: data,
CategoryData: categoryData,
ChartOfAccountData: chartOfAccountData,
ItemData: itemData,
}, nil
}
func (p *ExpenseProcessorImpl) validateExpensePurchaseCategory(ctx context.Context, categoryID, organizationID uuid.UUID) error {
category, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, categoryID, organizationID)
if err != nil {
return fmt.Errorf("purchase category not found: %w", err)
}
if !category.IsActive {
return fmt.Errorf("purchase category is inactive")
}
if category.Type != entities.PurchaseCategoryTypeExpense {
return fmt.Errorf("purchase category must be expense")
}
return nil
}

View File

@ -1,291 +0,0 @@
package processor
import (
"context"
"testing"
"time"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
)
type expenseRepositoryCaptureStub struct {
createdExpense *entities.Expense
createdItems []*entities.ExpenseItem
analytics *entities.ExpenseAnalytics
}
type expensePurchaseCategoryRepositoryStub struct {
category *entities.PurchaseCategory
}
func (*expensePurchaseCategoryRepositoryStub) Create(context.Context, *entities.PurchaseCategory) error {
return nil
}
func (s *expensePurchaseCategoryRepositoryStub) GetByIDAndOrganizationID(context.Context, uuid.UUID, uuid.UUID) (*entities.PurchaseCategory, error) {
return s.category, nil
}
func (*expensePurchaseCategoryRepositoryStub) Update(context.Context, *entities.PurchaseCategory) error {
return nil
}
func (*expensePurchaseCategoryRepositoryStub) SoftDelete(context.Context, uuid.UUID, uuid.UUID) error {
return nil
}
func (*expensePurchaseCategoryRepositoryStub) List(context.Context, uuid.UUID, map[string]interface{}, int, int) ([]*entities.PurchaseCategory, int64, error) {
return nil, 0, nil
}
func (*expensePurchaseCategoryRepositoryStub) ExistsByCode(context.Context, uuid.UUID, string, *uuid.UUID) (bool, error) {
return false, nil
}
func newExpensePurchaseCategoryRepo(categoryID uuid.UUID, categoryType entities.PurchaseCategoryType) *expensePurchaseCategoryRepositoryStub {
return &expensePurchaseCategoryRepositoryStub{
category: &entities.PurchaseCategory{
ID: categoryID,
Name: "Operational",
Type: categoryType,
IsActive: true,
},
}
}
func (s *expenseRepositoryCaptureStub) Create(_ context.Context, expense *entities.Expense) error {
if expense.ID == uuid.Nil {
expense.ID = uuid.New()
}
s.createdExpense = expense
return nil
}
func (s *expenseRepositoryCaptureStub) GetByID(context.Context, uuid.UUID) (*entities.Expense, error) {
if s.createdExpense == nil {
return nil, nil
}
items := make([]entities.ExpenseItem, len(s.createdItems))
for i, item := range s.createdItems {
items[i] = *item
}
s.createdExpense.Items = items
return s.createdExpense, nil
}
func (*expenseRepositoryCaptureStub) GetByIDAndOrganizationID(context.Context, uuid.UUID, uuid.UUID) (*entities.Expense, error) {
return nil, nil
}
func (*expenseRepositoryCaptureStub) Update(context.Context, *entities.Expense) error { return nil }
func (*expenseRepositoryCaptureStub) Delete(context.Context, uuid.UUID) error { return nil }
func (*expenseRepositoryCaptureStub) List(context.Context, uuid.UUID, map[string]interface{}, int, int) ([]*entities.Expense, int64, error) {
return nil, 0, nil
}
func (s *expenseRepositoryCaptureStub) GetAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time, string) (*entities.ExpenseAnalytics, error) {
return s.analytics, nil
}
func (s *expenseRepositoryCaptureStub) CreateItem(_ context.Context, item *entities.ExpenseItem) error {
if item.ID == uuid.Nil {
item.ID = uuid.New()
}
s.createdItems = append(s.createdItems, item)
return nil
}
func (*expenseRepositoryCaptureStub) DeleteItemsByExpenseID(context.Context, uuid.UUID) error {
return nil
}
func TestExpenseProcessorCreatePersistsItemName(t *testing.T) {
repo := &expenseRepositoryCaptureStub{}
purchaseCategoryID := uuid.New()
p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense))
chartOfAccountID := uuid.New()
resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{
Receiver: "Cashier",
TransactionDate: "2026-05-29",
CodeNumber: "EXP-001",
OutletID: uuid.NewString(),
Total: 10000,
Items: []models.CreateExpenseItemRequest{
{
ChartOfAccountID: chartOfAccountID.String(),
PurchaseCategoryID: purchaseCategoryID.String(),
Item: "Cleaning supplies",
Amount: 10000,
},
},
})
require.NoError(t, err)
require.NotNil(t, resp)
require.Len(t, repo.createdItems, 1)
require.Equal(t, "Cleaning supplies", repo.createdItems[0].Item)
require.Equal(t, purchaseCategoryID, repo.createdItems[0].PurchaseCategoryID)
require.Len(t, resp.Items, 1)
require.Equal(t, "Cleaning supplies", resp.Items[0].Item)
}
func TestExpenseProcessorCreateDefaultsStatusToDraft(t *testing.T) {
repo := &expenseRepositoryCaptureStub{}
purchaseCategoryID := uuid.New()
p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense))
resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{
Receiver: "Cashier",
TransactionDate: "2026-05-29",
CodeNumber: "EXP-001",
OutletID: uuid.NewString(),
Total: 10000,
Items: []models.CreateExpenseItemRequest{
{
ChartOfAccountID: uuid.NewString(),
PurchaseCategoryID: purchaseCategoryID.String(),
Item: "Cleaning supplies",
Amount: 10000,
},
},
})
require.NoError(t, err)
require.NotNil(t, resp)
require.Equal(t, "draft", repo.createdExpense.Status)
require.Equal(t, "draft", resp.Status)
}
func TestExpenseProcessorCreatePersistsProvidedStatus(t *testing.T) {
repo := &expenseRepositoryCaptureStub{}
purchaseCategoryID := uuid.New()
p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense))
status := "approved"
resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{
Receiver: "Cashier",
TransactionDate: "2026-05-29",
CodeNumber: "EXP-001",
OutletID: uuid.NewString(),
Status: &status,
Total: 10000,
Items: []models.CreateExpenseItemRequest{
{
ChartOfAccountID: uuid.NewString(),
PurchaseCategoryID: purchaseCategoryID.String(),
Item: "Cleaning supplies",
Amount: 10000,
},
},
})
require.NoError(t, err)
require.NotNil(t, resp)
require.Equal(t, "approved", repo.createdExpense.Status)
require.Equal(t, "approved", resp.Status)
}
func TestExpenseProcessorCreateRejectsRawMaterialPurchaseCategory(t *testing.T) {
repo := &expenseRepositoryCaptureStub{}
purchaseCategoryID := uuid.New()
p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeRawMaterial))
resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{
Receiver: "Cashier",
TransactionDate: "2026-05-29",
CodeNumber: "EXP-001",
OutletID: uuid.NewString(),
Total: 10000,
Items: []models.CreateExpenseItemRequest{
{
ChartOfAccountID: uuid.NewString(),
PurchaseCategoryID: purchaseCategoryID.String(),
Item: "Cleaning supplies",
Amount: 10000,
},
},
})
require.Error(t, err)
require.Nil(t, resp)
require.Contains(t, err.Error(), "expense")
}
func TestExpenseProcessorGetExpenseAnalyticsDefaultsGroupByAndMapsResponse(t *testing.T) {
coaID := uuid.New()
purchaseCategoryID := uuid.New()
outletID := uuid.New()
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
repo := &expenseRepositoryCaptureStub{
analytics: &entities.ExpenseAnalytics{
Summary: entities.ExpenseAnalyticsSummary{
TotalExpenses: 100000,
TotalExpenseCount: 2,
TotalTax: 10000,
AverageExpenseValue: 50000,
TotalCategories: 1,
TotalItems: 2,
},
Data: []entities.ExpenseAnalyticsData{
{
Date: now,
Expenses: 100000,
ExpenseCount: 2,
Tax: 10000,
Items: 2,
Categories: 1,
},
},
CategoryData: []entities.ExpenseAnalyticsCategoryData{
{
PurchaseCategoryID: purchaseCategoryID,
PurchaseCategoryName: "Operational Supplies",
PurchaseCategoryType: "expense",
TotalAmount: 100000,
ExpenseCount: 2,
ItemCount: 2,
},
},
ChartOfAccountData: []entities.ExpenseAnalyticsChartOfAccountData{
{
ChartOfAccountID: coaID,
ChartOfAccountName: "Operational",
TotalAmount: 100000,
ExpenseCount: 2,
ItemCount: 2,
},
},
ItemData: []entities.ExpenseAnalyticsItemData{
{
Item: "Cleaning supplies",
TotalAmount: 100000,
ExpenseCount: 2,
ItemCount: 2,
},
},
},
}
p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense))
resp, err := p.GetExpenseAnalytics(context.Background(), &models.ExpenseAnalyticsRequest{
OrganizationID: uuid.New(),
OutletID: &outletID,
DateFrom: now,
DateTo: now,
})
require.NoError(t, err)
require.NotNil(t, resp)
require.Equal(t, "day", resp.GroupBy)
require.Equal(t, &outletID, resp.OutletID)
require.Equal(t, float64(100000), resp.Summary.TotalExpenses)
require.Len(t, resp.Data, 1)
require.Equal(t, int64(2), resp.Data[0].ExpenseCount)
require.Len(t, resp.CategoryData, 1)
require.Equal(t, purchaseCategoryID, resp.CategoryData[0].PurchaseCategoryID)
require.Len(t, resp.ChartOfAccountData, 1)
require.Equal(t, coaID, resp.ChartOfAccountData[0].ChartOfAccountID)
require.Len(t, resp.ItemData, 1)
require.Equal(t, "Cleaning supplies", resp.ItemData[0].Item)
}

View File

@ -1,21 +0,0 @@
package processor
import (
"apskel-pos-be/internal/entities"
"context"
"time"
"github.com/google/uuid"
)
type ExpenseRepository interface {
Create(ctx context.Context, expense *entities.Expense) error
GetByID(ctx context.Context, id uuid.UUID) (*entities.Expense, error)
GetByIDAndOrganizationID(ctx context.Context, id, organizationID uuid.UUID) (*entities.Expense, error)
Update(ctx context.Context, expense *entities.Expense) error
Delete(ctx context.Context, id uuid.UUID) error
List(ctx context.Context, organizationID uuid.UUID, filters map[string]interface{}, limit, offset int) ([]*entities.Expense, int64, error)
GetAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) (*entities.ExpenseAnalytics, error)
CreateItem(ctx context.Context, item *entities.ExpenseItem) error
DeleteItemsByExpenseID(ctx context.Context, expenseID uuid.UUID) error
}

View File

@ -1,338 +0,0 @@
package processor
import (
"context"
"fmt"
"time"
"apskel-pos-be/internal/client"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/mappers"
"apskel-pos-be/internal/models"
"github.com/google/uuid"
)
// NotificationRepository is the interface the processor depends on.
type NotificationRepository interface {
Create(ctx context.Context, notification *entities.Notification) error
GetByID(ctx context.Context, id uuid.UUID) (*entities.Notification, error)
Update(ctx context.Context, notification *entities.Notification) error
Delete(ctx context.Context, id uuid.UUID) error
List(ctx context.Context, filters map[string]interface{}, limit, offset int) ([]*entities.Notification, int64, error)
}
// NotificationReceiverRepository is the interface the processor depends on.
type NotificationReceiverRepository interface {
Create(ctx context.Context, receiver *entities.NotificationReceiver) error
BulkCreate(ctx context.Context, receivers []*entities.NotificationReceiver) error
GetByID(ctx context.Context, id uuid.UUID) (*entities.NotificationReceiver, error)
GetByNotificationAndUser(ctx context.Context, notificationID, userID uuid.UUID) (*entities.NotificationReceiver, error)
Update(ctx context.Context, receiver *entities.NotificationReceiver) error
ListByUserID(ctx context.Context, userID uuid.UUID, isRead *bool, limit, offset int) ([]*entities.NotificationReceiver, int64, error)
CountUnreadByUserID(ctx context.Context, userID uuid.UUID) (int64, error)
SoftDeleteByID(ctx context.Context, id uuid.UUID) error
}
// NotificationDeliveryRepository is the interface the processor depends on.
type NotificationDeliveryRepository interface {
Create(ctx context.Context, delivery *entities.NotificationDelivery) error
BulkCreate(ctx context.Context, deliveries []*entities.NotificationDelivery) error
GetByID(ctx context.Context, id uuid.UUID) (*entities.NotificationDelivery, error)
Update(ctx context.Context, delivery *entities.NotificationDelivery) error
ListByReceiverID(ctx context.Context, receiverID uuid.UUID) ([]*entities.NotificationDelivery, error)
}
// NotificationUserRepository is a minimal interface to fetch user devices.
type NotificationUserDeviceRepository interface {
GetByUserID(ctx context.Context, userID uuid.UUID) ([]*entities.UserDevice, error)
}
// NotificationUserRepository is a minimal interface to fetch users by org.
type NotificationUserRepository interface {
GetActiveUsers(ctx context.Context, organizationID uuid.UUID) ([]*entities.User, error)
}
// NotificationProcessor defines the business logic interface.
type NotificationProcessor interface {
Send(ctx context.Context, req *models.SendNotificationRequest) (*models.NotificationResponse, error)
Broadcast(ctx context.Context, req *models.BroadcastNotificationRequest) (*models.NotificationResponse, error)
MarkAsRead(ctx context.Context, receiverID, userID uuid.UUID) (*models.NotificationReceiverResponse, error)
MarkAllAsRead(ctx context.Context, userID uuid.UUID) error
DeleteForUser(ctx context.Context, receiverID, userID uuid.UUID) error
ListForUser(ctx context.Context, req *models.ListNotificationsRequest) ([]*models.NotificationReceiverResponse, int64, int64, error)
GetByID(ctx context.Context, id uuid.UUID) (*models.NotificationResponse, error)
}
type NotificationProcessorImpl struct {
notificationRepo NotificationRepository
receiverRepo NotificationReceiverRepository
deliveryRepo NotificationDeliveryRepository
userDeviceRepo NotificationUserDeviceRepository
userRepo NotificationUserRepository
fcmClient client.FCMClient
}
func NewNotificationProcessor(
notificationRepo NotificationRepository,
receiverRepo NotificationReceiverRepository,
deliveryRepo NotificationDeliveryRepository,
userDeviceRepo NotificationUserDeviceRepository,
userRepo NotificationUserRepository,
fcmClient client.FCMClient,
) *NotificationProcessorImpl {
return &NotificationProcessorImpl{
notificationRepo: notificationRepo,
receiverRepo: receiverRepo,
deliveryRepo: deliveryRepo,
userDeviceRepo: userDeviceRepo,
userRepo: userRepo,
fcmClient: fcmClient,
}
}
// Send creates a notification and dispatches it to the given receiver user IDs via FCM.
func (p *NotificationProcessorImpl) Send(ctx context.Context, req *models.SendNotificationRequest) (*models.NotificationResponse, error) {
if len(req.ReceiverIDs) == 0 {
return nil, fmt.Errorf("at least one receiver_id is required")
}
notification := &entities.Notification{
Title: req.Title,
Body: req.Body,
Type: req.Type,
Category: req.Category,
Priority: req.Priority,
ImageURL: req.ImageURL,
ActionURL: req.ActionURL,
NotifiableType: req.NotifiableType,
NotifiableID: req.NotifiableID,
Data: req.Data,
ScheduledAt: req.ScheduledAt,
ExpiredAt: req.ExpiredAt,
CreatedBy: req.CreatedBy,
}
if err := p.notificationRepo.Create(ctx, notification); err != nil {
return nil, fmt.Errorf("failed to create notification: %w", err)
}
// Create receiver records and dispatch FCM per user.
for _, userID := range req.ReceiverIDs {
receiver := &entities.NotificationReceiver{
NotificationID: notification.ID,
UserID: userID,
}
if err := p.receiverRepo.Create(ctx, receiver); err != nil {
// Log but continue for other receivers.
continue
}
p.dispatchFCMToUser(ctx, receiver, notification)
}
// Mark notification as sent.
now := time.Now()
notification.SentAt = &now
_ = p.notificationRepo.Update(ctx, notification)
return mappers.NotificationEntityToResponse(notification), nil
}
// Broadcast sends a notification to all active users in an organization.
func (p *NotificationProcessorImpl) Broadcast(ctx context.Context, req *models.BroadcastNotificationRequest) (*models.NotificationResponse, error) {
users, err := p.userRepo.GetActiveUsers(ctx, req.OrganizationID)
if err != nil {
return nil, fmt.Errorf("failed to fetch organization users: %w", err)
}
notification := &entities.Notification{
Title: req.Title,
Body: req.Body,
Type: req.Type,
Category: req.Category,
Priority: req.Priority,
ImageURL: req.ImageURL,
ActionURL: req.ActionURL,
NotifiableType: req.NotifiableType,
NotifiableID: req.NotifiableID,
Data: req.Data,
ScheduledAt: req.ScheduledAt,
ExpiredAt: req.ExpiredAt,
CreatedBy: req.CreatedBy,
}
if err := p.notificationRepo.Create(ctx, notification); err != nil {
return nil, fmt.Errorf("failed to create notification: %w", err)
}
// Build receiver records in bulk.
receivers := make([]*entities.NotificationReceiver, 0, len(users))
for _, u := range users {
receivers = append(receivers, &entities.NotificationReceiver{
NotificationID: notification.ID,
UserID: u.ID,
})
}
if err := p.receiverRepo.BulkCreate(ctx, receivers); err != nil {
return nil, fmt.Errorf("failed to create notification receivers: %w", err)
}
// Dispatch FCM for each receiver.
for _, receiver := range receivers {
p.dispatchFCMToUser(ctx, receiver, notification)
}
now := time.Now()
notification.SentAt = &now
_ = p.notificationRepo.Update(ctx, notification)
return mappers.NotificationEntityToResponse(notification), nil
}
// MarkAsRead marks a single notification receiver record as read.
func (p *NotificationProcessorImpl) MarkAsRead(ctx context.Context, receiverID, userID uuid.UUID) (*models.NotificationReceiverResponse, error) {
receiver, err := p.receiverRepo.GetByID(ctx, receiverID)
if err != nil {
return nil, fmt.Errorf("notification not found: %w", err)
}
if receiver.UserID != userID {
return nil, fmt.Errorf("unauthorized: notification does not belong to user")
}
if !receiver.IsRead {
now := time.Now()
receiver.IsRead = true
receiver.ReadAt = &now
if err := p.receiverRepo.Update(ctx, receiver); err != nil {
return nil, fmt.Errorf("failed to mark notification as read: %w", err)
}
}
return mappers.NotificationReceiverEntityToResponse(receiver), nil
}
// MarkAllAsRead marks all unread notifications for a user as read.
func (p *NotificationProcessorImpl) MarkAllAsRead(ctx context.Context, userID uuid.UUID) error {
isRead := false
receivers, _, err := p.receiverRepo.ListByUserID(ctx, userID, &isRead, 1000, 0)
if err != nil {
return fmt.Errorf("failed to fetch unread notifications: %w", err)
}
now := time.Now()
for _, r := range receivers {
r.IsRead = true
r.ReadAt = &now
_ = p.receiverRepo.Update(ctx, r)
}
return nil
}
// DeleteForUser soft-deletes a notification receiver record for a user.
func (p *NotificationProcessorImpl) DeleteForUser(ctx context.Context, receiverID, userID uuid.UUID) error {
receiver, err := p.receiverRepo.GetByID(ctx, receiverID)
if err != nil {
return fmt.Errorf("notification not found: %w", err)
}
if receiver.UserID != userID {
return fmt.Errorf("unauthorized: notification does not belong to user")
}
return p.receiverRepo.SoftDeleteByID(ctx, receiverID)
}
// ListForUser returns paginated notifications for a user.
// Returns: receivers, total, unreadCount, error
func (p *NotificationProcessorImpl) ListForUser(ctx context.Context, req *models.ListNotificationsRequest) ([]*models.NotificationReceiverResponse, int64, int64, error) {
offset := (req.Page - 1) * req.Limit
receivers, total, err := p.receiverRepo.ListByUserID(ctx, req.UserID, req.IsRead, req.Limit, offset)
if err != nil {
return nil, 0, 0, fmt.Errorf("failed to list notifications: %w", err)
}
unreadCount, err := p.receiverRepo.CountUnreadByUserID(ctx, req.UserID)
if err != nil {
unreadCount = 0
}
responses := mappers.NotificationReceiverEntitiesToResponses(receivers)
return responses, total, unreadCount, nil
}
// GetByID returns a single notification by its ID.
func (p *NotificationProcessorImpl) GetByID(ctx context.Context, id uuid.UUID) (*models.NotificationResponse, error) {
notification, err := p.notificationRepo.GetByID(ctx, id)
if err != nil {
return nil, fmt.Errorf("notification not found: %w", err)
}
return mappers.NotificationEntityToResponse(notification), nil
}
// dispatchFCMToUser fetches all FCM tokens for a user and sends the push notification.
func (p *NotificationProcessorImpl) dispatchFCMToUser(ctx context.Context, receiver *entities.NotificationReceiver, notification *entities.Notification) {
if p.fcmClient == nil {
return
}
devices, err := p.userDeviceRepo.GetByUserID(ctx, receiver.UserID)
if err != nil || len(devices) == 0 {
return
}
// Build FCM data payload.
data := map[string]string{
"notification_id": notification.ID.String(),
"notification_receiver_id": receiver.ID.String(),
"type": notification.Type,
"category": notification.Category,
"action_url": notification.ActionURL,
}
// Collect valid FCM tokens and create delivery records.
tokens := make([]string, 0, len(devices))
deliveries := make([]*entities.NotificationDelivery, 0, len(devices))
for _, device := range devices {
if device.FCMToken == "" {
continue
}
tokens = append(tokens, device.FCMToken)
deliveries = append(deliveries, &entities.NotificationDelivery{
NotificationReceiverID: receiver.ID,
UserDeviceID: device.ID,
Channel: entities.NotificationChannelPush,
DeliveryStatus: entities.NotificationDeliveryStatusPending,
Provider: entities.NotificationProviderFirebase,
})
}
if len(tokens) == 0 {
return
}
// Persist delivery records before sending.
_ = p.deliveryRepo.BulkCreate(ctx, deliveries)
// Send via FCM multicast.
now := time.Now()
sendErr := p.fcmClient.SendMulticastNotification(ctx, tokens, notification.Title, notification.Body, data)
// Update delivery status.
for _, delivery := range deliveries {
if sendErr != nil {
delivery.DeliveryStatus = entities.NotificationDeliveryStatusFailed
delivery.FailedAt = &now
delivery.FailureReason = sendErr.Error()
} else {
delivery.DeliveryStatus = entities.NotificationDeliveryStatusSent
delivery.SentAt = &now
}
_ = p.deliveryRepo.Update(ctx, delivery)
}
}

View File

@ -1,6 +1,7 @@
package processor package processor
import ( import (
"apskel-pos-be/internal/constants"
"context" "context"
"errors" "errors"
"fmt" "fmt"
@ -86,7 +87,7 @@ type CustomerRepository interface {
} }
type InventoryMovementService interface { type InventoryMovementService interface {
CreateIngredientMovement(ctx context.Context, ingredientID, organizationID, outletID, userID uuid.UUID, movementType entities.InventoryMovementType, quantity float64, unitCost float64, reason string, referenceType *entities.InventoryMovementReferenceType, referenceID *uuid.UUID, purchaseOrderItemID *uuid.UUID) error CreateIngredientMovement(ctx context.Context, ingredientID, organizationID, outletID, userID uuid.UUID, movementType entities.InventoryMovementType, quantity float64, unitCost float64, reason string, referenceType *entities.InventoryMovementReferenceType, referenceID *uuid.UUID) error
CreateProductMovement(ctx context.Context, productID, organizationID, outletID, userID uuid.UUID, movementType entities.InventoryMovementType, quantity float64, unitCost float64, reason string, referenceType *entities.InventoryMovementReferenceType, referenceID *uuid.UUID) error CreateProductMovement(ctx context.Context, productID, organizationID, outletID, userID uuid.UUID, movementType entities.InventoryMovementType, quantity float64, unitCost float64, reason string, referenceType *entities.InventoryMovementReferenceType, referenceID *uuid.UUID) error
} }
@ -107,7 +108,6 @@ type OrderProcessorImpl struct {
productRecipeRepo *repository.ProductRecipeRepository productRecipeRepo *repository.ProductRecipeRepository
ingredientRepo IngredientRepository ingredientRepo IngredientRepository
inventoryMovementService InventoryMovementService inventoryMovementService InventoryMovementService
productOutletPriceRepo repository.ProductOutletPriceRepository
} }
func NewOrderProcessorImpl( func NewOrderProcessorImpl(
@ -126,7 +126,6 @@ func NewOrderProcessorImpl(
productRecipeRepo *repository.ProductRecipeRepository, productRecipeRepo *repository.ProductRecipeRepository,
ingredientRepo IngredientRepository, ingredientRepo IngredientRepository,
inventoryMovementService InventoryMovementService, inventoryMovementService InventoryMovementService,
productOutletPriceRepo repository.ProductOutletPriceRepository,
) *OrderProcessorImpl { ) *OrderProcessorImpl {
return &OrderProcessorImpl{ return &OrderProcessorImpl{
orderRepo: orderRepo, orderRepo: orderRepo,
@ -145,7 +144,6 @@ func NewOrderProcessorImpl(
productRecipeRepo: productRecipeRepo, productRecipeRepo: productRecipeRepo,
ingredientRepo: ingredientRepo, ingredientRepo: ingredientRepo,
inventoryMovementService: inventoryMovementService, inventoryMovementService: inventoryMovementService,
productOutletPriceRepo: productOutletPriceRepo,
} }
} }
@ -172,12 +170,6 @@ func (p *OrderProcessorImpl) CreateOrder(ctx context.Context, req *models.Create
unitPrice := product.Price unitPrice := product.Price
unitCost := product.Cost unitCost := product.Cost
if p.productOutletPriceRepo != nil {
if outletPrice, err := p.productOutletPriceRepo.GetByProductAndOutlet(ctx, itemReq.ProductID, req.OutletID); err == nil {
unitPrice = outletPrice.Price
}
}
if itemReq.ProductVariantID != nil { if itemReq.ProductVariantID != nil {
variant, err := p.productVariantRepo.GetByID(ctx, *itemReq.ProductVariantID) variant, err := p.productVariantRepo.GetByID(ctx, *itemReq.ProductVariantID)
if err != nil { if err != nil {
@ -301,12 +293,6 @@ func (p *OrderProcessorImpl) AddToOrder(ctx context.Context, orderID uuid.UUID,
unitPrice := product.Price unitPrice := product.Price
unitCost := product.Cost unitCost := product.Cost
if p.productOutletPriceRepo != nil {
if outletPrice, err := p.productOutletPriceRepo.GetByProductAndOutlet(ctx, itemReq.ProductID, order.OutletID); err == nil {
unitPrice = outletPrice.Price
}
}
// Handle product variant if specified // Handle product variant if specified
if itemReq.ProductVariantID != nil { if itemReq.ProductVariantID != nil {
variant, err := p.productVariantRepo.GetByID(ctx, *itemReq.ProductVariantID) variant, err := p.productVariantRepo.GetByID(ctx, *itemReq.ProductVariantID)
@ -338,7 +324,7 @@ func (p *OrderProcessorImpl) AddToOrder(ctx context.Context, orderID uuid.UUID,
ProductID: itemReq.ProductID, ProductID: itemReq.ProductID,
ProductVariantID: itemReq.ProductVariantID, ProductVariantID: itemReq.ProductVariantID,
Quantity: itemReq.Quantity, Quantity: itemReq.Quantity,
UnitPrice: unitPrice, UnitPrice: unitPrice, // Use price from database
TotalPrice: itemTotalPrice, TotalPrice: itemTotalPrice,
UnitCost: unitCost, UnitCost: unitCost,
TotalCost: itemTotalCost, TotalCost: itemTotalCost,
@ -387,10 +373,31 @@ func (p *OrderProcessorImpl) AddToOrder(ctx context.Context, orderID uuid.UUID,
return nil, fmt.Errorf("failed to create order item: %w", err) return nil, fmt.Errorf("failed to create order item: %w", err)
} }
itemResponse := mappers.OrderItemEntityToResponse(orderItem, order.OutletID) itemResponse := models.OrderItemResponse{
if itemResponse != nil { ID: orderItem.ID,
addedItemResponses = append(addedItemResponses, *itemResponse) OrderID: orderItem.OrderID,
ProductID: orderItem.ProductID,
ProductVariantID: orderItem.ProductVariantID,
Quantity: orderItem.Quantity,
UnitPrice: orderItem.UnitPrice,
TotalPrice: orderItem.TotalPrice,
UnitCost: orderItem.UnitCost,
TotalCost: orderItem.TotalCost,
RefundAmount: orderItem.RefundAmount,
RefundQuantity: orderItem.RefundQuantity,
IsPartiallyRefunded: orderItem.IsPartiallyRefunded,
IsFullyRefunded: orderItem.IsFullyRefunded,
RefundReason: orderItem.RefundReason,
RefundedAt: orderItem.RefundedAt,
RefundedBy: orderItem.RefundedBy,
Modifiers: []map[string]interface{}(orderItem.Modifiers),
Notes: orderItem.Notes,
Metadata: map[string]interface{}(orderItem.Metadata),
Status: constants.OrderItemStatus(orderItem.Status),
CreatedAt: orderItem.CreatedAt,
UpdatedAt: orderItem.UpdatedAt,
} }
addedItemResponses = append(addedItemResponses, itemResponse)
} }
orderWithRelations, err := p.orderRepo.GetWithRelations(ctx, orderID) orderWithRelations, err := p.orderRepo.GetWithRelations(ctx, orderID)
@ -594,10 +601,6 @@ func (p *OrderProcessorImpl) VoidOrder(ctx context.Context, req *models.VoidOrde
return fmt.Errorf("order item does not belong to this order") return fmt.Errorf("order item does not belong to this order")
} }
if orderItem.Status == entities.OrderItemStatusCancelled {
return fmt.Errorf("order item %s is already cancelled", orderItemID)
}
if itemVoid.Quantity > orderItem.Quantity { if itemVoid.Quantity > orderItem.Quantity {
return fmt.Errorf("void quantity cannot exceed original quantity for item %d", itemVoid.OrderItemID) return fmt.Errorf("void quantity cannot exceed original quantity for item %d", itemVoid.OrderItemID)
} }
@ -618,15 +621,9 @@ func (p *OrderProcessorImpl) VoidOrder(ctx context.Context, req *models.VoidOrde
return fmt.Errorf("outlet not found: %w", err) return fmt.Errorf("outlet not found: %w", err)
} }
// Reload order to get latest state
order, err = p.orderRepo.GetByID(ctx, req.OrderID)
if err != nil {
return fmt.Errorf("failed to reload order: %w", err)
}
order.Subtotal -= totalVoidedAmount order.Subtotal -= totalVoidedAmount
order.TotalCost -= totalVoidedCost order.TotalCost -= totalVoidedCost
order.TaxAmount = order.Subtotal * outlet.TaxRate order.TaxAmount = order.Subtotal * outlet.TaxRate // Recalculate tax using outlet's tax rate
order.TotalAmount = order.Subtotal + order.TaxAmount - order.DiscountAmount order.TotalAmount = order.Subtotal + order.TaxAmount - order.DiscountAmount
if err := p.orderRepo.Update(ctx, order); err != nil { if err := p.orderRepo.Update(ctx, order); err != nil {

View File

@ -1,122 +0,0 @@
package processor
import (
"context"
"fmt"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/mappers"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
"github.com/google/uuid"
)
type ProductOutletPriceProcessor interface {
Upsert(ctx context.Context, req *models.CreateProductOutletPriceRequest) (*models.ProductOutletPrice, error)
GetByProductAndOutlet(ctx context.Context, productID, outletID uuid.UUID) (*models.ProductOutletPrice, error)
GetByProduct(ctx context.Context, productID uuid.UUID) ([]*models.ProductOutletPrice, error)
GetByOutlet(ctx context.Context, outletID uuid.UUID) ([]*models.ProductOutletPrice, error)
Delete(ctx context.Context, id uuid.UUID) error
ResolvePrice(ctx context.Context, productID, outletID uuid.UUID, fallbackPrice float64) float64
BulkUpsert(ctx context.Context, productID uuid.UUID, prices []models.CreateProductOutletPriceRequest) ([]*models.ProductOutletPrice, error)
}
type ProductOutletPriceProcessorImpl struct {
repo repository.ProductOutletPriceRepository
productRepo ProductRepository
outletRepo OutletRepository
}
func NewProductOutletPriceProcessorImpl(repo repository.ProductOutletPriceRepository, productRepo ProductRepository, outletRepo OutletRepository) *ProductOutletPriceProcessorImpl {
return &ProductOutletPriceProcessorImpl{
repo: repo,
productRepo: productRepo,
outletRepo: outletRepo,
}
}
func (p *ProductOutletPriceProcessorImpl) Upsert(ctx context.Context, req *models.CreateProductOutletPriceRequest) (*models.ProductOutletPrice, error) {
if _, err := p.productRepo.GetByID(ctx, req.ProductID); err != nil {
return nil, fmt.Errorf("product not found: %w", err)
}
if _, err := p.outletRepo.GetByID(ctx, req.OutletID); err != nil {
return nil, fmt.Errorf("outlet not found: %w", err)
}
entity := &entities.ProductOutletPrice{
ProductID: req.ProductID,
OutletID: req.OutletID,
Price: req.Price,
PrintToChecker: req.PrintToChecker,
}
if err := p.repo.Upsert(ctx, entity); err != nil {
return nil, fmt.Errorf("failed to upsert product outlet price: %w", err)
}
actual, err := p.repo.GetByProductAndOutlet(ctx, req.ProductID, req.OutletID)
if err != nil {
return nil, fmt.Errorf("failed to retrieve upserted product outlet price: %w", err)
}
return mappers.ProductOutletPriceEntityToModel(actual), nil
}
func (p *ProductOutletPriceProcessorImpl) GetByProductAndOutlet(ctx context.Context, productID, outletID uuid.UUID) (*models.ProductOutletPrice, error) {
entity, err := p.repo.GetByProductAndOutlet(ctx, productID, outletID)
if err != nil {
return nil, fmt.Errorf("product outlet price not found: %w", err)
}
return mappers.ProductOutletPriceEntityToModel(entity), nil
}
func (p *ProductOutletPriceProcessorImpl) GetByProduct(ctx context.Context, productID uuid.UUID) ([]*models.ProductOutletPrice, error) {
entities, err := p.repo.GetByProduct(ctx, productID)
if err != nil {
return nil, fmt.Errorf("failed to get product outlet prices: %w", err)
}
return mappers.ProductOutletPriceEntitiesToModels(entities), nil
}
func (p *ProductOutletPriceProcessorImpl) GetByOutlet(ctx context.Context, outletID uuid.UUID) ([]*models.ProductOutletPrice, error) {
entities, err := p.repo.GetByOutlet(ctx, outletID)
if err != nil {
return nil, fmt.Errorf("failed to get outlet prices: %w", err)
}
return mappers.ProductOutletPriceEntitiesToModels(entities), nil
}
func (p *ProductOutletPriceProcessorImpl) Delete(ctx context.Context, id uuid.UUID) error {
if err := p.repo.Delete(ctx, id); err != nil {
return fmt.Errorf("failed to delete product outlet price: %w", err)
}
return nil
}
func (p *ProductOutletPriceProcessorImpl) ResolvePrice(ctx context.Context, productID, outletID uuid.UUID, fallbackPrice float64) float64 {
outletPrice, err := p.repo.GetByProductAndOutlet(ctx, productID, outletID)
if err != nil {
return fallbackPrice
}
return outletPrice.Price
}
func (p *ProductOutletPriceProcessorImpl) BulkUpsert(ctx context.Context, productID uuid.UUID, prices []models.CreateProductOutletPriceRequest) ([]*models.ProductOutletPrice, error) {
var results []*models.ProductOutletPrice
for _, req := range prices {
req.ProductID = productID
result, err := p.Upsert(ctx, &req)
if err != nil {
return nil, fmt.Errorf("failed to upsert price for outlet %s: %w", req.OutletID, err)
}
results = append(results, result)
}
return results, nil
}

View File

@ -5,7 +5,6 @@ import (
"fmt" "fmt"
"apskel-pos-be/internal/entities" "apskel-pos-be/internal/entities"
"apskel-pos-be/internal/logger"
"apskel-pos-be/internal/mappers" "apskel-pos-be/internal/mappers"
"apskel-pos-be/internal/models" "apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository" "apskel-pos-be/internal/repository"
@ -17,9 +16,8 @@ type ProductProcessor interface {
CreateProduct(ctx context.Context, req *models.CreateProductRequest) (*models.ProductResponse, error) CreateProduct(ctx context.Context, req *models.CreateProductRequest) (*models.ProductResponse, error)
UpdateProduct(ctx context.Context, id uuid.UUID, req *models.UpdateProductRequest) (*models.ProductResponse, error) UpdateProduct(ctx context.Context, id uuid.UUID, req *models.UpdateProductRequest) (*models.ProductResponse, error)
DeleteProduct(ctx context.Context, id uuid.UUID) error DeleteProduct(ctx context.Context, id uuid.UUID) error
GetProductByID(ctx context.Context, id uuid.UUID, outletID uuid.UUID) (*models.ProductResponse, error) GetProductByID(ctx context.Context, id uuid.UUID) (*models.ProductResponse, error)
ListProducts(ctx context.Context, filters map[string]interface{}, page, limit int) ([]models.ProductResponse, int, error) ListProducts(ctx context.Context, filters map[string]interface{}, page, limit int) ([]models.ProductResponse, int, error)
ListProductsAll(ctx context.Context, filters map[string]interface{}, page, limit int) ([]models.ProductResponse, int, error)
} }
type ProductRepository interface { type ProductRepository interface {
@ -34,13 +32,11 @@ type ProductRepository interface {
Update(ctx context.Context, product *entities.Product) error Update(ctx context.Context, product *entities.Product) error
Delete(ctx context.Context, id uuid.UUID) error Delete(ctx context.Context, id uuid.UUID) error
List(ctx context.Context, filters map[string]interface{}, limit, offset int) ([]*entities.Product, int64, error) List(ctx context.Context, filters map[string]interface{}, limit, offset int) ([]*entities.Product, int64, error)
ListWithOutletPrice(ctx context.Context, filters map[string]interface{}, outletID uuid.UUID, limit, offset int) ([]*entities.Product, int64, error)
Count(ctx context.Context, filters map[string]interface{}) (int64, error) Count(ctx context.Context, filters map[string]interface{}) (int64, error)
GetBySKU(ctx context.Context, organizationID uuid.UUID, sku string) (*entities.Product, error) GetBySKU(ctx context.Context, organizationID uuid.UUID, sku string) (*entities.Product, error)
ExistsBySKU(ctx context.Context, organizationID uuid.UUID, sku string, excludeID *uuid.UUID) (bool, error) ExistsBySKU(ctx context.Context, organizationID uuid.UUID, sku string, excludeID *uuid.UUID) (bool, error)
GetByName(ctx context.Context, organizationID uuid.UUID, name string) (*entities.Product, error) GetByName(ctx context.Context, organizationID uuid.UUID, name string) (*entities.Product, error)
ExistsByName(ctx context.Context, organizationID uuid.UUID, name string, excludeID *uuid.UUID) (bool, error) ExistsByName(ctx context.Context, organizationID uuid.UUID, name string, excludeID *uuid.UUID) (bool, error)
ExistsByNameInOutlet(ctx context.Context, organizationID uuid.UUID, outletID uuid.UUID, name string, excludeID *uuid.UUID) (bool, error)
UpdateActiveStatus(ctx context.Context, id uuid.UUID, isActive bool) error UpdateActiveStatus(ctx context.Context, id uuid.UUID, isActive bool) error
GetLowCostProducts(ctx context.Context, organizationID uuid.UUID, maxCost float64) ([]*entities.Product, error) GetLowCostProducts(ctx context.Context, organizationID uuid.UUID, maxCost float64) ([]*entities.Product, error)
} }
@ -51,17 +47,15 @@ type ProductProcessorImpl struct {
productVariantRepo repository.ProductVariantRepository productVariantRepo repository.ProductVariantRepository
inventoryRepo repository.InventoryRepository inventoryRepo repository.InventoryRepository
outletRepo OutletRepository outletRepo OutletRepository
outletPriceRepo repository.ProductOutletPriceRepository
} }
func NewProductProcessorImpl(productRepo ProductRepository, categoryRepo CategoryRepository, productVariantRepo repository.ProductVariantRepository, inventoryRepo repository.InventoryRepository, outletRepo OutletRepository, outletPriceRepo repository.ProductOutletPriceRepository) *ProductProcessorImpl { func NewProductProcessorImpl(productRepo ProductRepository, categoryRepo CategoryRepository, productVariantRepo repository.ProductVariantRepository, inventoryRepo repository.InventoryRepository, outletRepo OutletRepository) *ProductProcessorImpl {
return &ProductProcessorImpl{ return &ProductProcessorImpl{
productRepo: productRepo, productRepo: productRepo,
categoryRepo: categoryRepo, categoryRepo: categoryRepo,
productVariantRepo: productVariantRepo, productVariantRepo: productVariantRepo,
inventoryRepo: inventoryRepo, inventoryRepo: inventoryRepo,
outletRepo: outletRepo, outletRepo: outletRepo,
outletPriceRepo: outletPriceRepo,
} }
} }
@ -81,12 +75,12 @@ func (p *ProductProcessorImpl) CreateProduct(ctx context.Context, req *models.Cr
} }
} }
exists, err := p.productRepo.ExistsByNameInOutlet(ctx, req.OrganizationID, req.OutletID, req.Name, nil) exists, err := p.productRepo.ExistsByName(ctx, req.OrganizationID, req.Name, nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to check product name uniqueness: %w", err) return nil, fmt.Errorf("failed to check product name uniqueness: %w", err)
} }
if exists { if exists {
return nil, fmt.Errorf("product with name '%s' already exists for this outlet", req.Name) return nil, fmt.Errorf("product with name '%s' already exists for this organization", req.Name)
} }
productEntity := mappers.CreateProductRequestToEntity(req) productEntity := mappers.CreateProductRequestToEntity(req)
@ -124,23 +118,6 @@ func (p *ProductProcessorImpl) CreateProduct(ctx context.Context, req *models.Cr
} }
} }
// Upsert outlet-specific price if outlet context is present
if req.OutletID != uuid.Nil {
printToChecker := true // default
if req.PrintToChecker != nil {
printToChecker = *req.PrintToChecker
}
outletPriceEntity := &entities.ProductOutletPrice{
ProductID: productEntity.ID,
OutletID: req.OutletID,
Price: req.Price,
PrintToChecker: printToChecker,
}
if err := p.outletPriceRepo.Upsert(ctx, outletPriceEntity); err != nil {
return nil, fmt.Errorf("failed to assign outlet price: %w", err)
}
}
productWithCategory, err := p.productRepo.GetWithCategory(ctx, productEntity.ID) productWithCategory, err := p.productRepo.GetWithCategory(ctx, productEntity.ID)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to retrieve created product: %w", err) return nil, fmt.Errorf("failed to retrieve created product: %w", err)
@ -180,12 +157,12 @@ func (p *ProductProcessorImpl) UpdateProduct(ctx context.Context, id uuid.UUID,
} }
if req.Name != nil && *req.Name != existingProduct.Name { if req.Name != nil && *req.Name != existingProduct.Name {
exists, err := p.productRepo.ExistsByNameInOutlet(ctx, existingProduct.OrganizationID, req.OutletID, *req.Name, &id) exists, err := p.productRepo.ExistsByName(ctx, existingProduct.OrganizationID, *req.Name, &id)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to check product name uniqueness: %w", err) return nil, fmt.Errorf("failed to check product name uniqueness: %w", err)
} }
if exists { if exists {
return nil, fmt.Errorf("product with name '%s' already exists for this outlet", *req.Name) return nil, fmt.Errorf("product with name '%s' already exists for this organization", *req.Name)
} }
} }
@ -202,41 +179,6 @@ func (p *ProductProcessorImpl) UpdateProduct(ctx context.Context, id uuid.UUID,
} }
} }
// Upsert outlet-specific price if outlet context is present and price or print_to_checker is provided
if req.OutletID != uuid.Nil && (req.Price != nil || req.PrintToChecker != nil) {
// Fetch existing outlet price to use as fallback for fields not provided
existing, _ := p.outletPriceRepo.GetByProductAndOutlet(ctx, id, req.OutletID)
price := float64(0)
if existing != nil {
price = existing.Price
}
if req.Price != nil {
price = *req.Price
}
printToChecker := true // default
if existing != nil {
printToChecker = existing.PrintToChecker
}
if req.PrintToChecker != nil {
printToChecker = *req.PrintToChecker
}
outletPriceEntity := &entities.ProductOutletPrice{
ProductID: id,
OutletID: req.OutletID,
Price: price,
PrintToChecker: printToChecker,
}
logger.FromContext(ctx).Infof("ProductProcessor::UpdateProduct -> upserting outlet price: productID=%s outletID=%s price=%f printToChecker=%v", id, req.OutletID, price, printToChecker)
if err := p.outletPriceRepo.Upsert(ctx, outletPriceEntity); err != nil {
return nil, fmt.Errorf("failed to assign outlet price: %w", err)
}
} else {
logger.FromContext(ctx).Infof("ProductProcessor::UpdateProduct -> skipping outlet price upsert: outletID=%s price=%v printToChecker=%v", req.OutletID, req.Price, req.PrintToChecker)
}
productWithCategory, err := p.productRepo.GetWithCategory(ctx, id) productWithCategory, err := p.productRepo.GetWithCategory(ctx, id)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to retrieve updated product: %w", err) return nil, fmt.Errorf("failed to retrieve updated product: %w", err)
@ -272,106 +214,19 @@ func (p *ProductProcessorImpl) DeleteProduct(ctx context.Context, id uuid.UUID)
return nil return nil
} }
func (p *ProductProcessorImpl) GetProductByID(ctx context.Context, id uuid.UUID, outletID uuid.UUID) (*models.ProductResponse, error) { func (p *ProductProcessorImpl) GetProductByID(ctx context.Context, id uuid.UUID) (*models.ProductResponse, error) {
productEntity, err := p.productRepo.GetWithCategory(ctx, id) productEntity, err := p.productRepo.GetWithCategory(ctx, id)
if err != nil { if err != nil {
return nil, fmt.Errorf("product not found: %w", err) return nil, fmt.Errorf("product not found: %w", err)
} }
response := mappers.ProductEntityToResponse(productEntity) response := mappers.ProductEntityToResponse(productEntity)
if outletID != uuid.Nil {
// Attach outlet-specific price
outletPrice, err := p.outletPriceRepo.GetByProductAndOutlet(ctx, id, outletID)
if err == nil {
response.OutletPrice = &outletPrice.Price
response.PrintToChecker = outletPrice.PrintToChecker
}
} else {
// No outlet context — return all outlet prices for this product
outletPrices, err := p.outletPriceRepo.GetByProductWithOutlet(ctx, id)
if err == nil && len(outletPrices) > 0 {
prices := make([]models.OutletPrice, len(outletPrices))
for i, op := range outletPrices {
prices[i] = models.OutletPrice{
OutletID: op.OutletID,
OutletName: op.Outlet.Name,
Price: op.Price,
PrintToChecker: op.PrintToChecker,
}
}
response.OutletPrices = prices
}
}
return response, nil return response, nil
} }
func (p *ProductProcessorImpl) ListProducts(ctx context.Context, filters map[string]interface{}, page, limit int) ([]models.ProductResponse, int, error) { func (p *ProductProcessorImpl) ListProducts(ctx context.Context, filters map[string]interface{}, page, limit int) ([]models.ProductResponse, int, error) {
offset := (page - 1) * limit offset := (page - 1) * limit
// Extract outletID from filters — it's not a products column so remove it before querying
var outletID uuid.UUID
if oid, ok := filters["outlet_id"]; ok {
outletID = oid.(uuid.UUID)
delete(filters, "outlet_id")
}
// Use the JOIN-based query when an outlet is specified so we get outlet-specific
// prices in a single round-trip; fall back to the plain List otherwise.
var (
productEntities []*entities.Product
total int64
err error
)
if outletID != uuid.Nil {
productEntities, total, err = p.productRepo.ListWithOutletPrice(ctx, filters, outletID, limit, offset)
} else {
productEntities, total, err = p.productRepo.List(ctx, filters, limit, offset)
}
if err != nil {
return nil, 0, fmt.Errorf("failed to list products: %w", err)
}
responses := make([]models.ProductResponse, len(productEntities))
if outletID != uuid.Nil && len(productEntities) > 0 {
// Bulk-fetch outlet prices to populate OutletPrice and PrintToChecker per product
productIDs := make([]uuid.UUID, len(productEntities))
for i, e := range productEntities {
productIDs[i] = e.ID
}
outletPrices, opErr := p.outletPriceRepo.GetByProductsAndOutlet(ctx, productIDs, outletID)
priceMap := make(map[uuid.UUID]*entities.ProductOutletPrice)
if opErr == nil {
for _, op := range outletPrices {
priceMap[op.ProductID] = op
}
}
for i, entity := range productEntities {
response := mappers.ProductEntityToResponse(entity)
if response != nil {
if op, ok := priceMap[entity.ID]; ok {
response.OutletPrice = &op.Price
response.PrintToChecker = op.PrintToChecker
}
responses[i] = *response
}
}
} else {
for i, entity := range productEntities {
response := mappers.ProductEntityToResponse(entity)
if response != nil {
responses[i] = *response
}
}
}
return responses, int(total), nil
}
func (p *ProductProcessorImpl) ListProductsAll(ctx context.Context, filters map[string]interface{}, page, limit int) ([]models.ProductResponse, int, error) {
offset := (page - 1) * limit
productEntities, total, err := p.productRepo.List(ctx, filters, limit, offset) productEntities, total, err := p.productRepo.List(ctx, filters, limit, offset)
if err != nil { if err != nil {
return nil, 0, fmt.Errorf("failed to list products: %w", err) return nil, 0, fmt.Errorf("failed to list products: %w", err)

View File

@ -1,210 +0,0 @@
package processor
import (
"context"
"fmt"
"strings"
"unicode"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/mappers"
"apskel-pos-be/internal/models"
"github.com/google/uuid"
)
type PurchaseCategoryProcessor interface {
CreatePurchaseCategory(ctx context.Context, req *models.CreatePurchaseCategoryRequest) (*models.PurchaseCategoryResponse, error)
UpdatePurchaseCategory(ctx context.Context, id, organizationID uuid.UUID, req *models.UpdatePurchaseCategoryRequest) (*models.PurchaseCategoryResponse, error)
DeletePurchaseCategory(ctx context.Context, id, organizationID uuid.UUID) error
GetPurchaseCategoryByID(ctx context.Context, id, organizationID uuid.UUID) (*models.PurchaseCategoryResponse, error)
ListPurchaseCategories(ctx context.Context, req *models.ListPurchaseCategoriesRequest) ([]models.PurchaseCategoryResponse, int, error)
}
type PurchaseCategoryRepository interface {
Create(ctx context.Context, category *entities.PurchaseCategory) error
GetByIDAndOrganizationID(ctx context.Context, id, organizationID uuid.UUID) (*entities.PurchaseCategory, error)
Update(ctx context.Context, category *entities.PurchaseCategory) error
SoftDelete(ctx context.Context, id, organizationID uuid.UUID) error
List(ctx context.Context, organizationID uuid.UUID, filters map[string]interface{}, limit, offset int) ([]*entities.PurchaseCategory, int64, error)
ExistsByCode(ctx context.Context, organizationID uuid.UUID, code string, excludeID *uuid.UUID) (bool, error)
}
type PurchaseCategoryProcessorImpl struct {
purchaseCategoryRepo PurchaseCategoryRepository
}
func NewPurchaseCategoryProcessorImpl(purchaseCategoryRepo PurchaseCategoryRepository) *PurchaseCategoryProcessorImpl {
return &PurchaseCategoryProcessorImpl{purchaseCategoryRepo: purchaseCategoryRepo}
}
func (p *PurchaseCategoryProcessorImpl) CreatePurchaseCategory(ctx context.Context, req *models.CreatePurchaseCategoryRequest) (*models.PurchaseCategoryResponse, error) {
code := ""
if req.Code != nil && strings.TrimSpace(*req.Code) != "" {
code = normalizePurchaseCategoryCode(*req.Code)
} else {
code = normalizePurchaseCategoryCode(req.Name)
}
if code == "" {
return nil, fmt.Errorf("purchase category code cannot be empty")
}
exists, err := p.purchaseCategoryRepo.ExistsByCode(ctx, req.OrganizationID, code, nil)
if err != nil {
return nil, fmt.Errorf("failed to check purchase category code uniqueness: %w", err)
}
if exists {
return nil, fmt.Errorf("purchase category with code '%s' already exists", code)
}
if req.ParentID != nil {
parent, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, *req.ParentID, req.OrganizationID)
if err != nil {
return nil, fmt.Errorf("parent purchase category not found: %w", err)
}
if string(parent.Type) != req.Type {
return nil, fmt.Errorf("parent purchase category type must match child type")
}
}
category := mappers.CreatePurchaseCategoryRequestToEntity(req)
category.Code = code
if err := p.purchaseCategoryRepo.Create(ctx, category); err != nil {
return nil, fmt.Errorf("failed to create purchase category: %w", err)
}
return mappers.PurchaseCategoryEntityToResponse(category), nil
}
func (p *PurchaseCategoryProcessorImpl) UpdatePurchaseCategory(ctx context.Context, id, organizationID uuid.UUID, req *models.UpdatePurchaseCategoryRequest) (*models.PurchaseCategoryResponse, error) {
category, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, id, organizationID)
if err != nil {
return nil, fmt.Errorf("purchase category not found: %w", err)
}
newType := string(category.Type)
if req.Type != nil {
newType = *req.Type
}
if req.ParentID != nil {
if *req.ParentID == id {
return nil, fmt.Errorf("purchase category cannot be its own parent")
}
parent, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, *req.ParentID, organizationID)
if err != nil {
return nil, fmt.Errorf("parent purchase category not found: %w", err)
}
if string(parent.Type) != newType {
return nil, fmt.Errorf("parent purchase category type must match child type")
}
category.ParentID = req.ParentID
}
if req.Code != nil {
code := normalizePurchaseCategoryCode(*req.Code)
if code == "" {
return nil, fmt.Errorf("purchase category code cannot be empty")
}
if code != category.Code {
exists, err := p.purchaseCategoryRepo.ExistsByCode(ctx, organizationID, code, &id)
if err != nil {
return nil, fmt.Errorf("failed to check purchase category code uniqueness: %w", err)
}
if exists {
return nil, fmt.Errorf("purchase category with code '%s' already exists", code)
}
category.Code = code
}
}
if req.Name != nil {
category.Name = strings.TrimSpace(*req.Name)
}
if req.Type != nil {
category.Type = entities.PurchaseCategoryType(*req.Type)
}
if req.SortOrder != nil {
category.SortOrder = *req.SortOrder
}
if req.IsActive != nil {
category.IsActive = *req.IsActive
}
if err := p.purchaseCategoryRepo.Update(ctx, category); err != nil {
return nil, fmt.Errorf("failed to update purchase category: %w", err)
}
return mappers.PurchaseCategoryEntityToResponse(category), nil
}
func (p *PurchaseCategoryProcessorImpl) DeletePurchaseCategory(ctx context.Context, id, organizationID uuid.UUID) error {
_, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, id, organizationID)
if err != nil {
return fmt.Errorf("purchase category not found: %w", err)
}
if err := p.purchaseCategoryRepo.SoftDelete(ctx, id, organizationID); err != nil {
return fmt.Errorf("failed to delete purchase category: %w", err)
}
return nil
}
func (p *PurchaseCategoryProcessorImpl) GetPurchaseCategoryByID(ctx context.Context, id, organizationID uuid.UUID) (*models.PurchaseCategoryResponse, error) {
category, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, id, organizationID)
if err != nil {
return nil, fmt.Errorf("purchase category not found: %w", err)
}
return mappers.PurchaseCategoryEntityToResponse(category), nil
}
func (p *PurchaseCategoryProcessorImpl) ListPurchaseCategories(ctx context.Context, req *models.ListPurchaseCategoriesRequest) ([]models.PurchaseCategoryResponse, int, error) {
filters := make(map[string]interface{})
if req.ParentID != nil {
filters["parent_id"] = *req.ParentID
}
if req.Type != "" {
filters["type"] = req.Type
}
if req.Search != "" {
filters["search"] = req.Search
}
if req.IsActive != nil {
filters["is_active"] = *req.IsActive
}
offset := (req.Page - 1) * req.Limit
categories, total, err := p.purchaseCategoryRepo.List(ctx, req.OrganizationID, filters, req.Limit, offset)
if err != nil {
return nil, 0, fmt.Errorf("failed to list purchase categories: %w", err)
}
return mappers.PurchaseCategoryEntitiesToResponses(categories), int(total), nil
}
func normalizePurchaseCategoryCode(value string) string {
value = strings.TrimSpace(strings.ToLower(value))
var builder strings.Builder
lastUnderscore := false
for _, r := range value {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
builder.WriteRune(r)
lastUnderscore = false
continue
}
if !lastUnderscore {
builder.WriteRune('_')
lastUnderscore = true
}
}
return strings.Trim(builder.String(), "_")
}

View File

@ -11,8 +11,8 @@ import (
) )
type PurchaseOrderProcessor interface { type PurchaseOrderProcessor interface {
CreatePurchaseOrder(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, req *models.CreatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error) CreatePurchaseOrder(ctx context.Context, organizationID uuid.UUID, req *models.CreatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error)
UpdatePurchaseOrder(ctx context.Context, id, organizationID uuid.UUID, outletID *uuid.UUID, req *models.UpdatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error) UpdatePurchaseOrder(ctx context.Context, id, organizationID uuid.UUID, req *models.UpdatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error)
DeletePurchaseOrder(ctx context.Context, id, organizationID uuid.UUID) error DeletePurchaseOrder(ctx context.Context, id, organizationID uuid.UUID) error
GetPurchaseOrderByID(ctx context.Context, id, organizationID uuid.UUID) (*models.PurchaseOrderResponse, error) GetPurchaseOrderByID(ctx context.Context, id, organizationID uuid.UUID) (*models.PurchaseOrderResponse, error)
ListPurchaseOrders(ctx context.Context, organizationID uuid.UUID, filters map[string]interface{}, page, limit int) ([]*models.PurchaseOrderResponse, int, error) ListPurchaseOrders(ctx context.Context, organizationID uuid.UUID, filters map[string]interface{}, page, limit int) ([]*models.PurchaseOrderResponse, int, error)
@ -25,7 +25,6 @@ type PurchaseOrderProcessorImpl struct {
purchaseOrderRepo PurchaseOrderRepository purchaseOrderRepo PurchaseOrderRepository
vendorRepo VendorRepository vendorRepo VendorRepository
ingredientRepo IngredientRepository ingredientRepo IngredientRepository
purchaseCategoryRepo PurchaseCategoryRepository
unitRepo UnitRepository unitRepo UnitRepository
fileRepo FileRepository fileRepo FileRepository
inventoryMovementService InventoryMovementService inventoryMovementService InventoryMovementService
@ -36,7 +35,6 @@ func NewPurchaseOrderProcessorImpl(
purchaseOrderRepo PurchaseOrderRepository, purchaseOrderRepo PurchaseOrderRepository,
vendorRepo VendorRepository, vendorRepo VendorRepository,
ingredientRepo IngredientRepository, ingredientRepo IngredientRepository,
purchaseCategoryRepo PurchaseCategoryRepository,
unitRepo UnitRepository, unitRepo UnitRepository,
fileRepo FileRepository, fileRepo FileRepository,
inventoryMovementService InventoryMovementService, inventoryMovementService InventoryMovementService,
@ -46,7 +44,6 @@ func NewPurchaseOrderProcessorImpl(
purchaseOrderRepo: purchaseOrderRepo, purchaseOrderRepo: purchaseOrderRepo,
vendorRepo: vendorRepo, vendorRepo: vendorRepo,
ingredientRepo: ingredientRepo, ingredientRepo: ingredientRepo,
purchaseCategoryRepo: purchaseCategoryRepo,
unitRepo: unitRepo, unitRepo: unitRepo,
fileRepo: fileRepo, fileRepo: fileRepo,
inventoryMovementService: inventoryMovementService, inventoryMovementService: inventoryMovementService,
@ -54,14 +51,12 @@ func NewPurchaseOrderProcessorImpl(
} }
} }
func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, req *models.CreatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error) { func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, organizationID uuid.UUID, req *models.CreatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error) {
// Check if vendor exists and belongs to organization when provided. // Check if vendor exists and belongs to organization
if req.VendorID != nil { _, err := p.vendorRepo.GetByIDAndOrganizationID(ctx, req.VendorID, organizationID)
_, err := p.vendorRepo.GetByIDAndOrganizationID(ctx, *req.VendorID, organizationID)
if err != nil { if err != nil {
return nil, fmt.Errorf("vendor not found: %w", err) return nil, fmt.Errorf("vendor not found: %w", err)
} }
}
// Check if PO number already exists in organization // Check if PO number already exists in organization
existingPO, err := p.purchaseOrderRepo.GetByPONumber(ctx, req.PONumber, organizationID) existingPO, err := p.purchaseOrderRepo.GetByPONumber(ctx, req.PONumber, organizationID)
@ -69,53 +64,28 @@ func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, or
return nil, fmt.Errorf("purchase order with PO number %s already exists in this organization", req.PONumber) return nil, fmt.Errorf("purchase order with PO number %s already exists in this organization", req.PONumber)
} }
// Validate categories and inventory fields per item type. // Validate ingredients and units exist
for i, item := range req.Items { for i, item := range req.Items {
category, err := p.validatePurchaseCategory(ctx, item.PurchaseCategoryID, organizationID, i) _, err := p.ingredientRepo.GetByID(ctx, item.IngredientID, organizationID)
if err != nil {
return nil, err
}
switch category.Type {
case entities.PurchaseCategoryTypeRawMaterial:
if item.IngredientID == nil {
return nil, fmt.Errorf("ingredient_id is required for raw_material item %d", i)
}
if item.Quantity == nil {
return nil, fmt.Errorf("quantity is required for raw_material item %d", i)
}
if item.UnitID == nil {
return nil, fmt.Errorf("unit_id is required for raw_material item %d", i)
}
_, err := p.ingredientRepo.GetByID(ctx, *item.IngredientID, organizationID)
if err != nil { if err != nil {
return nil, fmt.Errorf("ingredient not found for item %d: %w", i, err) return nil, fmt.Errorf("ingredient not found for item %d: %w", i, err)
} }
_, err = p.unitRepo.GetByID(ctx, *item.UnitID, organizationID) _, err = p.unitRepo.GetByID(ctx, item.UnitID, organizationID)
if err != nil { if err != nil {
return nil, fmt.Errorf("unit not found for item %d: %w", i, err) return nil, fmt.Errorf("unit not found for item %d: %w", i, err)
} }
case entities.PurchaseCategoryTypeExpense:
if item.IngredientID != nil || item.Quantity != nil || item.UnitID != nil {
return nil, fmt.Errorf("ingredient_id, quantity, and unit_id must be empty for expense item %d", i)
}
default:
return nil, fmt.Errorf("purchase category for item %d has unsupported type %s", i, category.Type)
}
} }
// Calculate total amount // Calculate total amount
totalAmount := 0.0 totalAmount := 0.0
for _, item := range req.Items { for _, item := range req.Items {
totalAmount += calculatePurchaseOrderItemTotal(item.Quantity, item.Amount) totalAmount += item.Amount
} }
// Create purchase order entity // Create purchase order entity
poEntity := &entities.PurchaseOrder{ poEntity := &entities.PurchaseOrder{
OrganizationID: organizationID, OrganizationID: organizationID,
OutletID: outletID,
VendorID: req.VendorID, VendorID: req.VendorID,
PONumber: req.PONumber, PONumber: req.PONumber,
TransactionDate: req.TransactionDate, TransactionDate: req.TransactionDate,
@ -141,7 +111,6 @@ func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, or
itemEntity := &entities.PurchaseOrderItem{ itemEntity := &entities.PurchaseOrderItem{
PurchaseOrderID: poEntity.ID, PurchaseOrderID: poEntity.ID,
IngredientID: itemReq.IngredientID, IngredientID: itemReq.IngredientID,
PurchaseCategoryID: itemReq.PurchaseCategoryID,
Description: itemReq.Description, Description: itemReq.Description,
Quantity: itemReq.Quantity, Quantity: itemReq.Quantity,
UnitID: itemReq.UnitID, UnitID: itemReq.UnitID,
@ -176,15 +145,12 @@ func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, or
return mappers.PurchaseOrderEntityToResponse(createdPO), nil return mappers.PurchaseOrderEntityToResponse(createdPO), nil
} }
func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id, organizationID uuid.UUID, outletID *uuid.UUID, req *models.UpdatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error) { func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id, organizationID uuid.UUID, req *models.UpdatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error) {
// Get existing purchase order // Get existing purchase order
poEntity, err := p.purchaseOrderRepo.GetByIDAndOrganizationID(ctx, id, organizationID) poEntity, err := p.purchaseOrderRepo.GetByIDAndOrganizationID(ctx, id, organizationID)
if err != nil { if err != nil {
return nil, fmt.Errorf("purchase order not found: %w", err) return nil, fmt.Errorf("purchase order not found: %w", err)
} }
if poEntity.OutletID == nil && outletID != nil {
poEntity.OutletID = outletID
}
// Check if vendor exists and belongs to organization (if vendor is being updated) // Check if vendor exists and belongs to organization (if vendor is being updated)
if req.VendorID != nil { if req.VendorID != nil {
@ -192,7 +158,7 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id
if err != nil { if err != nil {
return nil, fmt.Errorf("vendor not found: %w", err) return nil, fmt.Errorf("vendor not found: %w", err)
} }
poEntity.VendorID = req.VendorID poEntity.VendorID = *req.VendorID
} }
// Check if PO number already exists (if PO number is being updated) // Check if PO number already exists (if PO number is being updated)
@ -209,7 +175,7 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id
poEntity.TransactionDate = *req.TransactionDate poEntity.TransactionDate = *req.TransactionDate
} }
if req.DueDate != nil { if req.DueDate != nil {
poEntity.DueDate = req.DueDate poEntity.DueDate = *req.DueDate
} }
if req.Reference != nil { if req.Reference != nil {
poEntity.Reference = req.Reference poEntity.Reference = req.Reference
@ -223,80 +189,68 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id
// Update items if provided // Update items if provided
if req.Items != nil { if req.Items != nil {
totalAmount := 0.0 // Delete existing items
items := make([]*entities.PurchaseOrderItem, len(req.Items))
for i, itemReq := range req.Items {
if itemReq.PurchaseCategoryID == nil {
return nil, fmt.Errorf("purchase_category_id is required for item %d", i)
}
ingredientID := itemReq.IngredientID
purchaseCategoryID := *itemReq.PurchaseCategoryID
unitID := itemReq.UnitID
quantity := itemReq.Quantity
amount := 0.0
if itemReq.Amount != nil {
amount = *itemReq.Amount
}
description := itemReq.Description
category, err := p.validatePurchaseCategory(ctx, purchaseCategoryID, organizationID, i)
if err != nil {
return nil, err
}
switch category.Type {
case entities.PurchaseCategoryTypeRawMaterial:
if ingredientID == nil {
return nil, fmt.Errorf("ingredient_id is required for raw_material item %d", i)
}
if quantity == nil {
return nil, fmt.Errorf("quantity is required for raw_material item %d", i)
}
if unitID == nil {
return nil, fmt.Errorf("unit_id is required for raw_material item %d", i)
}
_, err := p.ingredientRepo.GetByID(ctx, *ingredientID, organizationID)
if err != nil {
return nil, fmt.Errorf("ingredient not found: %w", err)
}
_, err = p.unitRepo.GetByID(ctx, *unitID, organizationID)
if err != nil {
return nil, fmt.Errorf("unit not found: %w", err)
}
case entities.PurchaseCategoryTypeExpense:
if ingredientID != nil || quantity != nil || unitID != nil {
return nil, fmt.Errorf("ingredient_id, quantity, and unit_id must be empty for expense item %d", i)
}
default:
return nil, fmt.Errorf("purchase category for item %d has unsupported type %s", i, category.Type)
}
items[i] = &entities.PurchaseOrderItem{
PurchaseOrderID: poEntity.ID,
IngredientID: ingredientID,
PurchaseCategoryID: purchaseCategoryID,
Description: description,
Quantity: quantity,
UnitID: unitID,
Amount: amount,
}
totalAmount += calculatePurchaseOrderItemTotal(quantity, amount)
}
// Delete and recreate only after all replacement items are valid.
err = p.purchaseOrderRepo.DeleteItemsByPurchaseOrderID(ctx, poEntity.ID) err = p.purchaseOrderRepo.DeleteItemsByPurchaseOrderID(ctx, poEntity.ID)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to delete existing items: %w", err) return nil, fmt.Errorf("failed to delete existing items: %w", err)
} }
for _, itemEntity := range items { // Create new items
totalAmount := 0.0
for _, itemReq := range req.Items {
// Validate ingredients and units exist
if itemReq.IngredientID != nil {
_, err := p.ingredientRepo.GetByID(ctx, *itemReq.IngredientID, organizationID)
if err != nil {
return nil, fmt.Errorf("ingredient not found: %w", err)
}
}
if itemReq.UnitID != nil {
_, err := p.unitRepo.GetByID(ctx, *itemReq.UnitID, organizationID)
if err != nil {
return nil, fmt.Errorf("unit not found: %w", err)
}
}
// Use existing values if not provided
ingredientID := poEntity.Items[0].IngredientID // This is a simplified approach
unitID := poEntity.Items[0].UnitID
quantity := poEntity.Items[0].Quantity
amount := poEntity.Items[0].Amount
description := poEntity.Items[0].Description
if itemReq.IngredientID != nil {
ingredientID = *itemReq.IngredientID
}
if itemReq.UnitID != nil {
unitID = *itemReq.UnitID
}
if itemReq.Quantity != nil {
quantity = *itemReq.Quantity
}
if itemReq.Amount != nil {
amount = *itemReq.Amount
}
if itemReq.Description != nil {
description = itemReq.Description
}
itemEntity := &entities.PurchaseOrderItem{
PurchaseOrderID: poEntity.ID,
IngredientID: ingredientID,
Description: description,
Quantity: quantity,
UnitID: unitID,
Amount: amount,
}
err = p.purchaseOrderRepo.CreateItem(ctx, itemEntity) err = p.purchaseOrderRepo.CreateItem(ctx, itemEntity)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create purchase order item: %w", err) return nil, fmt.Errorf("failed to create purchase order item: %w", err)
} }
totalAmount += amount
} }
poEntity.TotalAmount = totalAmount poEntity.TotalAmount = totalAmount
@ -425,27 +379,19 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte
// Update inventory for each item // Update inventory for each item
for _, item := range poWithItems.Items { for _, item := range poWithItems.Items {
if item.PurchaseCategory != nil && item.PurchaseCategory.Type == entities.PurchaseCategoryTypeExpense {
continue
}
if item.IngredientID == nil || item.UnitID == nil || item.Quantity == nil {
return nil, fmt.Errorf("purchase order item %s is missing raw material inventory fields", item.ID)
}
// Get ingredient to find its base unit // Get ingredient to find its base unit
ingredient, err := p.ingredientRepo.GetByID(ctx, *item.IngredientID, organizationID) ingredient, err := p.ingredientRepo.GetByID(ctx, item.IngredientID, organizationID)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get ingredient %s: %w", *item.IngredientID, err) return nil, fmt.Errorf("failed to get ingredient %s: %w", item.IngredientID, err)
} }
// Convert quantity to ingredient's base unit if needed // Convert quantity to ingredient's base unit if needed
quantityToAdd := *item.Quantity quantityToAdd := item.Quantity
if *item.UnitID != ingredient.UnitID { if item.UnitID != ingredient.UnitID {
// Convert from purchase unit to ingredient's base unit // Convert from purchase unit to ingredient's base unit
convertedQuantity, err := p.unitConverterRepo.ConvertQuantity(ctx, *item.IngredientID, *item.UnitID, ingredient.UnitID, organizationID, *item.Quantity) convertedQuantity, err := p.unitConverterRepo.ConvertQuantity(ctx, item.IngredientID, item.UnitID, ingredient.UnitID, organizationID, item.Quantity)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to convert quantity for ingredient %s from unit %s to %s: %w", *item.IngredientID, *item.UnitID, ingredient.UnitID, err) return nil, fmt.Errorf("failed to convert quantity for ingredient %s from unit %s to %s: %w", item.IngredientID, item.UnitID, ingredient.UnitID, err)
} }
quantityToAdd = convertedQuantity quantityToAdd = convertedQuantity
} }
@ -453,7 +399,7 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte
// Calculate unit cost in ingredient's base unit // Calculate unit cost in ingredient's base unit
unitCost := 0.0 unitCost := 0.0
if quantityToAdd > 0 { if quantityToAdd > 0 {
unitCost = calculatePurchaseOrderItemTotal(item.Quantity, item.Amount) / quantityToAdd unitCost = item.Amount / quantityToAdd
} }
// Create inventory movement for ingredient purchase // Create inventory movement for ingredient purchase
@ -463,7 +409,7 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte
err = p.inventoryMovementService.CreateIngredientMovement( err = p.inventoryMovementService.CreateIngredientMovement(
ctx, ctx,
*item.IngredientID, item.IngredientID,
organizationID, organizationID,
outletID, outletID,
userID, userID,
@ -473,21 +419,15 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte
reason, reason,
&referenceType, &referenceType,
referenceID, referenceID,
&item.ID,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create inventory movement for ingredient %s: %w", *item.IngredientID, err) return nil, fmt.Errorf("failed to create inventory movement for ingredient %s: %w", item.IngredientID, err)
} }
} }
} }
// Update the purchase order status // Update the purchase order status
statusOutletID := po.OutletID err = p.purchaseOrderRepo.UpdateStatus(ctx, id, status)
if statusOutletID == nil && outletID != uuid.Nil {
statusOutletID = &outletID
}
err = p.purchaseOrderRepo.UpdateStatusAndOutlet(ctx, id, status, statusOutletID)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to update purchase order status: %w", err) return nil, fmt.Errorf("failed to update purchase order status: %w", err)
} }
@ -500,28 +440,3 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte
return mappers.PurchaseOrderEntityToResponse(updatedPO), nil return mappers.PurchaseOrderEntityToResponse(updatedPO), nil
} }
func (p *PurchaseOrderProcessorImpl) validatePurchaseCategory(ctx context.Context, categoryID, organizationID uuid.UUID, itemIndex int) (*entities.PurchaseCategory, error) {
category, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, categoryID, organizationID)
if err != nil {
return nil, fmt.Errorf("purchase category not found for item %d: %w", itemIndex, err)
}
if !category.IsActive {
return nil, fmt.Errorf("purchase category for item %d is inactive", itemIndex)
}
if category.Type != entities.PurchaseCategoryTypeRawMaterial && category.Type != entities.PurchaseCategoryTypeExpense {
return nil, fmt.Errorf("purchase category for item %d must be raw_material or expense", itemIndex)
}
return category, nil
}
func calculatePurchaseOrderItemTotal(quantity *float64, amount float64) float64 {
if quantity == nil {
return amount
}
return *quantity * amount
}

View File

@ -19,7 +19,6 @@ type PurchaseOrderRepository interface {
GetByStatus(ctx context.Context, organizationID uuid.UUID, status string) ([]*entities.PurchaseOrder, error) GetByStatus(ctx context.Context, organizationID uuid.UUID, status string) ([]*entities.PurchaseOrder, error)
GetOverdue(ctx context.Context, organizationID uuid.UUID) ([]*entities.PurchaseOrder, error) GetOverdue(ctx context.Context, organizationID uuid.UUID) ([]*entities.PurchaseOrder, error)
UpdateStatus(ctx context.Context, id uuid.UUID, status string) error UpdateStatus(ctx context.Context, id uuid.UUID, status string) error
UpdateStatusAndOutlet(ctx context.Context, id uuid.UUID, status string, outletID *uuid.UUID) error
UpdateTotalAmount(ctx context.Context, id uuid.UUID, totalAmount float64) error UpdateTotalAmount(ctx context.Context, id uuid.UUID, totalAmount float64) error
CreateItem(ctx context.Context, item *entities.PurchaseOrderItem) error CreateItem(ctx context.Context, item *entities.PurchaseOrderItem) error
UpdateItem(ctx context.Context, item *entities.PurchaseOrderItem) error UpdateItem(ctx context.Context, item *entities.PurchaseOrderItem) error

View File

@ -22,6 +22,8 @@ const (
MetadataKeyLastSplitQuantities = "last_split_quantities" MetadataKeyLastSplitQuantities = "last_split_quantities"
) )
type SplitBillValidation struct { type SplitBillValidation struct {
OrderItems map[uuid.UUID]*entities.OrderItem OrderItems map[uuid.UUID]*entities.OrderItem
PaidQuantities map[uuid.UUID]int PaidQuantities map[uuid.UUID]int

View File

@ -4,7 +4,6 @@ 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"
@ -213,15 +212,6 @@ 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
} }

View File

@ -1,165 +0,0 @@
package processor
import (
"context"
"fmt"
"time"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/mappers"
"apskel-pos-be/internal/models"
"github.com/google/uuid"
)
type UserDeviceRepository interface {
Create(ctx context.Context, device *entities.UserDevice) error
GetByID(ctx context.Context, id uuid.UUID) (*entities.UserDevice, error)
GetByDeviceID(ctx context.Context, deviceID string, userID uuid.UUID) (*entities.UserDevice, error)
GetByUserID(ctx context.Context, userID uuid.UUID) ([]*entities.UserDevice, error)
Update(ctx context.Context, device *entities.UserDevice) error
Delete(ctx context.Context, id uuid.UUID) error
DeleteByUserID(ctx context.Context, userID uuid.UUID) error
List(ctx context.Context, filters map[string]interface{}, limit, offset int) ([]*entities.UserDevice, int64, error)
}
type UserDeviceProcessor interface {
RegisterDevice(ctx context.Context, req *models.RegisterUserDeviceRequest) (*models.UserDeviceResponse, error)
UpdateDevice(ctx context.Context, id uuid.UUID, req *models.UpdateUserDeviceRequest) (*models.UserDeviceResponse, error)
DeleteDevice(ctx context.Context, id uuid.UUID) error
GetDeviceByID(ctx context.Context, id uuid.UUID) (*models.UserDeviceResponse, error)
GetDevicesByUserID(ctx context.Context, userID uuid.UUID) ([]*models.UserDeviceResponse, error)
ListDevices(ctx context.Context, filters map[string]interface{}, page, limit int) ([]*models.UserDeviceResponse, int, error)
}
type UserDeviceProcessorImpl struct {
userDeviceRepo UserDeviceRepository
}
func NewUserDeviceProcessorImpl(userDeviceRepo UserDeviceRepository) *UserDeviceProcessorImpl {
return &UserDeviceProcessorImpl{
userDeviceRepo: userDeviceRepo,
}
}
func (p *UserDeviceProcessorImpl) RegisterDevice(ctx context.Context, req *models.RegisterUserDeviceRequest) (*models.UserDeviceResponse, error) {
// Upsert: if device already registered for this user, update it
existing, err := p.userDeviceRepo.GetByDeviceID(ctx, req.DeviceID, req.UserID)
if err == nil && existing != nil {
existing.DeviceName = req.DeviceName
existing.DeviceType = req.DeviceType
existing.Platform = req.Platform
existing.FCMToken = req.FCMToken
existing.AppVersion = req.AppVersion
existing.OsVersion = req.OsVersion
existing.IPAddress = req.IPAddress
now := time.Now()
existing.LastActiveAt = &now
if err := p.userDeviceRepo.Update(ctx, existing); err != nil {
return nil, fmt.Errorf("failed to update device: %w", err)
}
return mappers.UserDeviceEntityToResponse(existing), nil
}
deviceEntity := &entities.UserDevice{
UserID: req.UserID,
DeviceID: req.DeviceID,
DeviceName: req.DeviceName,
DeviceType: req.DeviceType,
Platform: req.Platform,
FCMToken: req.FCMToken,
AppVersion: req.AppVersion,
OsVersion: req.OsVersion,
IPAddress: req.IPAddress,
}
now := time.Now()
deviceEntity.LastActiveAt = &now
if err := p.userDeviceRepo.Create(ctx, deviceEntity); err != nil {
return nil, fmt.Errorf("failed to register device: %w", err)
}
return mappers.UserDeviceEntityToResponse(deviceEntity), nil
}
func (p *UserDeviceProcessorImpl) UpdateDevice(ctx context.Context, id uuid.UUID, req *models.UpdateUserDeviceRequest) (*models.UserDeviceResponse, error) {
deviceEntity, err := p.userDeviceRepo.GetByID(ctx, id)
if err != nil {
return nil, fmt.Errorf("device not found: %w", err)
}
if req.DeviceName != "" {
deviceEntity.DeviceName = req.DeviceName
}
if req.DeviceType != "" {
deviceEntity.DeviceType = req.DeviceType
}
if req.Platform != "" {
deviceEntity.Platform = req.Platform
}
if req.FCMToken != "" {
deviceEntity.FCMToken = req.FCMToken
}
if req.AppVersion != "" {
deviceEntity.AppVersion = req.AppVersion
}
if req.OsVersion != "" {
deviceEntity.OsVersion = req.OsVersion
}
now := time.Now()
deviceEntity.LastActiveAt = &now
if err := p.userDeviceRepo.Update(ctx, deviceEntity); err != nil {
return nil, fmt.Errorf("failed to update device: %w", err)
}
return mappers.UserDeviceEntityToResponse(deviceEntity), nil
}
func (p *UserDeviceProcessorImpl) DeleteDevice(ctx context.Context, id uuid.UUID) error {
_, err := p.userDeviceRepo.GetByID(ctx, id)
if err != nil {
return fmt.Errorf("device not found: %w", err)
}
if err := p.userDeviceRepo.Delete(ctx, id); err != nil {
return fmt.Errorf("failed to delete device: %w", err)
}
return nil
}
func (p *UserDeviceProcessorImpl) GetDeviceByID(ctx context.Context, id uuid.UUID) (*models.UserDeviceResponse, error) {
deviceEntity, err := p.userDeviceRepo.GetByID(ctx, id)
if err != nil {
return nil, fmt.Errorf("device not found: %w", err)
}
return mappers.UserDeviceEntityToResponse(deviceEntity), nil
}
func (p *UserDeviceProcessorImpl) GetDevicesByUserID(ctx context.Context, userID uuid.UUID) ([]*models.UserDeviceResponse, error) {
deviceEntities, err := p.userDeviceRepo.GetByUserID(ctx, userID)
if err != nil {
return nil, fmt.Errorf("failed to get devices: %w", err)
}
return mappers.UserDeviceEntitiesToResponses(deviceEntities), nil
}
func (p *UserDeviceProcessorImpl) ListDevices(ctx context.Context, filters map[string]interface{}, page, limit int) ([]*models.UserDeviceResponse, int, error) {
offset := (page - 1) * limit
deviceEntities, total, err := p.userDeviceRepo.List(ctx, filters, limit, offset)
if err != nil {
return nil, 0, fmt.Errorf("failed to list devices: %w", err)
}
deviceResponses := mappers.UserDeviceEntitiesToResponses(deviceEntities)
totalPages := int((total + int64(limit) - 1) / int64(limit))
return deviceResponses, totalPages, nil
}

View File

@ -253,3 +253,17 @@ func (p *UserProcessorImpl) UpdateUserOutlet(ctx context.Context, userID uuid.UU
return mappers.UserEntityToResponse(existingUser), nil return mappers.UserEntityToResponse(existingUser), nil
} }
func (p *UserProcessorImpl) UpdateFcmToken(ctx context.Context, userID uuid.UUID, fcmToken string) error {
_, err := p.userRepo.GetByID(ctx, userID)
if err != nil {
return fmt.Errorf("user not found: %w", err)
}
err = p.userRepo.UpdateFcmToken(ctx, userID, fcmToken)
if err != nil {
return fmt.Errorf("failed to update FCM token: %w", err)
}
return nil
}

View File

@ -19,4 +19,6 @@ type UserRepository interface {
UpdateActiveStatus(ctx context.Context, id uuid.UUID, isActive bool) error UpdateActiveStatus(ctx context.Context, id uuid.UUID, isActive bool) error
List(ctx context.Context, filters map[string]interface{}, limit, offset int) ([]*entities.User, int64, error) List(ctx context.Context, filters map[string]interface{}, limit, offset int) ([]*entities.User, int64, error)
Count(ctx context.Context, filters map[string]interface{}) (int64, error) Count(ctx context.Context, filters map[string]interface{}) (int64, error)
UpdateFcmToken(ctx context.Context, id uuid.UUID, fcmToken string) error
GetUsersWithFcmTokenByOrganization(ctx context.Context, organizationID uuid.UUID) ([]*entities.User, error)
} }

File diff suppressed because it is too large Load Diff

View File

@ -72,9 +72,6 @@ func (r *CategoryRepositoryImpl) List(ctx context.Context, filters map[string]in
case "search": case "search":
searchValue := "%" + value.(string) + "%" searchValue := "%" + value.(string) + "%"
query = query.Where("name ILIKE ? OR description ILIKE ?", searchValue, searchValue) query = query.Where("name ILIKE ? OR description ILIKE ?", searchValue, searchValue)
case "outlet_id":
// Include outlet-specific categories AND global categories (outlet_id IS NULL)
query = query.Where("outlet_id = ? OR outlet_id IS NULL", value)
default: default:
query = query.Where(key+" = ?", value) query = query.Where(key+" = ?", value)
} }

View File

@ -1,285 +0,0 @@
package repository
import (
"context"
"strings"
"time"
"github.com/google/uuid"
"apskel-pos-be/internal/entities"
"gorm.io/gorm"
)
type ExpenseRepositoryImpl struct {
db *gorm.DB
}
func NewExpenseRepositoryImpl(db *gorm.DB) *ExpenseRepositoryImpl {
return &ExpenseRepositoryImpl{
db: db,
}
}
func (r *ExpenseRepositoryImpl) Create(ctx context.Context, expense *entities.Expense) error {
return r.db.WithContext(ctx).Create(expense).Error
}
func (r *ExpenseRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) (*entities.Expense, error) {
var expense entities.Expense
err := r.db.WithContext(ctx).
Preload("Items.ChartOfAccount").
Preload("Items.PurchaseCategory").
First(&expense, "id = ?", id).Error
if err != nil {
return nil, err
}
return &expense, nil
}
func (r *ExpenseRepositoryImpl) GetByIDAndOrganizationID(ctx context.Context, id, organizationID uuid.UUID) (*entities.Expense, error) {
var expense entities.Expense
err := r.db.WithContext(ctx).
Preload("Items.ChartOfAccount").
Preload("Items.PurchaseCategory").
Where("id = ? AND organization_id = ?", id, organizationID).
First(&expense).Error
if err != nil {
return nil, err
}
return &expense, nil
}
func (r *ExpenseRepositoryImpl) Update(ctx context.Context, expense *entities.Expense) error {
return r.db.WithContext(ctx).Save(expense).Error
}
func (r *ExpenseRepositoryImpl) Delete(ctx context.Context, id uuid.UUID) error {
return r.db.WithContext(ctx).Delete(&entities.Expense{}, "id = ?", id).Error
}
func (r *ExpenseRepositoryImpl) List(ctx context.Context, organizationID uuid.UUID, filters map[string]interface{}, limit, offset int) ([]*entities.Expense, int64, error) {
var expenses []*entities.Expense
var total int64
query := r.db.WithContext(ctx).Model(&entities.Expense{}).Where("organization_id = ?", organizationID)
for key, value := range filters {
switch key {
case "search":
if searchStr, ok := value.(string); ok && searchStr != "" {
searchPattern := "%" + strings.ToLower(searchStr) + "%"
query = query.Where(`
LOWER(receiver) LIKE ?
OR LOWER(code_number) LIKE ?
OR LOWER(description) LIKE ?
OR EXISTS (
SELECT 1
FROM expense_items ei
WHERE ei.expense_id = expenses.id
AND LOWER(ei.item) LIKE ?
)
`, searchPattern, searchPattern, searchPattern, searchPattern)
}
case "outlet_id":
if outletID, ok := value.(uuid.UUID); ok {
query = query.Where("outlet_id = ?", outletID)
}
case "status":
if status, ok := value.(string); ok && status != "" {
query = query.Where("status = ?", status)
}
case "start_date":
if startDate, ok := value.(time.Time); ok {
query = query.Where("transaction_date >= ?", startDate)
}
case "end_date":
if endDate, ok := value.(time.Time); ok {
query = query.Where("transaction_date <= ?", endDate)
}
default:
query = query.Where(key+" = ?", value)
}
}
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
err := query.
Preload("Items.ChartOfAccount").
Preload("Items.PurchaseCategory").
Order("created_at DESC").
Limit(limit).
Offset(offset).
Find(&expenses).Error
return expenses, total, err
}
func (r *ExpenseRepositoryImpl) GetAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) (*entities.ExpenseAnalytics, error) {
var summary entities.ExpenseAnalyticsSummary
summaryQuery := r.db.WithContext(ctx).
Table("expenses e").
Select(`
COALESCE(SUM(e.total), 0) as total_expenses,
COUNT(e.id) as total_expense_count,
COALESCE(SUM(e.tax), 0) as total_tax,
COALESCE(AVG(e.total), 0) as average_expense_value
`).
Where("e.organization_id = ?", organizationID).
Where("e.status = ?", "approved").
Where("e.transaction_date >= ? AND e.transaction_date <= ?", dateFrom, dateTo)
if outletID != nil {
summaryQuery = summaryQuery.Where("e.outlet_id = ?", *outletID)
}
if err := summaryQuery.Scan(&summary).Error; err != nil {
return nil, err
}
countsQuery := r.db.WithContext(ctx).
Table("expense_items ei").
Select(`
COUNT(ei.id) as total_items,
COUNT(DISTINCT ei.purchase_category_id) as total_categories
`).
Joins("JOIN expenses e ON ei.expense_id = e.id").
Where("e.organization_id = ?", organizationID).
Where("e.status = ?", "approved").
Where("e.transaction_date >= ? AND e.transaction_date <= ?", dateFrom, dateTo)
if outletID != nil {
countsQuery = countsQuery.Where("e.outlet_id = ?", *outletID)
}
if err := countsQuery.Scan(&summary).Error; err != nil {
return nil, err
}
dateFormat := "DATE_TRUNC('day', e.transaction_date)"
switch groupBy {
case "hour":
dateFormat = "DATE_TRUNC('hour', e.transaction_date)"
case "week":
dateFormat = "DATE_TRUNC('week', e.transaction_date)"
case "month":
dateFormat = "DATE_TRUNC('month', e.transaction_date)"
}
var data []entities.ExpenseAnalyticsData
dataQuery := r.db.WithContext(ctx).
Table("expenses e").
Select(`
`+dateFormat+` as date,
COALESCE(SUM(e.total), 0) as expenses,
COUNT(e.id) as expense_count,
COALESCE(SUM(e.tax), 0) as tax,
COALESCE(SUM(item_counts.items), 0) as items,
COALESCE(SUM(item_counts.categories), 0) as categories
`).
Joins(`LEFT JOIN (
SELECT expense_id, COUNT(id) as items, COUNT(DISTINCT purchase_category_id) as categories
FROM expense_items
GROUP BY expense_id
) item_counts ON item_counts.expense_id = e.id`).
Where("e.organization_id = ?", organizationID).
Where("e.status = ?", "approved").
Where("e.transaction_date >= ? AND e.transaction_date <= ?", dateFrom, dateTo).
Group(dateFormat).
Order(dateFormat)
if outletID != nil {
dataQuery = dataQuery.Where("e.outlet_id = ?", *outletID)
}
if err := dataQuery.Scan(&data).Error; err != nil {
return nil, err
}
var categoryData []entities.ExpenseAnalyticsCategoryData
categoryQuery := r.db.WithContext(ctx).
Table("expense_items ei").
Select(`
pc.id as purchase_category_id,
pc.name as purchase_category_name,
pc.type as purchase_category_type,
COALESCE(SUM(ei.amount), 0) as total_amount,
COUNT(DISTINCT e.id) as expense_count,
COUNT(ei.id) as item_count
`).
Joins("JOIN expenses e ON ei.expense_id = e.id").
Joins("JOIN purchase_categories pc ON ei.purchase_category_id = pc.id").
Where("e.organization_id = ?", organizationID).
Where("pc.type = ?", entities.PurchaseCategoryTypeExpense).
Where("e.status = ?", "approved").
Where("e.transaction_date >= ? AND e.transaction_date <= ?", dateFrom, dateTo).
Group("pc.id, pc.name, pc.type").
Order("total_amount DESC")
if outletID != nil {
categoryQuery = categoryQuery.Where("e.outlet_id = ?", *outletID)
}
if err := categoryQuery.Scan(&categoryData).Error; err != nil {
return nil, err
}
var chartOfAccountData []entities.ExpenseAnalyticsChartOfAccountData
chartOfAccountQuery := r.db.WithContext(ctx).
Table("expense_items ei").
Select(`
COALESCE(parent_coa.id, coa.id) as chart_of_account_id,
COALESCE(parent_coa.name, coa.name, 'Lain-lain') as chart_of_account_name,
COALESCE(SUM(ei.amount), 0) as total_amount,
COUNT(DISTINCT e.id) as expense_count,
COUNT(ei.id) as item_count
`).
Joins("JOIN expenses e ON ei.expense_id = e.id").
Joins("JOIN chart_of_accounts coa ON ei.chart_of_account_id = coa.id").
Joins("LEFT JOIN chart_of_accounts parent_coa ON coa.parent_id = parent_coa.id").
Where("e.organization_id = ?", organizationID).
Where("e.status = ?", "approved").
Where("e.transaction_date >= ? AND e.transaction_date <= ?", dateFrom, dateTo).
Group("COALESCE(parent_coa.id, coa.id), COALESCE(parent_coa.name, coa.name, 'Lain-lain')").
Order("total_amount DESC")
if outletID != nil {
chartOfAccountQuery = chartOfAccountQuery.Where("e.outlet_id = ?", *outletID)
}
if err := chartOfAccountQuery.Scan(&chartOfAccountData).Error; err != nil {
return nil, err
}
var itemData []entities.ExpenseAnalyticsItemData
itemQuery := r.db.WithContext(ctx).
Table("expense_items ei").
Select(`
COALESCE(NULLIF(ei.item, ''), ei.description, coa.name) as item,
COALESCE(SUM(ei.amount), 0) as total_amount,
COUNT(DISTINCT e.id) as expense_count,
COUNT(ei.id) as item_count
`).
Joins("JOIN expenses e ON ei.expense_id = e.id").
Joins("JOIN chart_of_accounts coa ON ei.chart_of_account_id = coa.id").
Where("e.organization_id = ?", organizationID).
Where("e.status = ?", "approved").
Where("e.transaction_date >= ? AND e.transaction_date <= ?", dateFrom, dateTo).
Group("COALESCE(NULLIF(ei.item, ''), ei.description, coa.name)").
Order("total_amount DESC")
if outletID != nil {
itemQuery = itemQuery.Where("e.outlet_id = ?", *outletID)
}
if err := itemQuery.Scan(&itemData).Error; err != nil {
return nil, err
}
return &entities.ExpenseAnalytics{
Summary: summary,
Data: data,
CategoryData: categoryData,
ChartOfAccountData: chartOfAccountData,
ItemData: itemData,
}, nil
}
func (r *ExpenseRepositoryImpl) CreateItem(ctx context.Context, item *entities.ExpenseItem) error {
return r.db.WithContext(ctx).Create(item).Error
}
func (r *ExpenseRepositoryImpl) DeleteItemsByExpenseID(ctx context.Context, expenseID uuid.UUID) error {
return r.db.WithContext(ctx).Delete(&entities.ExpenseItem{}, "expense_id = ?", expenseID).Error
}

View File

@ -173,3 +173,4 @@ func (r *IngredientUnitConverterRepositoryImpl) ConvertQuantity(ctx context.Cont
// If no converter found, return error // If no converter found, return error
return 0, fmt.Errorf("no conversion found between units %s and %s for ingredient %s", fromUnitID, toUnitID, ingredientID) return 0, fmt.Errorf("no conversion found between units %s and %s for ingredient %s", fromUnitID, toUnitID, ingredientID)
} }

Some files were not shown because too many files have changed in this diff Show More