From da87d659dfb30a74d5eb40f909a3b1743e1f0453 Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 25 May 2026 14:59:40 +0700 Subject: [PATCH 01/11] Add expense CRUD --- internal/app/app.go | 10 + internal/constants/error.go | 1 + internal/contract/expense_contract.go | 61 ++++++ internal/entities/entities.go | 1 + internal/entities/expense.go | 40 ++++ internal/handler/expense_handler.go | 181 ++++++++++++++++++ internal/mappers/expense_mapper.go | 90 +++++++++ internal/models/expense.go | 77 ++++++++ internal/processor/expense_processor.go | 167 ++++++++++++++++ internal/processor/expense_repository.go | 17 ++ internal/repository/expense_repository.go | 83 ++++++++ internal/router/router.go | 14 +- internal/service/expense_service.go | 107 +++++++++++ internal/transformer/expense_transformer.go | 75 ++++++++ internal/validator/expense_validator.go | 125 ++++++++++++ .../000070_create_expenses_table.down.sql | 1 + .../000070_create_expenses_table.up.sql | 22 +++ 17 files changed, 1071 insertions(+), 1 deletion(-) create mode 100644 internal/contract/expense_contract.go create mode 100644 internal/entities/expense.go create mode 100644 internal/handler/expense_handler.go create mode 100644 internal/mappers/expense_mapper.go create mode 100644 internal/models/expense.go create mode 100644 internal/processor/expense_processor.go create mode 100644 internal/processor/expense_repository.go create mode 100644 internal/repository/expense_repository.go create mode 100644 internal/service/expense_service.go create mode 100644 internal/transformer/expense_transformer.go create mode 100644 internal/validator/expense_validator.go create mode 100644 migrations/000070_create_expenses_table.down.sql create mode 100644 migrations/000070_create_expenses_table.up.sql diff --git a/internal/app/app.go b/internal/app/app.go index 3f57cd5..36742c7 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -135,6 +135,8 @@ func (a *App) Initialize(cfg *config.Config) error { services.productOutletPriceService, validators.productOutletPriceValidator, selfOrderHandler, + services.expenseService, + validators.expenseValidator, ) return nil @@ -236,6 +238,7 @@ type repositories struct { notificationReceiverRepo *repository.NotificationReceiverRepositoryImpl notificationDeliveryRepo *repository.NotificationDeliveryRepositoryImpl productOutletPriceRepo *repository.ProductOutletPriceRepositoryImpl + expenseRepo *repository.ExpenseRepositoryImpl } func (a *App) initRepositories() *repositories { @@ -288,6 +291,7 @@ func (a *App) initRepositories() *repositories { notificationReceiverRepo: repository.NewNotificationReceiverRepository(a.db), notificationDeliveryRepo: repository.NewNotificationDeliveryRepository(a.db), productOutletPriceRepo: repository.NewProductOutletPriceRepositoryImpl(a.db), + expenseRepo: repository.NewExpenseRepositoryImpl(a.db), } } @@ -333,6 +337,7 @@ type processors struct { userDeviceProcessor *processor.UserDeviceProcessorImpl notificationProcessor *processor.NotificationProcessorImpl productOutletPriceProcessor processor.ProductOutletPriceProcessor + expenseProcessor *processor.ExpenseProcessorImpl } func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processors { @@ -383,6 +388,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor userDeviceProcessor: processor.NewUserDeviceProcessorImpl(repos.userDeviceRepo), notificationProcessor: buildNotificationProcessor(cfg, repos), productOutletPriceProcessor: processor.NewProductOutletPriceProcessorImpl(repos.productOutletPriceRepo, repos.productRepo, repos.outletRepo), + expenseProcessor: processor.NewExpenseProcessorImpl(repos.expenseRepo), } } @@ -422,6 +428,7 @@ type services struct { userDeviceService service.UserDeviceService notificationService service.NotificationService productOutletPriceService service.ProductOutletPriceService + expenseService *service.ExpenseServiceImpl } func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services { @@ -499,6 +506,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con userDeviceService: userDeviceService, notificationService: notificationService, productOutletPriceService: service.NewProductOutletPriceService(processors.productOutletPriceProcessor), + expenseService: service.NewExpenseService(processors.expenseProcessor), } } @@ -541,6 +549,7 @@ type validators struct { userDeviceValidator *validator.UserDeviceValidatorImpl notificationValidator *validator.NotificationValidatorImpl productOutletPriceValidator *validator.ProductOutletPriceValidatorImpl + expenseValidator *validator.ExpenseValidatorImpl } func (a *App) initValidators() *validators { @@ -571,6 +580,7 @@ func (a *App) initValidators() *validators { userDeviceValidator: validator.NewUserDeviceValidator(), notificationValidator: validator.NewNotificationValidator(), productOutletPriceValidator: validator.NewProductOutletPriceValidator(), + expenseValidator: validator.NewExpenseValidator(), } } diff --git a/internal/constants/error.go b/internal/constants/error.go index 4eb6366..8937a58 100644 --- a/internal/constants/error.go +++ b/internal/constants/error.go @@ -60,6 +60,7 @@ const ( NotificationServiceEntity = "notification_service" NotificationHandlerEntity = "notification_handler" ProductOutletPriceServiceEntity = "product_outlet_price_service" + ExpenseServiceEntity = "expense_service" ) var HttpErrorMap = map[string]int{ diff --git a/internal/contract/expense_contract.go b/internal/contract/expense_contract.go new file mode 100644 index 0000000..ef8af97 --- /dev/null +++ b/internal/contract/expense_contract.go @@ -0,0 +1,61 @@ +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"` + ChartOfAccountID string `json:"chart_of_account_id" validate:"required"` + OutletID string `json:"outlet_id" validate:"required"` + Description *string `json:"description,omitempty"` + Tax float64 `json:"tax"` + Total float64 `json:"total" validate:"required"` +} + +type UpdateExpenseRequest struct { + Receiver *string `json:"receiver,omitempty"` + TransactionDate *string `json:"transaction_date,omitempty"` + CodeNumber *string `json:"code_number,omitempty"` + ChartOfAccountID *string `json:"chart_of_account_id,omitempty"` + OutletID *string `json:"outlet_id,omitempty"` + Description *string `json:"description,omitempty"` + Tax *float64 `json:"tax,omitempty"` + Total *float64 `json:"total,omitempty"` + Reserved1 *string `json:"reserved1,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"` + ChartOfAccountID uuid.UUID `json:"chart_of_account_id"` + ChartOfAccountName string `json:"chart_of_account_name,omitempty"` + 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"` +} + +type ListExpenseRequest struct { + Page int `json:"page" validate:"min=1"` + Limit int `json:"limit" validate:"min=1,max=100"` + Search string `json:"search,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"` +} diff --git a/internal/entities/entities.go b/internal/entities/entities.go index dd2b914..c95a5c6 100644 --- a/internal/entities/entities.go +++ b/internal/entities/entities.go @@ -42,6 +42,7 @@ func GetAllEntities() []interface{} { &NotificationReceiver{}, &NotificationDelivery{}, &ProductOutletPrice{}, + &Expense{}, } } diff --git a/internal/entities/expense.go b/internal/entities/expense.go new file mode 100644 index 0000000..4b42c02 --- /dev/null +++ b/internal/entities/expense.go @@ -0,0 +1,40 @@ +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"` + ChartOfAccountID uuid.UUID `gorm:"type:uuid;not null;index" json:"chart_of_account_id"` + 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"` + ChartOfAccount *ChartOfAccount `gorm:"foreignKey:ChartOfAccountID" json:"chart_of_account,omitempty"` +} + +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" +} diff --git a/internal/handler/expense_handler.go b/internal/handler/expense_handler.go new file mode 100644 index 0000000..c4980fd --- /dev/null +++ b/internal/handler/expense_handler.go @@ -0,0 +1,181 @@ +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 + } + + 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") +} diff --git a/internal/mappers/expense_mapper.go b/internal/mappers/expense_mapper.go new file mode 100644 index 0000000..2ae595d --- /dev/null +++ b/internal/mappers/expense_mapper.go @@ -0,0 +1,90 @@ +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, + ChartOfAccountID: entity.ChartOfAccountID, + 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, + ChartOfAccountID: model.ChartOfAccountID, + 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, + ChartOfAccountID: entity.ChartOfAccountID, + Description: entity.Description, + Tax: entity.Tax, + Total: entity.Total, + Reserved1: entity.Reserved1, + CreatedAt: entity.CreatedAt, + UpdatedAt: entity.UpdatedAt, + } + + if entity.ChartOfAccount != nil { + resp.ChartOfAccountName = entity.ChartOfAccount.Name + } + + 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 +} diff --git a/internal/models/expense.go b/internal/models/expense.go new file mode 100644 index 0000000..40801ac --- /dev/null +++ b/internal/models/expense.go @@ -0,0 +1,77 @@ +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"` + ChartOfAccountID uuid.UUID `json:"chart_of_account_id"` + 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 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"` + ChartOfAccountID uuid.UUID `json:"chart_of_account_id"` + ChartOfAccountName string `json:"chart_of_account_name,omitempty"` + 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 CreateExpenseRequest struct { + Receiver string `json:"receiver"` + TransactionDate string `json:"transaction_date"` + CodeNumber string `json:"code_number"` + ChartOfAccountID string `json:"chart_of_account_id"` + OutletID string `json:"outlet_id"` + Description *string `json:"description"` + Tax float64 `json:"tax"` + Total float64 `json:"total"` +} + +type UpdateExpenseRequest struct { + Receiver *string `json:"receiver"` + TransactionDate *string `json:"transaction_date"` + CodeNumber *string `json:"code_number"` + ChartOfAccountID *string `json:"chart_of_account_id"` + OutletID *string `json:"outlet_id"` + Description *string `json:"description"` + Tax *float64 `json:"tax"` + Total *float64 `json:"total"` + Reserved1 *string `json:"reserved1"` +} + +type ListExpenseRequest struct { + Page int `json:"page"` + Limit int `json:"limit"` + Search string `json:"search,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"` +} diff --git a/internal/processor/expense_processor.go b/internal/processor/expense_processor.go new file mode 100644 index 0000000..a223dda --- /dev/null +++ b/internal/processor/expense_processor.go @@ -0,0 +1,167 @@ +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 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) +} + +type ExpenseProcessorImpl struct { + expenseRepo ExpenseRepository +} + +func NewExpenseProcessorImpl(expenseRepo ExpenseRepository) *ExpenseProcessorImpl { + return &ExpenseProcessorImpl{ + expenseRepo: expenseRepo, + } +} + +func (p *ExpenseProcessorImpl) CreateExpense(ctx context.Context, organizationID uuid.UUID, req *models.CreateExpenseRequest) (*models.ExpenseResponse, error) { + chartOfAccountID, err := uuid.Parse(req.ChartOfAccountID) + if err != nil { + return nil, fmt.Errorf("invalid chart_of_account_id: %w", err) + } + + 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) + } + + expenseEntity := &entities.Expense{ + OrganizationID: organizationID, + OutletID: outletID, + Receiver: req.Receiver, + TransactionDate: transactionDate, + CodeNumber: req.CodeNumber, + ChartOfAccountID: chartOfAccountID, + 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) + } + + 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.ChartOfAccountID != nil { + chartOfAccountID, err := uuid.Parse(*req.ChartOfAccountID) + if err != nil { + return nil, fmt.Errorf("invalid chart_of_account_id: %w", err) + } + expenseEntity.ChartOfAccountID = chartOfAccountID + } + 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 + } + + 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 +} diff --git a/internal/processor/expense_repository.go b/internal/processor/expense_repository.go new file mode 100644 index 0000000..46421e0 --- /dev/null +++ b/internal/processor/expense_repository.go @@ -0,0 +1,17 @@ +package processor + +import ( + "apskel-pos-be/internal/entities" + "context" + + "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) +} diff --git a/internal/repository/expense_repository.go b/internal/repository/expense_repository.go new file mode 100644 index 0000000..36148a3 --- /dev/null +++ b/internal/repository/expense_repository.go @@ -0,0 +1,83 @@ +package repository + +import ( + "context" + "strings" + + "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("ChartOfAccount").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("ChartOfAccount").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 ?", + searchPattern, searchPattern, searchPattern) + } + case "outlet_id": + if outletID, ok := value.(uuid.UUID); ok { + query = query.Where("outlet_id = ?", outletID) + } + default: + query = query.Where(key+" = ?", value) + } + } + + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + err := query.Preload("ChartOfAccount").Order("created_at DESC").Limit(limit).Offset(offset).Find(&expenses).Error + return expenses, total, err +} diff --git a/internal/router/router.go b/internal/router/router.go index 96d29a0..4febd1b 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -50,11 +50,12 @@ type Router struct { notificationHandler *handler.NotificationHandler selfOrderHandler *handler.SelfOrderHandler productOutletPriceHandler *handler.ProductOutletPriceHandler + expenseHandler *handler.ExpenseHandler authMiddleware *middleware.AuthMiddleware customerAuthMiddleware *middleware.CustomerAuthMiddleware } -func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler) *Router { +func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler, expenseService *service.ExpenseServiceImpl, expenseValidator *validator.ExpenseValidatorImpl) *Router { return &Router{ config: cfg, @@ -97,6 +98,7 @@ func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authSer notificationHandler: handler.NewNotificationHandler(notificationService, notificationValidator), selfOrderHandler: selfOrderHandler, productOutletPriceHandler: handler.NewProductOutletPriceHandler(productOutletPriceService, productOutletPriceValidator), + expenseHandler: handler.NewExpenseHandler(expenseService, expenseValidator), } } @@ -444,6 +446,16 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { accounts.GET("/:id/balance", r.accountHandler.GetAccountBalance) } + expenses := protected.Group("/expenses") + expenses.Use(r.authMiddleware.RequireAdminOrManager()) + { + expenses.POST("", r.expenseHandler.CreateExpense) + expenses.GET("", r.expenseHandler.ListExpenses) + expenses.GET("/:id", r.expenseHandler.GetExpense) + expenses.PUT("/:id", r.expenseHandler.UpdateExpense) + expenses.DELETE("/:id", r.expenseHandler.DeleteExpense) + } + orderIngredientTransactions := protected.Group("/order-ingredient-transactions") orderIngredientTransactions.Use(r.authMiddleware.RequireAdminOrManager()) { diff --git a/internal/service/expense_service.go b/internal/service/expense_service.go new file mode 100644 index 0000000..a7f13df --- /dev/null +++ b/internal/service/expense_service.go @@ -0,0 +1,107 @@ +package service + +import ( + "apskel-pos-be/internal/appcontext" + "context" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/processor" + "apskel-pos-be/internal/transformer" + + "github.com/google/uuid" +) + +type ExpenseService interface { + CreateExpense(ctx context.Context, apctx *appcontext.ContextInfo, req *contract.CreateExpenseRequest) *contract.Response + UpdateExpense(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, req *contract.UpdateExpenseRequest) *contract.Response + DeleteExpense(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response + GetExpenseByID(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response + ListExpenses(ctx context.Context, apctx *appcontext.ContextInfo, req *contract.ListExpenseRequest) *contract.Response +} + +type ExpenseServiceImpl struct { + expenseProcessor processor.ExpenseProcessor +} + +func NewExpenseService(expenseProcessor processor.ExpenseProcessor) *ExpenseServiceImpl { + return &ExpenseServiceImpl{ + expenseProcessor: expenseProcessor, + } +} + +func (s *ExpenseServiceImpl) CreateExpense(ctx context.Context, apctx *appcontext.ContextInfo, req *contract.CreateExpenseRequest) *contract.Response { + modelReq := transformer.CreateExpenseRequestToModel(req) + + expenseResponse, err := s.expenseProcessor.CreateExpense(ctx, apctx.OrganizationID, modelReq) + if err != nil { + errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.ExpenseServiceEntity, err.Error()) + return contract.BuildErrorResponse([]*contract.ResponseError{errorResp}) + } + + contractResponse := transformer.ExpenseModelResponseToResponse(expenseResponse) + return contract.BuildSuccessResponse(contractResponse) +} + +func (s *ExpenseServiceImpl) UpdateExpense(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, req *contract.UpdateExpenseRequest) *contract.Response { + modelReq := transformer.UpdateExpenseRequestToModel(req) + + expenseResponse, err := s.expenseProcessor.UpdateExpense(ctx, id, apctx.OrganizationID, modelReq) + if err != nil { + errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.ExpenseServiceEntity, err.Error()) + return contract.BuildErrorResponse([]*contract.ResponseError{errorResp}) + } + + contractResponse := transformer.ExpenseModelResponseToResponse(expenseResponse) + return contract.BuildSuccessResponse(contractResponse) +} + +func (s *ExpenseServiceImpl) DeleteExpense(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response { + err := s.expenseProcessor.DeleteExpense(ctx, id, apctx.OrganizationID) + if err != nil { + errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.ExpenseServiceEntity, err.Error()) + return contract.BuildErrorResponse([]*contract.ResponseError{errorResp}) + } + + return contract.BuildSuccessResponse(map[string]interface{}{ + "message": "Expense deleted successfully", + }) +} + +func (s *ExpenseServiceImpl) GetExpenseByID(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response { + expenseResponse, err := s.expenseProcessor.GetExpenseByID(ctx, id, apctx.OrganizationID) + if err != nil { + errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.ExpenseServiceEntity, err.Error()) + return contract.BuildErrorResponse([]*contract.ResponseError{errorResp}) + } + + contractResponse := transformer.ExpenseModelResponseToResponse(expenseResponse) + return contract.BuildSuccessResponse(contractResponse) +} + +func (s *ExpenseServiceImpl) ListExpenses(ctx context.Context, apctx *appcontext.ContextInfo, req *contract.ListExpenseRequest) *contract.Response { + modelReq := transformer.ListExpenseRequestToModel(req) + + filters := make(map[string]interface{}) + if modelReq.Search != "" { + filters["search"] = modelReq.Search + } + + expenses, totalPages, err := s.expenseProcessor.ListExpenses(ctx, apctx.OrganizationID, filters, modelReq.Page, modelReq.Limit) + if err != nil { + errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.ExpenseServiceEntity, err.Error()) + return contract.BuildErrorResponse([]*contract.ResponseError{errorResp}) + } + + contractResponses := transformer.ExpenseModelResponsesToResponses(expenses) + + response := contract.ListExpenseResponse{ + Expenses: contractResponses, + TotalCount: len(contractResponses), + Page: modelReq.Page, + Limit: modelReq.Limit, + TotalPages: totalPages, + } + + return contract.BuildSuccessResponse(response) +} diff --git a/internal/transformer/expense_transformer.go b/internal/transformer/expense_transformer.go new file mode 100644 index 0000000..4de0859 --- /dev/null +++ b/internal/transformer/expense_transformer.go @@ -0,0 +1,75 @@ +package transformer + +import ( + "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/models" +) + +func CreateExpenseRequestToModel(req *contract.CreateExpenseRequest) *models.CreateExpenseRequest { + return &models.CreateExpenseRequest{ + Receiver: req.Receiver, + TransactionDate: req.TransactionDate, + CodeNumber: req.CodeNumber, + ChartOfAccountID: req.ChartOfAccountID, + OutletID: req.OutletID, + Description: req.Description, + Tax: req.Tax, + Total: req.Total, + } +} + +func UpdateExpenseRequestToModel(req *contract.UpdateExpenseRequest) *models.UpdateExpenseRequest { + return &models.UpdateExpenseRequest{ + Receiver: req.Receiver, + TransactionDate: req.TransactionDate, + CodeNumber: req.CodeNumber, + ChartOfAccountID: req.ChartOfAccountID, + OutletID: req.OutletID, + Description: req.Description, + Tax: req.Tax, + Total: req.Total, + Reserved1: req.Reserved1, + } +} + +func ListExpenseRequestToModel(req *contract.ListExpenseRequest) *models.ListExpenseRequest { + return &models.ListExpenseRequest{ + Page: req.Page, + Limit: req.Limit, + Search: req.Search, + } +} + +func ExpenseModelResponseToResponse(expense *models.ExpenseResponse) *contract.ExpenseResponse { + return &contract.ExpenseResponse{ + ID: expense.ID, + OrganizationID: expense.OrganizationID, + OutletID: expense.OutletID, + Receiver: expense.Receiver, + TransactionDate: expense.TransactionDate, + CodeNumber: expense.CodeNumber, + ChartOfAccountID: expense.ChartOfAccountID, + ChartOfAccountName: expense.ChartOfAccountName, + Description: expense.Description, + Tax: expense.Tax, + Total: expense.Total, + Reserved1: expense.Reserved1, + CreatedAt: expense.CreatedAt, + UpdatedAt: expense.UpdatedAt, + } +} + +func ExpenseModelResponsesToResponses(expenses []*models.ExpenseResponse) []contract.ExpenseResponse { + if expenses == nil { + return nil + } + + responses := make([]contract.ExpenseResponse, len(expenses)) + for i, expense := range expenses { + response := ExpenseModelResponseToResponse(expense) + if response != nil { + responses[i] = *response + } + } + return responses +} diff --git a/internal/validator/expense_validator.go b/internal/validator/expense_validator.go new file mode 100644 index 0000000..5af4a35 --- /dev/null +++ b/internal/validator/expense_validator.go @@ -0,0 +1,125 @@ +package validator + +import ( + "errors" + "strings" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/contract" + + "github.com/google/uuid" +) + +type ExpenseValidator interface { + ValidateCreateExpenseRequest(req *contract.CreateExpenseRequest) (error, string) + ValidateUpdateExpenseRequest(req *contract.UpdateExpenseRequest) (error, string) + ValidateListExpenseRequest(req *contract.ListExpenseRequest) (error, string) +} + +type ExpenseValidatorImpl struct{} + +func NewExpenseValidator() *ExpenseValidatorImpl { + return &ExpenseValidatorImpl{} +} + +func (v *ExpenseValidatorImpl) ValidateCreateExpenseRequest(req *contract.CreateExpenseRequest) (error, string) { + if req == nil { + return errors.New("request body is required"), constants.MissingFieldErrorCode + } + + if strings.TrimSpace(req.Receiver) == "" { + return errors.New("receiver is required"), constants.MissingFieldErrorCode + } + + if strings.TrimSpace(req.TransactionDate) == "" { + return errors.New("transaction_date is required"), constants.MissingFieldErrorCode + } + + if strings.TrimSpace(req.CodeNumber) == "" { + return errors.New("code_number is required"), constants.MissingFieldErrorCode + } + + if strings.TrimSpace(req.ChartOfAccountID) == "" { + return errors.New("chart_of_account_id is required"), constants.MissingFieldErrorCode + } + + if _, err := uuid.Parse(req.ChartOfAccountID); err != nil { + return errors.New("chart_of_account_id must be a valid UUID"), constants.MalformedFieldErrorCode + } + + if strings.TrimSpace(req.OutletID) == "" { + return errors.New("outlet_id is required"), constants.MissingFieldErrorCode + } + + if _, err := uuid.Parse(req.OutletID); err != nil { + return errors.New("outlet_id must be a valid UUID"), constants.MalformedFieldErrorCode + } + + if req.Total <= 0 { + return errors.New("total must be greater than 0"), constants.MalformedFieldErrorCode + } + + if req.Tax < 0 { + return errors.New("tax cannot be negative"), constants.MalformedFieldErrorCode + } + + return nil, "" +} + +func (v *ExpenseValidatorImpl) ValidateUpdateExpenseRequest(req *contract.UpdateExpenseRequest) (error, string) { + if req == nil { + return errors.New("request body is required"), constants.MissingFieldErrorCode + } + + if req.Receiver != nil && strings.TrimSpace(*req.Receiver) == "" { + return errors.New("receiver cannot be empty"), constants.MalformedFieldErrorCode + } + + if req.CodeNumber != nil && strings.TrimSpace(*req.CodeNumber) == "" { + return errors.New("code_number cannot be empty"), constants.MalformedFieldErrorCode + } + + if req.ChartOfAccountID != nil { + if strings.TrimSpace(*req.ChartOfAccountID) == "" { + return errors.New("chart_of_account_id cannot be empty"), constants.MalformedFieldErrorCode + } + if _, err := uuid.Parse(*req.ChartOfAccountID); err != nil { + return errors.New("chart_of_account_id must be a valid UUID"), constants.MalformedFieldErrorCode + } + } + + if req.OutletID != nil { + if strings.TrimSpace(*req.OutletID) == "" { + return errors.New("outlet_id cannot be empty"), constants.MalformedFieldErrorCode + } + if _, err := uuid.Parse(*req.OutletID); err != nil { + return errors.New("outlet_id must be a valid UUID"), constants.MalformedFieldErrorCode + } + } + + if req.Total != nil && *req.Total <= 0 { + return errors.New("total must be greater than 0"), constants.MalformedFieldErrorCode + } + + if req.Tax != nil && *req.Tax < 0 { + return errors.New("tax cannot be negative"), constants.MalformedFieldErrorCode + } + + return nil, "" +} + +func (v *ExpenseValidatorImpl) ValidateListExpenseRequest(req *contract.ListExpenseRequest) (error, string) { + if req == nil { + return errors.New("request body is required"), constants.MissingFieldErrorCode + } + + if req.Page < 1 { + return errors.New("page must be at least 1"), constants.MalformedFieldErrorCode + } + + if req.Limit < 1 || req.Limit > 100 { + return errors.New("limit must be between 1 and 100"), constants.MalformedFieldErrorCode + } + + return nil, "" +} diff --git a/migrations/000070_create_expenses_table.down.sql b/migrations/000070_create_expenses_table.down.sql new file mode 100644 index 0000000..2205240 --- /dev/null +++ b/migrations/000070_create_expenses_table.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS expenses; diff --git a/migrations/000070_create_expenses_table.up.sql b/migrations/000070_create_expenses_table.up.sql new file mode 100644 index 0000000..5652a99 --- /dev/null +++ b/migrations/000070_create_expenses_table.up.sql @@ -0,0 +1,22 @@ +CREATE TABLE expenses ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + outlet_id UUID NOT NULL REFERENCES outlets(id) ON DELETE CASCADE, + receiver VARCHAR(255) NOT NULL, + transaction_date DATE NOT NULL, + code_number VARCHAR(50) NOT NULL, + chart_of_account_id UUID NOT NULL REFERENCES chart_of_accounts(id) ON DELETE RESTRICT, + description TEXT, + tax DECIMAL(15,2) NOT NULL DEFAULT 0, + total DECIMAL(15,2) NOT NULL DEFAULT 0, + reserved1 TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX idx_expenses_organization_id ON expenses(organization_id); +CREATE INDEX idx_expenses_outlet_id ON expenses(outlet_id); +CREATE INDEX idx_expenses_chart_of_account_id ON expenses(chart_of_account_id); +CREATE INDEX idx_expenses_transaction_date ON expenses(transaction_date); +CREATE INDEX idx_expenses_code_number ON expenses(code_number); +CREATE INDEX idx_expenses_created_at ON expenses(created_at); From b8be29e1103de1bdf5284a9edb8865e85f700468 Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 25 May 2026 16:19:36 +0700 Subject: [PATCH 02/11] Add item_expense --- internal/contract/expense_contract.go | 64 ++++++---- internal/entities/expense.go | 31 +++-- internal/entities/expense_item.go | 33 +++++ internal/mappers/expense_mapper.go | 116 +++++++++++------- internal/models/expense.go | 95 +++++++++----- internal/processor/expense_processor.go | 82 +++++++++---- internal/processor/expense_repository.go | 2 + internal/repository/expense_repository.go | 24 +++- internal/transformer/expense_transformer.go | 107 +++++++++++----- internal/validator/expense_validator.go | 50 +++++--- .../000070_create_expenses_table.down.sql | 1 + .../000070_create_expenses_table.up.sql | 15 ++- 12 files changed, 439 insertions(+), 181 deletions(-) create mode 100644 internal/entities/expense_item.go diff --git a/internal/contract/expense_contract.go b/internal/contract/expense_contract.go index ef8af97..ef1cedf 100644 --- a/internal/contract/expense_contract.go +++ b/internal/contract/expense_contract.go @@ -7,41 +7,63 @@ import ( ) type CreateExpenseRequest struct { - Receiver string `json:"receiver" validate:"required"` - TransactionDate string `json:"transaction_date" validate:"required"` - CodeNumber string `json:"code_number" validate:"required"` + 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"` + 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"` - OutletID string `json:"outlet_id" validate:"required"` Description *string `json:"description,omitempty"` - Tax float64 `json:"tax"` - Total float64 `json:"total" validate:"required"` + 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"` + Receiver *string `json:"receiver,omitempty"` + TransactionDate *string `json:"transaction_date,omitempty"` + CodeNumber *string `json:"code_number,omitempty"` + OutletID *string `json:"outlet_id,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"` - OutletID *string `json:"outlet_id,omitempty"` Description *string `json:"description,omitempty"` - Tax *float64 `json:"tax,omitempty"` - Total *float64 `json:"total,omitempty"` - Reserved1 *string `json:"reserved1,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"` + 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"` - 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"` + ExpenseID uuid.UUID `json:"expense_id"` ChartOfAccountID uuid.UUID `json:"chart_of_account_id"` ChartOfAccountName string `json:"chart_of_account_name,omitempty"` Description *string `json:"description"` - Tax float64 `json:"tax"` - Total float64 `json:"total"` - Reserved1 *string `json:"reserved1,omitempty"` + Amount float64 `json:"amount"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } diff --git a/internal/entities/expense.go b/internal/entities/expense.go index 4b42c02..a9ba53e 100644 --- a/internal/entities/expense.go +++ b/internal/entities/expense.go @@ -9,23 +9,22 @@ import ( ) 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"` - ChartOfAccountID uuid.UUID `gorm:"type:uuid;not null;index" json:"chart_of_account_id"` - 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"` + 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"` + 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"` - ChartOfAccount *ChartOfAccount `gorm:"foreignKey:ChartOfAccountID" json:"chart_of_account,omitempty"` + Organization *Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"` + Outlet *Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"` + Items []ExpenseItem `gorm:"foreignKey:ExpenseID" json:"items,omitempty"` } func (e *Expense) BeforeCreate(tx *gorm.DB) error { diff --git a/internal/entities/expense_item.go b/internal/entities/expense_item.go new file mode 100644 index 0000000..72b3c24 --- /dev/null +++ b/internal/entities/expense_item.go @@ -0,0 +1,33 @@ +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"` + 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"` +} + +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" +} diff --git a/internal/mappers/expense_mapper.go b/internal/mappers/expense_mapper.go index 2ae595d..83078c2 100644 --- a/internal/mappers/expense_mapper.go +++ b/internal/mappers/expense_mapper.go @@ -11,19 +11,18 @@ func ExpenseEntityToModel(entity *entities.Expense) *models.Expense { } return &models.Expense{ - ID: entity.ID, - OrganizationID: entity.OrganizationID, - OutletID: entity.OutletID, - Receiver: entity.Receiver, - TransactionDate: entity.TransactionDate, - CodeNumber: entity.CodeNumber, - ChartOfAccountID: entity.ChartOfAccountID, - Description: entity.Description, - Tax: entity.Tax, - Total: entity.Total, - Reserved1: entity.Reserved1, - CreatedAt: entity.CreatedAt, - UpdatedAt: entity.UpdatedAt, + ID: entity.ID, + OrganizationID: entity.OrganizationID, + OutletID: entity.OutletID, + Receiver: entity.Receiver, + TransactionDate: entity.TransactionDate, + CodeNumber: entity.CodeNumber, + Description: entity.Description, + Tax: entity.Tax, + Total: entity.Total, + Reserved1: entity.Reserved1, + CreatedAt: entity.CreatedAt, + UpdatedAt: entity.UpdatedAt, } } @@ -33,19 +32,18 @@ func ExpenseModelToEntity(model *models.Expense) *entities.Expense { } return &entities.Expense{ - ID: model.ID, - OrganizationID: model.OrganizationID, - OutletID: model.OutletID, - Receiver: model.Receiver, - TransactionDate: model.TransactionDate, - CodeNumber: model.CodeNumber, - ChartOfAccountID: model.ChartOfAccountID, - Description: model.Description, - Tax: model.Tax, - Total: model.Total, - Reserved1: model.Reserved1, - CreatedAt: model.CreatedAt, - UpdatedAt: model.UpdatedAt, + ID: model.ID, + OrganizationID: model.OrganizationID, + OutletID: model.OutletID, + Receiver: model.Receiver, + TransactionDate: model.TransactionDate, + CodeNumber: model.CodeNumber, + Description: model.Description, + Tax: model.Tax, + Total: model.Total, + Reserved1: model.Reserved1, + CreatedAt: model.CreatedAt, + UpdatedAt: model.UpdatedAt, } } @@ -55,23 +53,22 @@ func ExpenseEntityToResponse(entity *entities.Expense) *models.ExpenseResponse { } resp := &models.ExpenseResponse{ - ID: entity.ID, - OrganizationID: entity.OrganizationID, - OutletID: entity.OutletID, - Receiver: entity.Receiver, - TransactionDate: entity.TransactionDate, - CodeNumber: entity.CodeNumber, - ChartOfAccountID: entity.ChartOfAccountID, - Description: entity.Description, - Tax: entity.Tax, - Total: entity.Total, - Reserved1: entity.Reserved1, - CreatedAt: entity.CreatedAt, - UpdatedAt: entity.UpdatedAt, + ID: entity.ID, + OrganizationID: entity.OrganizationID, + OutletID: entity.OutletID, + Receiver: entity.Receiver, + TransactionDate: entity.TransactionDate, + CodeNumber: entity.CodeNumber, + Description: entity.Description, + Tax: entity.Tax, + Total: entity.Total, + Reserved1: entity.Reserved1, + CreatedAt: entity.CreatedAt, + UpdatedAt: entity.UpdatedAt, } - if entity.ChartOfAccount != nil { - resp.ChartOfAccountName = entity.ChartOfAccount.Name + if entity.Items != nil { + resp.Items = ExpenseItemEntitiesToResponses(entity.Items) } return resp @@ -88,3 +85,40 @@ func ExpenseEntitiesToResponses(entities []*entities.Expense) []*models.ExpenseR } 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, + Description: entity.Description, + Amount: entity.Amount, + CreatedAt: entity.CreatedAt, + UpdatedAt: entity.UpdatedAt, + } + + if entity.ChartOfAccount != nil { + response.ChartOfAccountName = entity.ChartOfAccount.Name + } + + 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 +} diff --git a/internal/models/expense.go b/internal/models/expense.go index 40801ac..b1e6ae8 100644 --- a/internal/models/expense.go +++ b/internal/models/expense.go @@ -7,59 +7,90 @@ import ( ) 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"` + 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"` - 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"` + ExpenseID uuid.UUID `json:"expense_id"` ChartOfAccountID uuid.UUID `json:"chart_of_account_id"` Description *string `json:"description"` - Tax float64 `json:"tax"` - Total float64 `json:"total"` - Reserved1 *string `json:"reserved1"` + 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"` + 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"` - 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"` + ExpenseID uuid.UUID `json:"expense_id"` ChartOfAccountID uuid.UUID `json:"chart_of_account_id"` ChartOfAccountName string `json:"chart_of_account_name,omitempty"` Description *string `json:"description"` - Tax float64 `json:"tax"` - Total float64 `json:"total"` - Reserved1 *string `json:"reserved1"` + 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"` + Receiver string `json:"receiver"` + TransactionDate string `json:"transaction_date"` + CodeNumber string `json:"code_number"` + OutletID string `json:"outlet_id"` + 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"` - OutletID string `json:"outlet_id"` - Description *string `json:"description"` - Tax float64 `json:"tax"` - Total float64 `json:"total"` + Description *string `json:"description,omitempty"` + Amount float64 `json:"amount"` } type UpdateExpenseRequest struct { - Receiver *string `json:"receiver"` - TransactionDate *string `json:"transaction_date"` - CodeNumber *string `json:"code_number"` - ChartOfAccountID *string `json:"chart_of_account_id"` - OutletID *string `json:"outlet_id"` - Description *string `json:"description"` - Tax *float64 `json:"tax"` - Total *float64 `json:"total"` - Reserved1 *string `json:"reserved1"` + Receiver *string `json:"receiver,omitempty"` + TransactionDate *string `json:"transaction_date,omitempty"` + CodeNumber *string `json:"code_number,omitempty"` + OutletID *string `json:"outlet_id,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"` + Description *string `json:"description,omitempty"` + Amount *float64 `json:"amount,omitempty"` } type ListExpenseRequest struct { diff --git a/internal/processor/expense_processor.go b/internal/processor/expense_processor.go index a223dda..94e73f9 100644 --- a/internal/processor/expense_processor.go +++ b/internal/processor/expense_processor.go @@ -31,11 +31,6 @@ func NewExpenseProcessorImpl(expenseRepo ExpenseRepository) *ExpenseProcessorImp } func (p *ExpenseProcessorImpl) CreateExpense(ctx context.Context, organizationID uuid.UUID, req *models.CreateExpenseRequest) (*models.ExpenseResponse, error) { - chartOfAccountID, err := uuid.Parse(req.ChartOfAccountID) - if err != nil { - return nil, fmt.Errorf("invalid chart_of_account_id: %w", err) - } - outletID, err := uuid.Parse(req.OutletID) if err != nil { return nil, fmt.Errorf("invalid outlet_id: %w", err) @@ -47,15 +42,14 @@ func (p *ExpenseProcessorImpl) CreateExpense(ctx context.Context, organizationID } expenseEntity := &entities.Expense{ - OrganizationID: organizationID, - OutletID: outletID, - Receiver: req.Receiver, - TransactionDate: transactionDate, - CodeNumber: req.CodeNumber, - ChartOfAccountID: chartOfAccountID, - Description: req.Description, - Tax: req.Tax, - Total: req.Total, + OrganizationID: organizationID, + OutletID: outletID, + Receiver: req.Receiver, + TransactionDate: transactionDate, + CodeNumber: req.CodeNumber, + Description: req.Description, + Tax: req.Tax, + Total: req.Total, } err = p.expenseRepo.Create(ctx, expenseEntity) @@ -63,6 +57,25 @@ func (p *ExpenseProcessorImpl) CreateExpense(ctx context.Context, organizationID return nil, fmt.Errorf("failed to create expense: %w", err) } + for _, 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) + } + + itemEntity := &entities.ExpenseItem{ + ExpenseID: expenseEntity.ID, + ChartOfAccountID: chartOfAccountID, + Description: itemReq.Description, + Amount: itemReq.Amount, + } + + err = p.expenseRepo.CreateItem(ctx, itemEntity) + 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 @@ -90,13 +103,6 @@ func (p *ExpenseProcessorImpl) UpdateExpense(ctx context.Context, id, organizati if req.CodeNumber != nil { expenseEntity.CodeNumber = *req.CodeNumber } - if req.ChartOfAccountID != nil { - chartOfAccountID, err := uuid.Parse(*req.ChartOfAccountID) - if err != nil { - return nil, fmt.Errorf("invalid chart_of_account_id: %w", err) - } - expenseEntity.ChartOfAccountID = chartOfAccountID - } if req.OutletID != nil { outletID, err := uuid.Parse(*req.OutletID) if err != nil { @@ -117,6 +123,40 @@ func (p *ExpenseProcessorImpl) UpdateExpense(ctx context.Context, id, organizati expenseEntity.Reserved1 = req.Reserved1 } + if req.Items != nil { + err = p.expenseRepo.DeleteItemsByExpenseID(ctx, expenseEntity.ID) + if err != nil { + return nil, fmt.Errorf("failed to delete existing items: %w", err) + } + + for _, 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) + } + } + + amount := 0.0 + if itemReq.Amount != nil { + amount = *itemReq.Amount + } + + itemEntity := &entities.ExpenseItem{ + ExpenseID: expenseEntity.ID, + ChartOfAccountID: chartOfAccountID, + Description: itemReq.Description, + Amount: amount, + } + + err = p.expenseRepo.CreateItem(ctx, itemEntity) + 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) diff --git a/internal/processor/expense_repository.go b/internal/processor/expense_repository.go index 46421e0..baefbc2 100644 --- a/internal/processor/expense_repository.go +++ b/internal/processor/expense_repository.go @@ -14,4 +14,6 @@ type ExpenseRepository interface { 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) + CreateItem(ctx context.Context, item *entities.ExpenseItem) error + DeleteItemsByExpenseID(ctx context.Context, expenseID uuid.UUID) error } diff --git a/internal/repository/expense_repository.go b/internal/repository/expense_repository.go index 36148a3..355163a 100644 --- a/internal/repository/expense_repository.go +++ b/internal/repository/expense_repository.go @@ -27,7 +27,9 @@ func (r *ExpenseRepositoryImpl) Create(ctx context.Context, expense *entities.Ex func (r *ExpenseRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) (*entities.Expense, error) { var expense entities.Expense - err := r.db.WithContext(ctx).Preload("ChartOfAccount").First(&expense, "id = ?", id).Error + err := r.db.WithContext(ctx). + Preload("Items.ChartOfAccount"). + First(&expense, "id = ?", id).Error if err != nil { return nil, err } @@ -36,7 +38,10 @@ func (r *ExpenseRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) (*ent func (r *ExpenseRepositoryImpl) GetByIDAndOrganizationID(ctx context.Context, id, organizationID uuid.UUID) (*entities.Expense, error) { var expense entities.Expense - err := r.db.WithContext(ctx).Preload("ChartOfAccount").Where("id = ? AND organization_id = ?", id, organizationID).First(&expense).Error + err := r.db.WithContext(ctx). + Preload("Items.ChartOfAccount"). + Where("id = ? AND organization_id = ?", id, organizationID). + First(&expense).Error if err != nil { return nil, err } @@ -78,6 +83,19 @@ func (r *ExpenseRepositoryImpl) List(ctx context.Context, organizationID uuid.UU return nil, 0, err } - err := query.Preload("ChartOfAccount").Order("created_at DESC").Limit(limit).Offset(offset).Find(&expenses).Error + err := query. + Preload("Items.ChartOfAccount"). + Order("created_at DESC"). + Limit(limit). + Offset(offset). + Find(&expenses).Error return expenses, total, err } + +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 +} diff --git a/internal/transformer/expense_transformer.go b/internal/transformer/expense_transformer.go index 4de0859..695c0cc 100644 --- a/internal/transformer/expense_transformer.go +++ b/internal/transformer/expense_transformer.go @@ -6,29 +6,59 @@ import ( ) func CreateExpenseRequestToModel(req *contract.CreateExpenseRequest) *models.CreateExpenseRequest { + items := make([]models.CreateExpenseItemRequest, len(req.Items)) + for i, item := range req.Items { + items[i] = CreateExpenseItemRequestToModel(&item) + } + return &models.CreateExpenseRequest{ - Receiver: req.Receiver, - TransactionDate: req.TransactionDate, - CodeNumber: req.CodeNumber, + Receiver: req.Receiver, + TransactionDate: req.TransactionDate, + CodeNumber: req.CodeNumber, + OutletID: req.OutletID, + Description: req.Description, + Tax: req.Tax, + Total: req.Total, + Items: items, + } +} + +func CreateExpenseItemRequestToModel(req *contract.CreateExpenseItemRequest) models.CreateExpenseItemRequest { + return models.CreateExpenseItemRequest{ ChartOfAccountID: req.ChartOfAccountID, - OutletID: req.OutletID, Description: req.Description, - Tax: req.Tax, - Total: req.Total, + Amount: req.Amount, } } func UpdateExpenseRequestToModel(req *contract.UpdateExpenseRequest) *models.UpdateExpenseRequest { - return &models.UpdateExpenseRequest{ - Receiver: req.Receiver, - TransactionDate: req.TransactionDate, - CodeNumber: req.CodeNumber, + modelReq := &models.UpdateExpenseRequest{ + Receiver: req.Receiver, + TransactionDate: req.TransactionDate, + CodeNumber: req.CodeNumber, + OutletID: req.OutletID, + Description: req.Description, + Tax: req.Tax, + Total: req.Total, + Reserved1: req.Reserved1, + } + + if req.Items != nil { + items := make([]models.UpdateExpenseItemRequest, len(req.Items)) + for i, item := range req.Items { + items[i] = UpdateExpenseItemRequestToModel(&item) + } + modelReq.Items = items + } + + return modelReq +} + +func UpdateExpenseItemRequestToModel(req *contract.UpdateExpenseItemRequest) models.UpdateExpenseItemRequest { + return models.UpdateExpenseItemRequest{ ChartOfAccountID: req.ChartOfAccountID, - OutletID: req.OutletID, Description: req.Description, - Tax: req.Tax, - Total: req.Total, - Reserved1: req.Reserved1, + Amount: req.Amount, } } @@ -41,21 +71,42 @@ func ListExpenseRequestToModel(req *contract.ListExpenseRequest) *models.ListExp } func ExpenseModelResponseToResponse(expense *models.ExpenseResponse) *contract.ExpenseResponse { + if expense == nil { + return nil + } + + items := make([]contract.ExpenseItemResponse, len(expense.Items)) + for i, item := range expense.Items { + items[i] = ExpenseItemModelResponseToResponse(&item) + } + return &contract.ExpenseResponse{ - ID: expense.ID, - OrganizationID: expense.OrganizationID, - OutletID: expense.OutletID, - Receiver: expense.Receiver, - TransactionDate: expense.TransactionDate, - CodeNumber: expense.CodeNumber, - ChartOfAccountID: expense.ChartOfAccountID, - ChartOfAccountName: expense.ChartOfAccountName, - Description: expense.Description, - Tax: expense.Tax, - Total: expense.Total, - Reserved1: expense.Reserved1, - CreatedAt: expense.CreatedAt, - UpdatedAt: expense.UpdatedAt, + ID: expense.ID, + OrganizationID: expense.OrganizationID, + OutletID: expense.OutletID, + Receiver: expense.Receiver, + TransactionDate: expense.TransactionDate, + CodeNumber: expense.CodeNumber, + Description: expense.Description, + Tax: expense.Tax, + Total: expense.Total, + Reserved1: expense.Reserved1, + CreatedAt: expense.CreatedAt, + UpdatedAt: expense.UpdatedAt, + Items: items, + } +} + +func ExpenseItemModelResponseToResponse(item *models.ExpenseItemResponse) contract.ExpenseItemResponse { + return contract.ExpenseItemResponse{ + ID: item.ID, + ExpenseID: item.ExpenseID, + ChartOfAccountID: item.ChartOfAccountID, + ChartOfAccountName: item.ChartOfAccountName, + Description: item.Description, + Amount: item.Amount, + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, } } diff --git a/internal/validator/expense_validator.go b/internal/validator/expense_validator.go index 5af4a35..b7c5aae 100644 --- a/internal/validator/expense_validator.go +++ b/internal/validator/expense_validator.go @@ -2,6 +2,7 @@ package validator import ( "errors" + "fmt" "strings" "apskel-pos-be/internal/constants" @@ -39,14 +40,6 @@ func (v *ExpenseValidatorImpl) ValidateCreateExpenseRequest(req *contract.Create return errors.New("code_number is required"), constants.MissingFieldErrorCode } - if strings.TrimSpace(req.ChartOfAccountID) == "" { - return errors.New("chart_of_account_id is required"), constants.MissingFieldErrorCode - } - - if _, err := uuid.Parse(req.ChartOfAccountID); err != nil { - return errors.New("chart_of_account_id must be a valid UUID"), constants.MalformedFieldErrorCode - } - if strings.TrimSpace(req.OutletID) == "" { return errors.New("outlet_id is required"), constants.MissingFieldErrorCode } @@ -63,6 +56,22 @@ func (v *ExpenseValidatorImpl) ValidateCreateExpenseRequest(req *contract.Create return errors.New("tax cannot be negative"), constants.MalformedFieldErrorCode } + if len(req.Items) == 0 { + return errors.New("at least one item is required"), constants.MissingFieldErrorCode + } + + for i, item := range req.Items { + if strings.TrimSpace(item.ChartOfAccountID) == "" { + return fmt.Errorf("item %d: chart_of_account_id is required", i), constants.MissingFieldErrorCode + } + if _, err := uuid.Parse(item.ChartOfAccountID); err != nil { + return fmt.Errorf("item %d: chart_of_account_id must be a valid UUID", i), constants.MalformedFieldErrorCode + } + if item.Amount <= 0 { + return fmt.Errorf("item %d: amount must be greater than 0", i), constants.MalformedFieldErrorCode + } + } + return nil, "" } @@ -79,15 +88,6 @@ func (v *ExpenseValidatorImpl) ValidateUpdateExpenseRequest(req *contract.Update return errors.New("code_number cannot be empty"), constants.MalformedFieldErrorCode } - if req.ChartOfAccountID != nil { - if strings.TrimSpace(*req.ChartOfAccountID) == "" { - return errors.New("chart_of_account_id cannot be empty"), constants.MalformedFieldErrorCode - } - if _, err := uuid.Parse(*req.ChartOfAccountID); err != nil { - return errors.New("chart_of_account_id must be a valid UUID"), constants.MalformedFieldErrorCode - } - } - if req.OutletID != nil { if strings.TrimSpace(*req.OutletID) == "" { return errors.New("outlet_id cannot be empty"), constants.MalformedFieldErrorCode @@ -105,6 +105,22 @@ func (v *ExpenseValidatorImpl) ValidateUpdateExpenseRequest(req *contract.Update return errors.New("tax cannot be negative"), constants.MalformedFieldErrorCode } + if req.Items != nil { + for i, item := range req.Items { + if item.ChartOfAccountID != nil { + if strings.TrimSpace(*item.ChartOfAccountID) == "" { + return fmt.Errorf("item %d: chart_of_account_id cannot be empty", i), constants.MalformedFieldErrorCode + } + if _, err := uuid.Parse(*item.ChartOfAccountID); err != nil { + return fmt.Errorf("item %d: chart_of_account_id must be a valid UUID", i), constants.MalformedFieldErrorCode + } + } + if item.Amount != nil && *item.Amount <= 0 { + return fmt.Errorf("item %d: amount must be greater than 0", i), constants.MalformedFieldErrorCode + } + } + } + return nil, "" } diff --git a/migrations/000070_create_expenses_table.down.sql b/migrations/000070_create_expenses_table.down.sql index 2205240..440c9dc 100644 --- a/migrations/000070_create_expenses_table.down.sql +++ b/migrations/000070_create_expenses_table.down.sql @@ -1 +1,2 @@ +DROP TABLE IF EXISTS expense_items; DROP TABLE IF EXISTS expenses; diff --git a/migrations/000070_create_expenses_table.up.sql b/migrations/000070_create_expenses_table.up.sql index 5652a99..292b548 100644 --- a/migrations/000070_create_expenses_table.up.sql +++ b/migrations/000070_create_expenses_table.up.sql @@ -5,7 +5,6 @@ CREATE TABLE expenses ( receiver VARCHAR(255) NOT NULL, transaction_date DATE NOT NULL, code_number VARCHAR(50) NOT NULL, - chart_of_account_id UUID NOT NULL REFERENCES chart_of_accounts(id) ON DELETE RESTRICT, description TEXT, tax DECIMAL(15,2) NOT NULL DEFAULT 0, total DECIMAL(15,2) NOT NULL DEFAULT 0, @@ -16,7 +15,19 @@ CREATE TABLE expenses ( CREATE INDEX idx_expenses_organization_id ON expenses(organization_id); CREATE INDEX idx_expenses_outlet_id ON expenses(outlet_id); -CREATE INDEX idx_expenses_chart_of_account_id ON expenses(chart_of_account_id); CREATE INDEX idx_expenses_transaction_date ON expenses(transaction_date); CREATE INDEX idx_expenses_code_number ON expenses(code_number); CREATE INDEX idx_expenses_created_at ON expenses(created_at); + +CREATE TABLE expense_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + expense_id UUID NOT NULL REFERENCES expenses(id) ON DELETE CASCADE, + chart_of_account_id UUID NOT NULL REFERENCES chart_of_accounts(id) ON DELETE RESTRICT, + description TEXT, + amount DECIMAL(15,2) NOT NULL DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX idx_expense_items_expense_id ON expense_items(expense_id); +CREATE INDEX idx_expense_items_chart_of_account_id ON expense_items(chart_of_account_id); From 024d9ee637792e0606e99b076944c77b2d056165 Mon Sep 17 00:00:00 2001 From: ryan Date: Tue, 26 May 2026 14:59:56 +0700 Subject: [PATCH 03/11] Update profit-loss --- internal/app/app.go | 2 +- internal/contract/analytics_contract.go | 73 ++---- internal/entities/analytics.go | 58 ++--- internal/models/analytics.go | 73 ++---- internal/processor/analytics_processor.go | 168 +++++++++----- .../processor/analytics_processor_test.go | 21 +- internal/repository/analytics_repository.go | 217 ++++++++---------- internal/service/analytics_service.go | 16 +- internal/service/report_service.go | 52 ++++- internal/transformer/analytics_transformer.go | 95 +++----- 10 files changed, 354 insertions(+), 421 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index 36742c7..82f4442 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -359,7 +359,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor paymentMethodProcessor: processor.NewPaymentMethodProcessorImpl(repos.paymentMethodRepo), fileProcessor: processor.NewFileProcessorImpl(repos.fileRepo, fileClient), customerProcessor: processor.NewCustomerProcessor(repos.customerRepo), - analyticsProcessor: processor.NewAnalyticsProcessorImpl(repos.analyticsRepo), + analyticsProcessor: processor.NewAnalyticsProcessorImpl(repos.analyticsRepo, repos.expenseRepo), tableProcessor: processor.NewTableProcessor(repos.tableRepo, repos.orderRepo), unitProcessor: processor.NewUnitProcessor(repos.unitRepo), ingredientProcessor: processor.NewIngredientProcessor(repos.ingredientRepo, repos.unitRepo, repos.ingredientCompositionRepo), diff --git a/internal/contract/analytics_contract.go b/internal/contract/analytics_contract.go index 5389240..d9d6556 100644 --- a/internal/contract/analytics_contract.go +++ b/internal/contract/analytics_contract.go @@ -236,68 +236,33 @@ type DashboardOverview struct { RefundedOrders int64 `json:"refunded_orders"` } -// ProfitLossAnalyticsRequest represents the request for profit and loss analytics type ProfitLossAnalyticsRequest 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"` + Date string `form:"date" validate:"required"` } -// ProfitLossAnalyticsResponse represents the response for profit and loss analytics type ProfitLossAnalyticsResponse 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 ProfitLossSummary `json:"summary"` - Data []ProfitLossData `json:"data"` - ProductData []ProductProfitData `json:"product_data"` + OrganizationID uuid.UUID `json:"organization_id"` + OutletID *uuid.UUID `json:"outlet_id,omitempty"` + Date time.Time `json:"date"` + MainSummary []ProfitLossSummaryRow `json:"main_summary"` + OperationalExpenses []OperationalExpenseItem `json:"operational_expenses"` + OperationalExpensesTotal float64 `json:"operational_expenses_total"` } -// ProfitLossSummary represents the summary of profit and loss analytics -type ProfitLossSummary struct { - TotalRevenue float64 `json:"total_revenue"` - TotalCost float64 `json:"total_cost"` - GrossProfit float64 `json:"gross_profit"` - GrossProfitMargin float64 `json:"gross_profit_margin"` - TotalTax float64 `json:"total_tax"` - TotalDiscount float64 `json:"total_discount"` - NetProfit float64 `json:"net_profit"` - NetProfitMargin float64 `json:"net_profit_margin"` - TotalOrders int64 `json:"total_orders"` - AverageProfit float64 `json:"average_profit"` - ProfitabilityRatio float64 `json:"profitability_ratio"` +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"` } -// ProfitLossData represents individual profit and loss data point by time period -type ProfitLossData struct { - Date time.Time `json:"date"` - Revenue float64 `json:"revenue"` - Cost float64 `json:"cost"` - GrossProfit float64 `json:"gross_profit"` - GrossProfitMargin float64 `json:"gross_profit_margin"` - Tax float64 `json:"tax"` - Discount float64 `json:"discount"` - NetProfit float64 `json:"net_profit"` - NetProfitMargin float64 `json:"net_profit_margin"` - Orders int64 `json:"orders"` -} - -// ProductProfitData represents profit data for individual products -type ProductProfitData struct { - ProductID uuid.UUID `json:"product_id"` - ProductName string `json:"product_name"` - CategoryID uuid.UUID `json:"category_id"` - CategoryName string `json:"category_name"` - QuantitySold int64 `json:"quantity_sold"` - Revenue float64 `json:"revenue"` - Cost float64 `json:"cost"` - GrossProfit float64 `json:"gross_profit"` - GrossProfitMargin float64 `json:"gross_profit_margin"` - AveragePrice float64 `json:"average_price"` - AverageCost float64 `json:"average_cost"` - ProfitPerUnit float64 `json:"profit_per_unit"` +type OperationalExpenseItem struct { + Item string `json:"item"` + Nominal float64 `json:"nominal"` } diff --git a/internal/entities/analytics.go b/internal/entities/analytics.go index 2b69ae7..f06aff9 100644 --- a/internal/entities/analytics.go +++ b/internal/entities/analytics.go @@ -113,54 +113,22 @@ type DashboardOverview struct { RefundedOrders int64 `json:"refunded_orders"` } -// ProfitLossAnalytics represents profit and loss analytics data type ProfitLossAnalytics struct { - Summary ProfitLossSummary `json:"summary"` - Data []ProfitLossData `json:"data"` - ProductData []ProductProfitData `json:"product_data"` + TodayRevenue float64 + TodayCost float64 + MtdRevenue float64 + MtdCost float64 + TodayExpenseByCategory []ExpenseCategoryTotal + MtdExpenseByCategory []ExpenseCategoryTotal + OperationalExpenseItems []OperationalExpenseItem } -// ProfitLossSummary represents profit and loss summary data -type ProfitLossSummary struct { - TotalRevenue float64 `json:"total_revenue"` - TotalCost float64 `json:"total_cost"` - GrossProfit float64 `json:"gross_profit"` - GrossProfitMargin float64 `json:"gross_profit_margin"` - TotalTax float64 `json:"total_tax"` - TotalDiscount float64 `json:"total_discount"` - NetProfit float64 `json:"net_profit"` - NetProfitMargin float64 `json:"net_profit_margin"` - TotalOrders int64 `json:"total_orders"` - AverageProfit float64 `json:"average_profit"` - ProfitabilityRatio float64 `json:"profitability_ratio"` +type ExpenseCategoryTotal struct { + CategoryName string + Amount float64 } -// ProfitLossData represents profit and loss data by time period -type ProfitLossData struct { - Date time.Time `json:"date"` - Revenue float64 `json:"revenue"` - Cost float64 `json:"cost"` - GrossProfit float64 `json:"gross_profit"` - GrossProfitMargin float64 `json:"gross_profit_margin"` - Tax float64 `json:"tax"` - Discount float64 `json:"discount"` - NetProfit float64 `json:"net_profit"` - NetProfitMargin float64 `json:"net_profit_margin"` - Orders int64 `json:"orders"` -} - -// ProductProfitData represents profit data for individual products -type ProductProfitData struct { - ProductID uuid.UUID `json:"product_id"` - ProductName string `json:"product_name"` - CategoryID uuid.UUID `json:"category_id"` - CategoryName string `json:"category_name"` - QuantitySold int64 `json:"quantity_sold"` - Revenue float64 `json:"revenue"` - Cost float64 `json:"cost"` - GrossProfit float64 `json:"gross_profit"` - GrossProfitMargin float64 `json:"gross_profit_margin"` - AveragePrice float64 `json:"average_price"` - AverageCost float64 `json:"average_cost"` - ProfitPerUnit float64 `json:"profit_per_unit"` +type OperationalExpenseItem struct { + Description string + Amount float64 } diff --git a/internal/models/analytics.go b/internal/models/analytics.go index 2821518..2a76965 100644 --- a/internal/models/analytics.go +++ b/internal/models/analytics.go @@ -246,68 +246,33 @@ type DashboardOverview struct { RefundedOrders int64 `json:"refunded_orders"` } -// ProfitLossAnalyticsRequest represents the request for profit and loss analytics type ProfitLossAnalyticsRequest 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"` + Date time.Time `validate:"required"` } -// ProfitLossAnalyticsResponse represents the response for profit and loss analytics type ProfitLossAnalyticsResponse 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 ProfitLossSummary `json:"summary"` - Data []ProfitLossData `json:"data"` - ProductData []ProductProfitData `json:"product_data"` + OrganizationID uuid.UUID `json:"organization_id"` + OutletID *uuid.UUID `json:"outlet_id,omitempty"` + Date time.Time `json:"date"` + MainSummary []ProfitLossSummaryRow `json:"main_summary"` + OperationalExpenses []OperationalExpenseItem `json:"operational_expenses"` + OperationalExpensesTotal float64 `json:"operational_expenses_total"` } -// ProfitLossSummary represents the summary of profit and loss analytics -type ProfitLossSummary struct { - TotalRevenue float64 `json:"total_revenue"` - TotalCost float64 `json:"total_cost"` - GrossProfit float64 `json:"gross_profit"` - GrossProfitMargin float64 `json:"gross_profit_margin"` - TotalTax float64 `json:"total_tax"` - TotalDiscount float64 `json:"total_discount"` - NetProfit float64 `json:"net_profit"` - NetProfitMargin float64 `json:"net_profit_margin"` - TotalOrders int64 `json:"total_orders"` - AverageProfit float64 `json:"average_profit"` - ProfitabilityRatio float64 `json:"profitability_ratio"` +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"` } -// ProfitLossData represents individual profit and loss data point by time period -type ProfitLossData struct { - Date time.Time `json:"date"` - Revenue float64 `json:"revenue"` - Cost float64 `json:"cost"` - GrossProfit float64 `json:"gross_profit"` - GrossProfitMargin float64 `json:"gross_profit_margin"` - Tax float64 `json:"tax"` - Discount float64 `json:"discount"` - NetProfit float64 `json:"net_profit"` - NetProfitMargin float64 `json:"net_profit_margin"` - Orders int64 `json:"orders"` -} - -// ProductProfitData represents profit data for individual products -type ProductProfitData struct { - ProductID uuid.UUID `json:"product_id"` - ProductName string `json:"product_name"` - CategoryID uuid.UUID `json:"category_id"` - CategoryName string `json:"category_name"` - QuantitySold int64 `json:"quantity_sold"` - Revenue float64 `json:"revenue"` - Cost float64 `json:"cost"` - GrossProfit float64 `json:"gross_profit"` - GrossProfitMargin float64 `json:"gross_profit_margin"` - AveragePrice float64 `json:"average_price"` - AverageCost float64 `json:"average_cost"` - ProfitPerUnit float64 `json:"profit_per_unit"` +type OperationalExpenseItem struct { + Item string `json:"item"` + Nominal float64 `json:"nominal"` } diff --git a/internal/processor/analytics_processor.go b/internal/processor/analytics_processor.go index 344b801..c9ae596 100644 --- a/internal/processor/analytics_processor.go +++ b/internal/processor/analytics_processor.go @@ -3,8 +3,10 @@ package processor import ( "context" "fmt" + "strings" "time" + "apskel-pos-be/internal/entities" "apskel-pos-be/internal/models" "apskel-pos-be/internal/repository" ) @@ -21,11 +23,13 @@ type AnalyticsProcessor interface { type AnalyticsProcessorImpl struct { analyticsRepo repository.AnalyticsRepository + expenseRepo ExpenseRepository } -func NewAnalyticsProcessorImpl(analyticsRepo repository.AnalyticsRepository) *AnalyticsProcessorImpl { +func NewAnalyticsProcessorImpl(analyticsRepo repository.AnalyticsRepository, expenseRepo ExpenseRepository) *AnalyticsProcessorImpl { return &AnalyticsProcessorImpl{ analyticsRepo: analyticsRepo, + expenseRepo: expenseRepo, } } @@ -394,71 +398,127 @@ func (p *AnalyticsProcessorImpl) GetDashboardAnalytics(ctx context.Context, req } func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req *models.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsResponse, error) { - if req.DateFrom.After(req.DateTo) { - return nil, fmt.Errorf("date_from cannot be after date_to") + if req.Date.IsZero() { + return nil, fmt.Errorf("date is required") } - // Get analytics data from repository - 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.Date) if err != nil { return nil, fmt.Errorf("failed to get profit/loss analytics: %w", err) } - // Transform entities to models - data := make([]models.ProfitLossData, len(result.Data)) - for i, item := range result.Data { - data[i] = models.ProfitLossData{ - Date: item.Date, - Revenue: item.Revenue, - Cost: item.Cost, - GrossProfit: item.GrossProfit, - GrossProfitMargin: item.GrossProfitMargin, - Tax: item.Tax, - Discount: item.Discount, - NetProfit: item.NetProfit, - NetProfitMargin: item.NetProfitMargin, - Orders: item.Orders, + todayPromosi := getExpenseAmountByCategory(result.TodayExpenseByCategory, "promosi") + todayLainLain := getExpenseAmountByCategory(result.TodayExpenseByCategory, "lain") + todayTotalOps := todayPromosi + todayLainLain + todayGaji := getExpenseAmountByCategory(result.TodayExpenseByCategory, "gaji") + + mtdPromosi := getExpenseAmountByCategory(result.MtdExpenseByCategory, "promosi") + mtdLainLain := getExpenseAmountByCategory(result.MtdExpenseByCategory, "lain") + mtdTotalOps := mtdPromosi + mtdLainLain + mtdGaji := getExpenseAmountByCategory(result.MtdExpenseByCategory, "gaji") + + 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 } - productData := make([]models.ProductProfitData, len(result.ProductData)) - for i, item := range result.ProductData { - productData[i] = models.ProductProfitData{ - ProductID: item.ProductID, - ProductName: item.ProductName, - CategoryID: item.CategoryID, - CategoryName: item.CategoryName, - QuantitySold: item.QuantitySold, - Revenue: item.Revenue, - Cost: item.Cost, - GrossProfit: item.GrossProfit, - GrossProfitMargin: item.GrossProfitMargin, - AveragePrice: item.AveragePrice, - AverageCost: item.AverageCost, - ProfitPerUnit: item.ProfitPerUnit, + 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: []models.ProfitLossSummaryRow{ + { + ID: "by_promosi", Label: "1. By Promosi", + TodayNominal: todayPromosi, TodayPct: todayPct(todayPromosi), + MtdNominal: mtdPromosi, MtdPct: mtdPct(mtdPromosi), + }, + { + ID: "by_lain_lain", Label: "2. By Lain lain", + TodayNominal: todayLainLain, TodayPct: todayPct(todayLainLain), + MtdNominal: mtdLainLain, MtdPct: mtdPct(mtdLainLain), + }, + { + ID: "total_biaya_ops", Label: "Total Biaya OPS (4.1+4.2)", IsBold: true, + TodayNominal: todayTotalOps, TodayPct: todayPct(todayTotalOps), + MtdNominal: mtdTotalOps, MtdPct: mtdPct(mtdTotalOps), + }, + }, + }, + { + 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.Description, + Nominal: item.Amount, } + opsTotal += item.Amount } return &models.ProfitLossAnalyticsResponse{ - OrganizationID: req.OrganizationID, - OutletID: req.OutletID, - DateFrom: req.DateFrom, - DateTo: req.DateTo, - GroupBy: req.GroupBy, - Summary: models.ProfitLossSummary{ - TotalRevenue: result.Summary.TotalRevenue, - TotalCost: result.Summary.TotalCost, - GrossProfit: result.Summary.GrossProfit, - GrossProfitMargin: result.Summary.GrossProfitMargin, - TotalTax: result.Summary.TotalTax, - TotalDiscount: result.Summary.TotalDiscount, - NetProfit: result.Summary.NetProfit, - NetProfitMargin: result.Summary.NetProfitMargin, - TotalOrders: result.Summary.TotalOrders, - AverageProfit: result.Summary.AverageProfit, - ProfitabilityRatio: result.Summary.ProfitabilityRatio, - }, - Data: data, - ProductData: productData, + OrganizationID: req.OrganizationID, + OutletID: req.OutletID, + Date: req.Date, + MainSummary: mainSummary, + OperationalExpenses: opsItems, + OperationalExpensesTotal: opsTotal, }, nil } + +func getExpenseAmountByCategory(categories []entities.ExpenseCategoryTotal, keyword string) float64 { + for _, cat := range categories { + if strings.Contains(strings.ToLower(cat.CategoryName), keyword) { + return cat.Amount + } + } + return 0 +} diff --git a/internal/processor/analytics_processor_test.go b/internal/processor/analytics_processor_test.go index cad6e01..6fb57c5 100644 --- a/internal/processor/analytics_processor_test.go +++ b/internal/processor/analytics_processor_test.go @@ -40,10 +40,27 @@ func (analyticsRepositoryStub) GetDashboardOverview(context.Context, uuid.UUID, return nil, nil } -func (analyticsRepositoryStub) GetProfitLossAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time, string) (*entities.ProfitLossAnalytics, error) { +func (analyticsRepositoryStub) GetProfitLossAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time) (*entities.ProfitLossAnalytics, error) { return nil, 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) 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" @@ -55,7 +72,7 @@ func TestAnalyticsProcessorGetPurchasingAnalyticsPassesOutletName(t *testing.T) TotalPurchases: 125, }, }, - }) + }, expenseRepositoryStub{}) result, err := processor.GetPurchasingAnalytics(context.Background(), &models.PurchasingAnalyticsRequest{ OrganizationID: uuid.New(), diff --git a/internal/repository/analytics_repository.go b/internal/repository/analytics_repository.go index 9c2a0cf..b28863d 100644 --- a/internal/repository/analytics_repository.go +++ b/internal/repository/analytics_repository.go @@ -17,7 +17,7 @@ type AnalyticsRepository interface { GetProductAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, limit int) ([]*entities.ProductAnalytics, error) GetProductAnalyticsPerCategory(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]*entities.ProductAnalyticsPerCategory, error) GetDashboardOverview(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (*entities.DashboardOverview, error) - GetProfitLossAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) (*entities.ProfitLossAnalytics, error) + GetProfitLossAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, date time.Time) (*entities.ProfitLossAnalytics, error) } type AnalyticsRepositoryImpl struct { @@ -432,152 +432,119 @@ func (r *AnalyticsRepositoryImpl) GetDashboardOverview(ctx context.Context, orga return &result, nil } -func (r *AnalyticsRepositoryImpl) GetProfitLossAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) (*entities.ProfitLossAnalytics, error) { - // Summary query - var summary entities.ProfitLossSummary +func (r *AnalyticsRepositoryImpl) GetProfitLossAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, date time.Time) (*entities.ProfitLossAnalytics, error) { + mtdStart := time.Date(date.Year(), date.Month(), 1, 0, 0, 0, 0, date.Location()) + todayStart := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + todayEnd := todayStart.Add(24 * time.Hour).Add(-time.Nanosecond) - summaryQuery := r.db.WithContext(ctx). - Table("orders o"). - Select(` - COALESCE(SUM(o.total_amount), 0) as total_revenue, - COALESCE(SUM(o.total_cost), 0) as total_cost, - COALESCE(SUM(o.total_amount - o.total_cost), 0) as gross_profit, - CASE - WHEN SUM(o.total_amount) > 0 - THEN (SUM(o.total_amount - o.total_cost) / SUM(o.total_amount)) * 100 - ELSE 0 - END as gross_profit_margin, - COALESCE(SUM(o.tax_amount), 0) as total_tax, - COALESCE(SUM(o.discount_amount), 0) as total_discount, - COALESCE(SUM(o.total_amount - o.total_cost - o.discount_amount), 0) as net_profit, - CASE - WHEN SUM(o.total_amount) > 0 - THEN (SUM(o.total_amount - o.total_cost - o.discount_amount) / SUM(o.total_amount)) * 100 - ELSE 0 - END as net_profit_margin, - COUNT(o.id) as total_orders, - CASE - WHEN COUNT(o.id) > 0 - THEN SUM(o.total_amount - o.total_cost - o.discount_amount) / COUNT(o.id) - ELSE 0 - END as average_profit, - CASE - WHEN SUM(o.total_cost) > 0 - THEN (SUM(o.total_amount - o.total_cost) / SUM(o.total_cost)) * 100 - ELSE 0 - END as profitability_ratio - `). - Where("o.organization_id = ?", organizationID). - Where("o.status = ?", entities.OrderStatusCompleted). - Where("o.payment_status = ?", entities.PaymentStatusCompleted). - Where("o.is_void = false AND o.is_refund = false"). - Where("o.created_at >= ? AND o.created_at <= ?", dateFrom, dateTo) - - summaryQuery = r.resolveOutletID(summaryQuery, outletID, "o.outlet_id") - - err := summaryQuery.Scan(&summary).Error - if err != nil { - return nil, err + type revenueCostResult struct { + Revenue float64 + Cost float64 } - // Time series data query - var timeFormat string - switch groupBy { - case "hour": - timeFormat = "DATE_TRUNC('hour', o.created_at)" - case "week": - timeFormat = "DATE_TRUNC('week', o.created_at)" - case "month": - timeFormat = "DATE_TRUNC('month', o.created_at)" - default: // day - timeFormat = "DATE_TRUNC('day', o.created_at)" - } - - var data []entities.ProfitLossData - - dataQuery := r.db.WithContext(ctx). + var todayRC revenueCostResult + todayQuery := r.db.WithContext(ctx). Table("orders o"). Select(` - `+timeFormat+` as date, COALESCE(SUM(o.total_amount), 0) as revenue, - COALESCE(SUM(o.total_cost), 0) as cost, - COALESCE(SUM(o.total_amount - o.total_cost), 0) as gross_profit, - CASE - WHEN SUM(o.total_amount) > 0 - THEN (SUM(o.total_amount - o.total_cost) / SUM(o.total_amount)) * 100 - ELSE 0 - END as gross_profit_margin, - COALESCE(SUM(o.tax_amount), 0) as tax, - COALESCE(SUM(o.discount_amount), 0) as discount, - COALESCE(SUM(o.total_amount - o.total_cost - o.discount_amount), 0) as net_profit, - CASE - WHEN SUM(o.total_amount) > 0 - THEN (SUM(o.total_amount - o.total_cost - o.discount_amount) / SUM(o.total_amount)) * 100 - ELSE 0 - END as net_profit_margin, - COUNT(o.id) as orders + COALESCE(SUM(o.total_cost), 0) as cost `). Where("o.organization_id = ?", organizationID). Where("o.status = ?", entities.OrderStatusCompleted). Where("o.payment_status = ?", entities.PaymentStatusCompleted). Where("o.is_void = false AND o.is_refund = false"). - Where("o.created_at >= ? AND o.created_at <= ?", dateFrom, dateTo). - Group(timeFormat). - Order(timeFormat) + Where("o.created_at >= ? AND o.created_at <= ?", todayStart, todayEnd) + todayQuery = r.resolveOutletID(todayQuery, outletID, "o.outlet_id") + if err := todayQuery.Scan(&todayRC).Error; err != nil { + return nil, err + } - dataQuery = r.resolveOutletID(dataQuery, outletID, "o.outlet_id") + var mtdRC revenueCostResult + mtdQuery := r.db.WithContext(ctx). + Table("orders o"). + Select(` + COALESCE(SUM(o.total_amount), 0) as revenue, + COALESCE(SUM(o.total_cost), 0) as cost + `). + Where("o.organization_id = ?", organizationID). + Where("o.status = ?", entities.OrderStatusCompleted). + Where("o.payment_status = ?", entities.PaymentStatusCompleted). + Where("o.is_void = false AND o.is_refund = false"). + Where("o.created_at >= ? AND o.created_at <= ?", mtdStart, todayEnd) + mtdQuery = r.resolveOutletID(mtdQuery, outletID, "o.outlet_id") + if err := mtdQuery.Scan(&mtdRC).Error; err != nil { + return nil, err + } - err = dataQuery.Scan(&data).Error + todayExpenseByCategory, err := r.getExpenseByCategory(ctx, organizationID, outletID, todayStart, todayEnd) if err != nil { return nil, err } - // Product profit data query - var productData []entities.ProductProfitData + mtdExpenseByCategory, err := r.getExpenseByCategory(ctx, organizationID, outletID, mtdStart, todayEnd) + if err != nil { + return nil, err + } - productQuery := r.db.WithContext(ctx). - Table("order_items oi"). - Select(` - p.id as product_id, - p.name as product_name, - c.id as category_id, - c.name as category_name, - SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END) as quantity_sold, - SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END) as revenue, - SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0)) ELSE 0 END) as cost, - SUM(CASE WHEN oi.is_fully_refunded = false THEN (oi.total_price - COALESCE(oi.refund_amount, 0)) - (oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0))) ELSE 0 END) as gross_profit, - CASE - WHEN SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END) > 0 - THEN (SUM(CASE WHEN oi.is_fully_refunded = false THEN (oi.total_price - COALESCE(oi.refund_amount, 0)) - (oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0))) ELSE 0 END) / SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END)) * 100 - ELSE 0 - END as gross_profit_margin, - AVG(CASE WHEN oi.is_fully_refunded = false THEN oi.unit_price ELSE NULL END) as average_price, - AVG(CASE WHEN oi.is_fully_refunded = false THEN oi.unit_cost ELSE NULL END) as average_cost, - AVG(CASE WHEN oi.is_fully_refunded = false THEN oi.unit_price - oi.unit_cost ELSE NULL END) as profit_per_unit - `). - Joins("JOIN orders o ON oi.order_id = o.id"). - Joins("JOIN products p ON oi.product_id = p.id"). - Joins("JOIN categories c ON p.category_id = c.id"). - Where("o.organization_id = ?", organizationID). - Where("o.status = ?", entities.OrderStatusCompleted). - Where("o.payment_status = ?", entities.PaymentStatusCompleted). - Where("o.is_void = false AND o.is_refund = false"). - Where("oi.status != ?", entities.OrderItemStatusCancelled). - Where("o.created_at >= ? AND o.created_at <= ?", dateFrom, dateTo). - Group("p.id, p.name, c.id, c.name"). - Order("p.name ASC"). - Limit(1000) - - productQuery = r.resolveOutletID(productQuery, outletID, "o.outlet_id") - - err = productQuery.Scan(&productData).Error + opsItems, err := r.getOperationalExpenseItems(ctx, organizationID, outletID, mtdStart, todayEnd) if err != nil { return nil, err } return &entities.ProfitLossAnalytics{ - Summary: summary, - Data: data, - ProductData: productData, + TodayRevenue: todayRC.Revenue, + TodayCost: todayRC.Cost, + MtdRevenue: mtdRC.Revenue, + MtdCost: mtdRC.Cost, + TodayExpenseByCategory: todayExpenseByCategory, + MtdExpenseByCategory: mtdExpenseByCategory, + OperationalExpenseItems: opsItems, }, nil } + +func (r *AnalyticsRepositoryImpl) getExpenseByCategory(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]entities.ExpenseCategoryTotal, error) { + var results []entities.ExpenseCategoryTotal + + query := r.db.WithContext(ctx). + Table("expense_items ei"). + Select(`COALESCE(parent_coa.name, 'Lain-lain') as category_name, COALESCE(SUM(ei.amount), 0) as amount`). + 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.transaction_date >= ? AND e.transaction_date <= ?", dateFrom, dateTo) + + if outletID != nil { + query = query.Where("e.outlet_id = ?", *outletID) + } + + err := query. + Group("parent_coa.name"). + Order("parent_coa.name"). + Scan(&results).Error + + return results, err +} + +func (r *AnalyticsRepositoryImpl) getOperationalExpenseItems(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]entities.OperationalExpenseItem, error) { + var results []entities.OperationalExpenseItem + + query := r.db.WithContext(ctx). + Table("expense_items ei"). + Select(`COALESCE(ei.description, coa.name) as description, COALESCE(SUM(ei.amount), 0) as amount`). + 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.transaction_date >= ? AND e.transaction_date <= ?", dateFrom, dateTo) + + if outletID != nil { + query = query.Where("e.outlet_id = ?", *outletID) + } + + err := query. + Group("COALESCE(ei.description, coa.name)"). + Order("amount DESC"). + Scan(&results).Error + + return results, err +} diff --git a/internal/service/analytics_service.go b/internal/service/analytics_service.go index 7643c3e..c0483a7 100644 --- a/internal/service/analytics_service.go +++ b/internal/service/analytics_service.go @@ -306,20 +306,8 @@ func (s *AnalyticsServiceImpl) validateProfitLossAnalyticsRequest(req *models.Pr return fmt.Errorf("organization_id is required") } - if req.DateFrom.IsZero() { - return fmt.Errorf("date_from is required") - } - - if req.DateTo.IsZero() { - return fmt.Errorf("date_to is required") - } - - if req.DateFrom.After(req.DateTo) { - return fmt.Errorf("date_from cannot be after date_to") - } - - if req.GroupBy != "" && req.GroupBy != "hour" && req.GroupBy != "day" && req.GroupBy != "week" && req.GroupBy != "month" { - return fmt.Errorf("invalid group_by value, must be one of: hour, day, week, month") + if req.Date.IsZero() { + return fmt.Errorf("date is required") } return nil diff --git a/internal/service/report_service.go b/internal/service/report_service.go index 55aeede..915eb28 100644 --- a/internal/service/report_service.go +++ b/internal/service/report_service.go @@ -113,7 +113,8 @@ func (s *ReportServiceImpl) GenerateDailyTransactionPDF(ctx context.Context, org end := day.Add(24*time.Hour - time.Nanosecond) salesReq := &models.SalesAnalyticsRequest{OrganizationID: orgID, OutletID: &outID, DateFrom: start, DateTo: end, GroupBy: "day"} - plReq := &models.ProfitLossAnalyticsRequest{OrganizationID: orgID, OutletID: &outID, DateFrom: start, DateTo: end, GroupBy: "day"} + plReq := &models.ProfitLossAnalyticsRequest{OrganizationID: orgID, OutletID: &outID, Date: day} + productReq := &models.ProductAnalyticsRequest{OrganizationID: orgID, OutletID: &outID, DateFrom: start, DateTo: end, Limit: 1000} sales, err := s.analyticsService.GetSalesAnalytics(ctx, salesReq) if err != nil { @@ -123,6 +124,15 @@ func (s *ReportServiceImpl) GenerateDailyTransactionPDF(ctx context.Context, org if err != nil { return "", "", fmt.Errorf("get profit/loss analytics: %w", err) } + products, err := s.analyticsService.GetProductAnalytics(ctx, productReq) + if err != nil { + return "", "", fmt.Errorf("get product analytics: %w", err) + } + + totalOmset := getPLNominalByID(pl.MainSummary, "total_omset") + hpp := getPLNominalByID(pl.MainSummary, "hpp") + labaKotor := getPLNominalByID(pl.MainSummary, "laba_kotor") + labaKotorPct := getPLPctByID(pl.MainSummary, "laba_kotor") data := reportTemplateData{ OrganizationName: org.Name, @@ -133,28 +143,28 @@ func (s *ReportServiceImpl) GenerateDailyTransactionPDF(ctx context.Context, org GeneratedBy: generatedBy, PrintTime: time.Now().Format("02/01/2006 15:04:05"), Summary: reportSummary{ - TotalTransactions: pl.Summary.TotalOrders, + TotalTransactions: sales.Summary.TotalOrders, TotalItems: sales.Summary.TotalItems, - GrossSales: formatCurrency(pl.Summary.TotalRevenue), - Discount: formatCurrency(pl.Summary.TotalDiscount), - Tax: formatCurrency(pl.Summary.TotalTax), + GrossSales: formatCurrency(totalOmset), + Discount: formatCurrency(sales.Summary.TotalDiscount), + Tax: formatCurrency(sales.Summary.TotalTax), NetSales: formatCurrency(sales.Summary.NetSales), - COGS: formatCurrency(pl.Summary.TotalCost), - GrossProfit: formatCurrency(pl.Summary.GrossProfit), - GrossMarginPercent: fmt.Sprintf("%.2f", pl.Summary.GrossProfitMargin), + COGS: formatCurrency(hpp), + GrossProfit: formatCurrency(labaKotor), + GrossMarginPercent: fmt.Sprintf("%.2f", labaKotorPct), }, } - items := make([]reportItem, 0, len(pl.ProductData)) - for _, p := range pl.ProductData { + items := make([]reportItem, 0, len(products.Data)) + for _, p := range products.Data { items = append(items, reportItem{ Name: p.ProductName, Quantity: p.QuantitySold, GrossSales: formatCurrency(p.Revenue), Discount: formatCurrency(0), NetSales: formatCurrency(p.Revenue), - COGS: formatCurrency(p.Cost), - GrossProfit: formatCurrency(p.GrossProfit), + COGS: formatCurrency(p.StandardHppTotal), + GrossProfit: formatCurrency(p.Revenue - p.StandardHppTotal), }) } data.Items = items @@ -190,3 +200,21 @@ func (s *ReportServiceImpl) GenerateDailyTransactionPDF(ctx context.Context, org return publicURL, fileName, nil } + +func getPLNominalByID(rows []models.ProfitLossSummaryRow, id string) float64 { + for _, row := range rows { + if row.ID == id { + return row.TodayNominal + } + } + return 0 +} + +func getPLPctByID(rows []models.ProfitLossSummaryRow, id string) float64 { + for _, row := range rows { + if row.ID == id { + return row.TodayPct + } + } + return 0 +} diff --git a/internal/transformer/analytics_transformer.go b/internal/transformer/analytics_transformer.go index 52105e2..c5436cc 100644 --- a/internal/transformer/analytics_transformer.go +++ b/internal/transformer/analytics_transformer.go @@ -427,93 +427,68 @@ func DashboardAnalyticsModelToContract(resp *models.DashboardAnalyticsResponse) } } -// ProfitLossAnalyticsContractToModel transforms contract request to model func ProfitLossAnalyticsContractToModel(req *contract.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsRequest, error) { if req == nil { return nil, fmt.Errorf("request cannot be nil") } - // Parse date range using utility function - dateFrom, dateTo, err := util.ParseDateRangeToJakartaTime(req.DateFrom, req.DateTo) + dateTime, err := util.ParseDateToJakartaTime(req.Date) if err != nil { return nil, fmt.Errorf("invalid date format: %w", err) } - if dateFrom == nil || dateTo == nil { - return nil, fmt.Errorf("both date_from and date_to are required") + if dateTime == nil { + return nil, fmt.Errorf("date is required") } return &models.ProfitLossAnalyticsRequest{ OrganizationID: req.OrganizationID, OutletID: parseOutletID(req.OutletID), - DateFrom: *dateFrom, - DateTo: *dateTo, - GroupBy: req.GroupBy, + Date: *dateTime, }, nil } -// ProfitLossAnalyticsModelToContract transforms model response to contract func ProfitLossAnalyticsModelToContract(resp *models.ProfitLossAnalyticsResponse) *contract.ProfitLossAnalyticsResponse { if resp == nil { return nil } - // Transform profit/loss data - data := make([]contract.ProfitLossData, len(resp.Data)) - for i, item := range resp.Data { - data[i] = contract.ProfitLossData{ - Date: item.Date, - Revenue: item.Revenue, - Cost: item.Cost, - GrossProfit: item.GrossProfit, - GrossProfitMargin: item.GrossProfitMargin, - Tax: item.Tax, - Discount: item.Discount, - NetProfit: item.NetProfit, - NetProfitMargin: item.NetProfitMargin, - Orders: item.Orders, - } + mainSummary := make([]contract.ProfitLossSummaryRow, len(resp.MainSummary)) + for i, row := range resp.MainSummary { + mainSummary[i] = profitLossSummaryRowModelToContract(row) } - // Transform product profit data - productData := make([]contract.ProductProfitData, len(resp.ProductData)) - for i, item := range resp.ProductData { - productData[i] = contract.ProductProfitData{ - ProductID: item.ProductID, - ProductName: item.ProductName, - CategoryID: item.CategoryID, - CategoryName: item.CategoryName, - QuantitySold: item.QuantitySold, - Revenue: item.Revenue, - Cost: item.Cost, - GrossProfit: item.GrossProfit, - GrossProfitMargin: item.GrossProfitMargin, - AveragePrice: item.AveragePrice, - AverageCost: item.AverageCost, - ProfitPerUnit: item.ProfitPerUnit, + opsItems := make([]contract.OperationalExpenseItem, len(resp.OperationalExpenses)) + for i, item := range resp.OperationalExpenses { + opsItems[i] = contract.OperationalExpenseItem{ + Item: item.Item, + Nominal: item.Nominal, } } return &contract.ProfitLossAnalyticsResponse{ - OrganizationID: resp.OrganizationID, - OutletID: resp.OutletID, - DateFrom: resp.DateFrom, - DateTo: resp.DateTo, - GroupBy: resp.GroupBy, - Summary: contract.ProfitLossSummary{ - TotalRevenue: resp.Summary.TotalRevenue, - TotalCost: resp.Summary.TotalCost, - GrossProfit: resp.Summary.GrossProfit, - GrossProfitMargin: resp.Summary.GrossProfitMargin, - TotalTax: resp.Summary.TotalTax, - TotalDiscount: resp.Summary.TotalDiscount, - NetProfit: resp.Summary.NetProfit, - NetProfitMargin: resp.Summary.NetProfitMargin, - TotalOrders: resp.Summary.TotalOrders, - AverageProfit: resp.Summary.AverageProfit, - ProfitabilityRatio: resp.Summary.ProfitabilityRatio, - }, - Data: data, - ProductData: productData, + OrganizationID: resp.OrganizationID, + OutletID: resp.OutletID, + Date: resp.Date, + MainSummary: mainSummary, + OperationalExpenses: opsItems, + OperationalExpensesTotal: resp.OperationalExpensesTotal, + } +} + +func profitLossSummaryRowModelToContract(row models.ProfitLossSummaryRow) contract.ProfitLossSummaryRow { + subItems := make([]contract.ProfitLossSummaryRow, len(row.SubItems)) + for i, sub := range row.SubItems { + subItems[i] = profitLossSummaryRowModelToContract(sub) + } + return contract.ProfitLossSummaryRow{ + ID: row.ID, + Label: row.Label, + IsBold: row.IsBold, + TodayNominal: row.TodayNominal, + TodayPct: row.TodayPct, + MtdNominal: row.MtdNominal, + MtdPct: row.MtdPct, + SubItems: subItems, } } From a55a3f4ee2be0d0c8a37e7816f061daa1dfffe8a Mon Sep 17 00:00:00 2001 From: ryan Date: Tue, 26 May 2026 15:25:47 +0700 Subject: [PATCH 04/11] add expense_name --- internal/contract/expense_contract.go | 3 +++ internal/entities/expense.go | 1 + internal/mappers/expense_mapper.go | 3 +++ internal/models/expense.go | 4 ++++ internal/processor/expense_processor.go | 4 ++++ internal/repository/expense_repository.go | 4 ++-- internal/transformer/expense_transformer.go | 3 +++ internal/validator/expense_validator.go | 8 ++++++++ migrations/000071_add_expense_name_to_expenses.down.sql | 2 ++ migrations/000071_add_expense_name_to_expenses.up.sql | 2 ++ 10 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 migrations/000071_add_expense_name_to_expenses.down.sql create mode 100644 migrations/000071_add_expense_name_to_expenses.up.sql diff --git a/internal/contract/expense_contract.go b/internal/contract/expense_contract.go index ef1cedf..ddc6f9e 100644 --- a/internal/contract/expense_contract.go +++ b/internal/contract/expense_contract.go @@ -7,6 +7,7 @@ import ( ) type CreateExpenseRequest struct { + ExpenseName string `json:"expense_name" validate:"required"` Receiver string `json:"receiver" validate:"required"` TransactionDate string `json:"transaction_date" validate:"required"` CodeNumber string `json:"code_number" validate:"required"` @@ -24,6 +25,7 @@ type CreateExpenseItemRequest struct { } type UpdateExpenseRequest struct { + ExpenseName *string `json:"expense_name,omitempty"` Receiver *string `json:"receiver,omitempty"` TransactionDate *string `json:"transaction_date,omitempty"` CodeNumber *string `json:"code_number,omitempty"` @@ -45,6 +47,7 @@ type ExpenseResponse struct { ID uuid.UUID `json:"id"` OrganizationID uuid.UUID `json:"organization_id"` OutletID uuid.UUID `json:"outlet_id"` + ExpenseName string `json:"expense_name"` Receiver string `json:"receiver"` TransactionDate time.Time `json:"transaction_date"` CodeNumber string `json:"code_number"` diff --git a/internal/entities/expense.go b/internal/entities/expense.go index a9ba53e..622b955 100644 --- a/internal/entities/expense.go +++ b/internal/entities/expense.go @@ -12,6 +12,7 @@ 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"` + ExpenseName string `gorm:"not null;size:255" json:"expense_name"` 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"` diff --git a/internal/mappers/expense_mapper.go b/internal/mappers/expense_mapper.go index 83078c2..17324a6 100644 --- a/internal/mappers/expense_mapper.go +++ b/internal/mappers/expense_mapper.go @@ -14,6 +14,7 @@ func ExpenseEntityToModel(entity *entities.Expense) *models.Expense { ID: entity.ID, OrganizationID: entity.OrganizationID, OutletID: entity.OutletID, + ExpenseName: entity.ExpenseName, Receiver: entity.Receiver, TransactionDate: entity.TransactionDate, CodeNumber: entity.CodeNumber, @@ -35,6 +36,7 @@ func ExpenseModelToEntity(model *models.Expense) *entities.Expense { ID: model.ID, OrganizationID: model.OrganizationID, OutletID: model.OutletID, + ExpenseName: model.ExpenseName, Receiver: model.Receiver, TransactionDate: model.TransactionDate, CodeNumber: model.CodeNumber, @@ -56,6 +58,7 @@ func ExpenseEntityToResponse(entity *entities.Expense) *models.ExpenseResponse { ID: entity.ID, OrganizationID: entity.OrganizationID, OutletID: entity.OutletID, + ExpenseName: entity.ExpenseName, Receiver: entity.Receiver, TransactionDate: entity.TransactionDate, CodeNumber: entity.CodeNumber, diff --git a/internal/models/expense.go b/internal/models/expense.go index b1e6ae8..37b3b13 100644 --- a/internal/models/expense.go +++ b/internal/models/expense.go @@ -10,6 +10,7 @@ type Expense struct { ID uuid.UUID `json:"id"` OrganizationID uuid.UUID `json:"organization_id"` OutletID uuid.UUID `json:"outlet_id"` + ExpenseName string `json:"expense_name"` Receiver string `json:"receiver"` TransactionDate time.Time `json:"transaction_date"` CodeNumber string `json:"code_number"` @@ -35,6 +36,7 @@ type ExpenseResponse struct { ID uuid.UUID `json:"id"` OrganizationID uuid.UUID `json:"organization_id"` OutletID uuid.UUID `json:"outlet_id"` + ExpenseName string `json:"expense_name"` Receiver string `json:"receiver"` TransactionDate time.Time `json:"transaction_date"` CodeNumber string `json:"code_number"` @@ -59,6 +61,7 @@ type ExpenseItemResponse struct { } type CreateExpenseRequest struct { + ExpenseName string `json:"expense_name"` Receiver string `json:"receiver"` TransactionDate string `json:"transaction_date"` CodeNumber string `json:"code_number"` @@ -76,6 +79,7 @@ type CreateExpenseItemRequest struct { } type UpdateExpenseRequest struct { + ExpenseName *string `json:"expense_name,omitempty"` Receiver *string `json:"receiver,omitempty"` TransactionDate *string `json:"transaction_date,omitempty"` CodeNumber *string `json:"code_number,omitempty"` diff --git a/internal/processor/expense_processor.go b/internal/processor/expense_processor.go index 94e73f9..76b30a8 100644 --- a/internal/processor/expense_processor.go +++ b/internal/processor/expense_processor.go @@ -44,6 +44,7 @@ func (p *ExpenseProcessorImpl) CreateExpense(ctx context.Context, organizationID expenseEntity := &entities.Expense{ OrganizationID: organizationID, OutletID: outletID, + ExpenseName: req.ExpenseName, Receiver: req.Receiver, TransactionDate: transactionDate, CodeNumber: req.CodeNumber, @@ -90,6 +91,9 @@ func (p *ExpenseProcessorImpl) UpdateExpense(ctx context.Context, id, organizati return nil, fmt.Errorf("expense not found: %w", err) } + if req.ExpenseName != nil { + expenseEntity.ExpenseName = *req.ExpenseName + } if req.Receiver != nil { expenseEntity.Receiver = *req.Receiver } diff --git a/internal/repository/expense_repository.go b/internal/repository/expense_repository.go index 355163a..1bfcad0 100644 --- a/internal/repository/expense_repository.go +++ b/internal/repository/expense_repository.go @@ -67,8 +67,8 @@ func (r *ExpenseRepositoryImpl) List(ctx context.Context, organizationID uuid.UU 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 ?", - searchPattern, searchPattern, searchPattern) + query = query.Where("LOWER(expense_name) LIKE ? OR LOWER(receiver) LIKE ? OR LOWER(code_number) LIKE ? OR LOWER(description) LIKE ?", + searchPattern, searchPattern, searchPattern, searchPattern) } case "outlet_id": if outletID, ok := value.(uuid.UUID); ok { diff --git a/internal/transformer/expense_transformer.go b/internal/transformer/expense_transformer.go index 695c0cc..4b1d059 100644 --- a/internal/transformer/expense_transformer.go +++ b/internal/transformer/expense_transformer.go @@ -12,6 +12,7 @@ func CreateExpenseRequestToModel(req *contract.CreateExpenseRequest) *models.Cre } return &models.CreateExpenseRequest{ + ExpenseName: req.ExpenseName, Receiver: req.Receiver, TransactionDate: req.TransactionDate, CodeNumber: req.CodeNumber, @@ -33,6 +34,7 @@ func CreateExpenseItemRequestToModel(req *contract.CreateExpenseItemRequest) mod func UpdateExpenseRequestToModel(req *contract.UpdateExpenseRequest) *models.UpdateExpenseRequest { modelReq := &models.UpdateExpenseRequest{ + ExpenseName: req.ExpenseName, Receiver: req.Receiver, TransactionDate: req.TransactionDate, CodeNumber: req.CodeNumber, @@ -84,6 +86,7 @@ func ExpenseModelResponseToResponse(expense *models.ExpenseResponse) *contract.E ID: expense.ID, OrganizationID: expense.OrganizationID, OutletID: expense.OutletID, + ExpenseName: expense.ExpenseName, Receiver: expense.Receiver, TransactionDate: expense.TransactionDate, CodeNumber: expense.CodeNumber, diff --git a/internal/validator/expense_validator.go b/internal/validator/expense_validator.go index b7c5aae..b0cb81d 100644 --- a/internal/validator/expense_validator.go +++ b/internal/validator/expense_validator.go @@ -28,6 +28,10 @@ func (v *ExpenseValidatorImpl) ValidateCreateExpenseRequest(req *contract.Create return errors.New("request body is required"), constants.MissingFieldErrorCode } + if strings.TrimSpace(req.ExpenseName) == "" { + return errors.New("expense_name is required"), constants.MissingFieldErrorCode + } + if strings.TrimSpace(req.Receiver) == "" { return errors.New("receiver is required"), constants.MissingFieldErrorCode } @@ -80,6 +84,10 @@ func (v *ExpenseValidatorImpl) ValidateUpdateExpenseRequest(req *contract.Update return errors.New("request body is required"), constants.MissingFieldErrorCode } + if req.ExpenseName != nil && strings.TrimSpace(*req.ExpenseName) == "" { + return errors.New("expense_name cannot be empty"), constants.MalformedFieldErrorCode + } + if req.Receiver != nil && strings.TrimSpace(*req.Receiver) == "" { return errors.New("receiver cannot be empty"), constants.MalformedFieldErrorCode } diff --git a/migrations/000071_add_expense_name_to_expenses.down.sql b/migrations/000071_add_expense_name_to_expenses.down.sql new file mode 100644 index 0000000..553fdc9 --- /dev/null +++ b/migrations/000071_add_expense_name_to_expenses.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS idx_expenses_expense_name; +ALTER TABLE expenses DROP COLUMN expense_name; diff --git a/migrations/000071_add_expense_name_to_expenses.up.sql b/migrations/000071_add_expense_name_to_expenses.up.sql new file mode 100644 index 0000000..9cb91e7 --- /dev/null +++ b/migrations/000071_add_expense_name_to_expenses.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE expenses ADD COLUMN expense_name VARCHAR(255) NOT NULL DEFAULT ''; +CREATE INDEX idx_expenses_expense_name ON expenses(expense_name); From 66a8126da0315bc98727b50d69cc159ecfba8dc5 Mon Sep 17 00:00:00 2001 From: Efril Date: Thu, 28 May 2026 11:52:16 +0700 Subject: [PATCH 05/11] expense filter by outlet and date range --- internal/contract/expense_contract.go | 9 ++++++--- internal/handler/expense_handler.go | 16 ++++++++++++++++ internal/models/expense.go | 9 ++++++--- internal/repository/expense_repository.go | 9 +++++++++ internal/service/expense_service.go | 18 ++++++++++++++++++ internal/transformer/expense_transformer.go | 9 ++++++--- 6 files changed, 61 insertions(+), 9 deletions(-) diff --git a/internal/contract/expense_contract.go b/internal/contract/expense_contract.go index ddc6f9e..c451e69 100644 --- a/internal/contract/expense_contract.go +++ b/internal/contract/expense_contract.go @@ -72,9 +72,12 @@ type ExpenseItemResponse struct { } type ListExpenseRequest struct { - Page int `json:"page" validate:"min=1"` - Limit int `json:"limit" validate:"min=1,max=100"` - Search string `json:"search,omitempty"` + 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"` + StartDate string `json:"start_date,omitempty"` + EndDate string `json:"end_date,omitempty"` } type ListExpenseResponse struct { diff --git a/internal/handler/expense_handler.go b/internal/handler/expense_handler.go index c4980fd..1b3fc8e 100644 --- a/internal/handler/expense_handler.go +++ b/internal/handler/expense_handler.go @@ -164,6 +164,22 @@ func (h *ExpenseHandler) ListExpenses(c *gin.Context) { req.Search = search } + // 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()) diff --git a/internal/models/expense.go b/internal/models/expense.go index 37b3b13..68e8645 100644 --- a/internal/models/expense.go +++ b/internal/models/expense.go @@ -98,9 +98,12 @@ type UpdateExpenseItemRequest struct { } type ListExpenseRequest struct { - Page int `json:"page"` - Limit int `json:"limit"` - Search string `json:"search,omitempty"` + Page int `json:"page"` + Limit int `json:"limit"` + Search string `json:"search,omitempty"` + OutletID string `json:"outlet_id,omitempty"` + StartDate string `json:"start_date,omitempty"` + EndDate string `json:"end_date,omitempty"` } type ListExpenseResponse struct { diff --git a/internal/repository/expense_repository.go b/internal/repository/expense_repository.go index 1bfcad0..7711915 100644 --- a/internal/repository/expense_repository.go +++ b/internal/repository/expense_repository.go @@ -3,6 +3,7 @@ package repository import ( "context" "strings" + "time" "github.com/google/uuid" @@ -74,6 +75,14 @@ func (r *ExpenseRepositoryImpl) List(ctx context.Context, organizationID uuid.UU if outletID, ok := value.(uuid.UUID); ok { query = query.Where("outlet_id = ?", outletID) } + 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) } diff --git a/internal/service/expense_service.go b/internal/service/expense_service.go index a7f13df..24af26c 100644 --- a/internal/service/expense_service.go +++ b/internal/service/expense_service.go @@ -3,6 +3,7 @@ package service import ( "apskel-pos-be/internal/appcontext" "context" + "time" "apskel-pos-be/internal/constants" "apskel-pos-be/internal/contract" @@ -86,6 +87,23 @@ func (s *ExpenseServiceImpl) ListExpenses(ctx context.Context, apctx *appcontext if modelReq.Search != "" { filters["search"] = modelReq.Search } + if modelReq.OutletID != "" { + outletID, err := uuid.Parse(modelReq.OutletID) + if err == nil { + filters["outlet_id"] = outletID + } + } + if modelReq.StartDate != "" { + if startDate, err := time.Parse("2006-01-02", modelReq.StartDate); err == nil { + filters["start_date"] = startDate + } + } + if modelReq.EndDate != "" { + if endDate, err := time.Parse("2006-01-02", modelReq.EndDate); err == nil { + // include the full end date day + filters["end_date"] = endDate.Add(24*time.Hour - time.Nanosecond) + } + } expenses, totalPages, err := s.expenseProcessor.ListExpenses(ctx, apctx.OrganizationID, filters, modelReq.Page, modelReq.Limit) if err != nil { diff --git a/internal/transformer/expense_transformer.go b/internal/transformer/expense_transformer.go index 4b1d059..bbf0ce7 100644 --- a/internal/transformer/expense_transformer.go +++ b/internal/transformer/expense_transformer.go @@ -66,9 +66,12 @@ func UpdateExpenseItemRequestToModel(req *contract.UpdateExpenseItemRequest) mod func ListExpenseRequestToModel(req *contract.ListExpenseRequest) *models.ListExpenseRequest { return &models.ListExpenseRequest{ - Page: req.Page, - Limit: req.Limit, - Search: req.Search, + Page: req.Page, + Limit: req.Limit, + Search: req.Search, + OutletID: req.OutletID, + StartDate: req.StartDate, + EndDate: req.EndDate, } } From 23ac572e3f809bd05972553ac1c5ac08a9344368 Mon Sep 17 00:00:00 2001 From: Efril Date: Thu, 28 May 2026 13:49:57 +0700 Subject: [PATCH 06/11] add print_to_checker at product outlet --- internal/contract/product_contract.go | 29 +++--- .../contract/product_outlet_price_contract.go | 30 ++++--- internal/entities/product_outlet_price.go | 13 +-- .../mappers/product_outlet_price_mapper.go | 26 +++--- internal/models/product.go | 10 ++- internal/models/product_outlet_price.go | 23 ++--- .../product_outlet_price_processor.go | 7 +- internal/processor/product_processor.go | 88 +++++++++++++++---- .../product_outlet_price_repository.go | 17 ++-- .../service/product_outlet_price_service.go | 7 +- .../product_outlet_price_transformer.go | 23 ++--- internal/transformer/product_transformer.go | 32 ++++--- ..._checker_to_product_outlet_prices.down.sql | 1 + ...to_checker_to_product_outlet_prices.up.sql | 1 + 14 files changed, 199 insertions(+), 108 deletions(-) create mode 100644 migrations/000070_add_print_to_checker_to_product_outlet_prices.down.sql create mode 100644 migrations/000070_add_print_to_checker_to_product_outlet_prices.up.sql diff --git a/internal/contract/product_contract.go b/internal/contract/product_contract.go index 1fec481..61c9e90 100644 --- a/internal/contract/product_contract.go +++ b/internal/contract/product_contract.go @@ -17,6 +17,7 @@ type CreateProductRequest struct { BusinessType *string `json:"business_type,omitempty"` ImageURL *string `json:"image_url,omitempty" validate:"omitempty,max=500"` PrinterType *string `json:"printer_type,omitempty" validate:"omitempty,max=50"` + PrintToChecker *bool `json:"print_to_checker,omitempty"` Metadata map[string]interface{} `json:"metadata,omitempty"` IsActive *bool `json:"is_active,omitempty"` Variants []CreateProductVariantRequest `json:"variants,omitempty"` @@ -26,19 +27,20 @@ type CreateProductRequest struct { } type UpdateProductRequest struct { - OutletID *uuid.UUID `json:"outlet_id,omitempty"` - CategoryID *uuid.UUID `json:"category_id,omitempty"` - SKU *string `json:"sku,omitempty"` - Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"` - Description *string `json:"description,omitempty"` - Price *float64 `json:"price,omitempty" validate:"omitempty,min=0"` - Cost *float64 `json:"cost,omitempty" validate:"omitempty,min=0"` - BusinessType *string `json:"business_type,omitempty"` - ImageURL *string `json:"image_url,omitempty" validate:"omitempty,max=500"` - PrinterType *string `json:"printer_type,omitempty" validate:"omitempty,max=50"` - Metadata map[string]interface{} `json:"metadata,omitempty"` - IsActive *bool `json:"is_active,omitempty"` - ReorderLevel *int `json:"reorder_level,omitempty" validate:"omitempty,min=0"` + OutletID *uuid.UUID `json:"outlet_id,omitempty"` + CategoryID *uuid.UUID `json:"category_id,omitempty"` + SKU *string `json:"sku,omitempty"` + Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"` + Description *string `json:"description,omitempty"` + Price *float64 `json:"price,omitempty" validate:"omitempty,min=0"` + Cost *float64 `json:"cost,omitempty" validate:"omitempty,min=0"` + BusinessType *string `json:"business_type,omitempty"` + ImageURL *string `json:"image_url,omitempty" validate:"omitempty,max=500"` + PrinterType *string `json:"printer_type,omitempty" validate:"omitempty,max=50"` + PrintToChecker *bool `json:"print_to_checker,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + IsActive *bool `json:"is_active,omitempty"` + ReorderLevel *int `json:"reorder_level,omitempty" validate:"omitempty,min=0"` } type CreateProductVariantRequest struct { @@ -71,6 +73,7 @@ type ProductResponse struct { BusinessType string `json:"business_type"` ImageURL *string `json:"image_url"` PrinterType string `json:"printer_type"` + PrintToChecker bool `json:"print_to_checker"` Metadata map[string]interface{} `json:"metadata"` IsActive bool `json:"is_active"` CreatedAt time.Time `json:"created_at"` diff --git a/internal/contract/product_outlet_price_contract.go b/internal/contract/product_outlet_price_contract.go index ed66de7..0e9b189 100644 --- a/internal/contract/product_outlet_price_contract.go +++ b/internal/contract/product_outlet_price_contract.go @@ -7,23 +7,26 @@ import ( ) 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"` + 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"` + 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"` - CreatedAt time.Time `json:"created_at,omitempty"` - UpdatedAt time.Time `json:"updated_at,omitempty"` + 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 { @@ -37,6 +40,7 @@ type BulkCreateProductOutletPriceRequest struct { } type CreateProductOutletPricePerOutletRequest struct { - OutletID uuid.UUID `json:"outlet_id" validate:"required"` - Price float64 `json:"price" validate:"required,min=0"` + OutletID uuid.UUID `json:"outlet_id" validate:"required"` + Price float64 `json:"price" validate:"required,min=0"` + PrintToChecker bool `json:"print_to_checker"` } diff --git a/internal/entities/product_outlet_price.go b/internal/entities/product_outlet_price.go index 16f623a..0cb39bf 100644 --- a/internal/entities/product_outlet_price.go +++ b/internal/entities/product_outlet_price.go @@ -8,12 +8,13 @@ import ( ) 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"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + 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"` diff --git a/internal/mappers/product_outlet_price_mapper.go b/internal/mappers/product_outlet_price_mapper.go index 12556be..b7e181a 100644 --- a/internal/mappers/product_outlet_price_mapper.go +++ b/internal/mappers/product_outlet_price_mapper.go @@ -11,12 +11,13 @@ func ProductOutletPriceEntityToModel(entity *entities.ProductOutletPrice) *model } return &models.ProductOutletPrice{ - ID: entity.ID, - ProductID: entity.ProductID, - OutletID: entity.OutletID, - Price: entity.Price, - CreatedAt: entity.CreatedAt, - UpdatedAt: entity.UpdatedAt, + ID: entity.ID, + ProductID: entity.ProductID, + OutletID: entity.OutletID, + Price: entity.Price, + PrintToChecker: entity.PrintToChecker, + CreatedAt: entity.CreatedAt, + UpdatedAt: entity.UpdatedAt, } } @@ -26,12 +27,13 @@ func ProductOutletPriceModelToEntity(model *models.ProductOutletPrice) *entities } return &entities.ProductOutletPrice{ - ID: model.ID, - ProductID: model.ProductID, - OutletID: model.OutletID, - Price: model.Price, - CreatedAt: model.CreatedAt, - UpdatedAt: model.UpdatedAt, + ID: model.ID, + ProductID: model.ProductID, + OutletID: model.OutletID, + Price: model.Price, + PrintToChecker: model.PrintToChecker, + CreatedAt: model.CreatedAt, + UpdatedAt: model.UpdatedAt, } } diff --git a/internal/models/product.go b/internal/models/product.go index 77e6b6e..0e0b6a1 100644 --- a/internal/models/product.go +++ b/internal/models/product.go @@ -50,6 +50,7 @@ type CreateProductRequest struct { BusinessType constants.BusinessType `validate:"required"` ImageURL *string `validate:"omitempty,max=500"` PrinterType *string `validate:"omitempty,max=50"` + PrintToChecker *bool `validate:"omitempty"` UnitID *uuid.UUID `validate:"omitempty"` HasIngredients bool `validate:"omitempty"` Metadata map[string]interface{} @@ -70,6 +71,7 @@ type UpdateProductRequest struct { Cost *float64 `validate:"omitempty,min=0"` ImageURL *string `validate:"omitempty,max=500"` PrinterType *string `validate:"omitempty,max=50"` + PrintToChecker *bool `validate:"omitempty"` UnitID *uuid.UUID `validate:"omitempty"` HasIngredients *bool `validate:"omitempty"` Metadata map[string]interface{} @@ -108,6 +110,7 @@ type ProductResponse struct { BusinessType constants.BusinessType ImageURL *string PrinterType string + PrintToChecker bool UnitID *uuid.UUID HasIngredients bool Metadata map[string]interface{} @@ -118,9 +121,10 @@ type ProductResponse struct { } type OutletPrice struct { - OutletID uuid.UUID - OutletName string - Price float64 + OutletID uuid.UUID + OutletName string + Price float64 + PrintToChecker bool } type ProductVariantResponse struct { diff --git a/internal/models/product_outlet_price.go b/internal/models/product_outlet_price.go index 2a192da..0f6f44c 100644 --- a/internal/models/product_outlet_price.go +++ b/internal/models/product_outlet_price.go @@ -7,22 +7,25 @@ import ( ) type ProductOutletPrice struct { - ID uuid.UUID - ProductID uuid.UUID - OutletID uuid.UUID - Price float64 - CreatedAt time.Time - UpdatedAt time.Time + 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"` + 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"` + Price *float64 `validate:"required,min=0"` + PrintToChecker *bool } type ProductOutletPriceResponse struct { diff --git a/internal/processor/product_outlet_price_processor.go b/internal/processor/product_outlet_price_processor.go index 97e00e0..6c5ba44 100644 --- a/internal/processor/product_outlet_price_processor.go +++ b/internal/processor/product_outlet_price_processor.go @@ -46,9 +46,10 @@ func (p *ProductOutletPriceProcessorImpl) Upsert(ctx context.Context, req *model } entity := &entities.ProductOutletPrice{ - ProductID: req.ProductID, - OutletID: req.OutletID, - Price: req.Price, + ProductID: req.ProductID, + OutletID: req.OutletID, + Price: req.Price, + PrintToChecker: req.PrintToChecker, } if err := p.repo.Upsert(ctx, entity); err != nil { diff --git a/internal/processor/product_processor.go b/internal/processor/product_processor.go index 6b1593c..f778833 100644 --- a/internal/processor/product_processor.go +++ b/internal/processor/product_processor.go @@ -5,6 +5,7 @@ import ( "fmt" "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/logger" "apskel-pos-be/internal/mappers" "apskel-pos-be/internal/models" "apskel-pos-be/internal/repository" @@ -125,10 +126,15 @@ 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, + 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) @@ -196,16 +202,39 @@ func (p *ProductProcessorImpl) UpdateProduct(ctx context.Context, id uuid.UUID, } } - // Upsert outlet-specific price if outlet context is present - if req.OutletID != uuid.Nil && req.Price != nil { - outletPriceEntity := &entities.ProductOutletPrice{ - ProductID: id, - OutletID: req.OutletID, - Price: *req.Price, + // 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) @@ -256,6 +285,7 @@ func (p *ProductProcessorImpl) GetProductByID(ctx context.Context, id uuid.UUID, 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 @@ -264,9 +294,10 @@ func (p *ProductProcessorImpl) GetProductByID(ctx context.Context, id uuid.UUID, 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, + OutletID: op.OutletID, + OutletName: op.Outlet.Name, + Price: op.Price, + PrintToChecker: op.PrintToChecker, } } response.OutletPrices = prices @@ -303,10 +334,35 @@ func (p *ProductProcessorImpl) ListProducts(ctx context.Context, filters map[str } responses := make([]models.ProductResponse, len(productEntities)) - for i, entity := range productEntities { - response := mappers.ProductEntityToResponse(entity) - if response != nil { - responses[i] = *response + 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 + } } } diff --git a/internal/repository/product_outlet_price_repository.go b/internal/repository/product_outlet_price_repository.go index 6a0f4aa..5ad693c 100644 --- a/internal/repository/product_outlet_price_repository.go +++ b/internal/repository/product_outlet_price_repository.go @@ -7,7 +7,6 @@ import ( "github.com/google/uuid" "gorm.io/gorm" - "gorm.io/gorm/clause" ) type ProductOutletPriceRepository interface { @@ -53,10 +52,18 @@ func (r *ProductOutletPriceRepositoryImpl) GetByOutlet(ctx context.Context, outl } func (r *ProductOutletPriceRepositoryImpl) Upsert(ctx context.Context, price *entities.ProductOutletPrice) error { - return r.db.WithContext(ctx).Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "product_id"}, {Name: "outlet_id"}}, - DoUpdates: clause.AssignmentColumns([]string{"price", "updated_at"}), - }).Create(price).Error + if price.ID == uuid.Nil { + price.ID = uuid.New() + } + return r.db.WithContext(ctx).Exec(` + INSERT INTO product_outlet_prices (id, product_id, outlet_id, price, print_to_checker, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, NOW(), NOW()) + ON CONFLICT (product_id, outlet_id) + DO UPDATE SET + price = EXCLUDED.price, + print_to_checker = EXCLUDED.print_to_checker, + updated_at = NOW() + `, price.ID, price.ProductID, price.OutletID, price.Price, price.PrintToChecker).Error } func (r *ProductOutletPriceRepositoryImpl) Delete(ctx context.Context, id uuid.UUID) error { diff --git a/internal/service/product_outlet_price_service.go b/internal/service/product_outlet_price_service.go index dc35f68..5f14deb 100644 --- a/internal/service/product_outlet_price_service.go +++ b/internal/service/product_outlet_price_service.go @@ -105,9 +105,10 @@ func (s *ProductOutletPriceServiceImpl) BulkUpsert(ctx context.Context, req *con prices := make([]models.CreateProductOutletPriceRequest, len(req.Prices)) for i, p := range req.Prices { prices[i] = models.CreateProductOutletPriceRequest{ - ProductID: req.ProductID, - OutletID: p.OutletID, - Price: p.Price, + ProductID: req.ProductID, + OutletID: p.OutletID, + Price: p.Price, + PrintToChecker: p.PrintToChecker, } } diff --git a/internal/transformer/product_outlet_price_transformer.go b/internal/transformer/product_outlet_price_transformer.go index 593f93e..fd571a7 100644 --- a/internal/transformer/product_outlet_price_transformer.go +++ b/internal/transformer/product_outlet_price_transformer.go @@ -11,9 +11,10 @@ func CreateProductOutletPriceRequestToModel(req *contract.CreateProductOutletPri } return &models.CreateProductOutletPriceRequest{ - ProductID: req.ProductID, - OutletID: req.OutletID, - Price: req.Price, + ProductID: req.ProductID, + OutletID: req.OutletID, + Price: req.Price, + PrintToChecker: req.PrintToChecker, } } @@ -23,7 +24,8 @@ func UpdateProductOutletPriceRequestToModel(req *contract.UpdateProductOutletPri } return &models.UpdateProductOutletPriceRequest{ - Price: &req.Price, + Price: &req.Price, + PrintToChecker: req.PrintToChecker, } } @@ -33,12 +35,13 @@ func ProductOutletPriceModelToResponse(m *models.ProductOutletPrice) *contract.P } return &contract.ProductOutletPriceResponse{ - ID: m.ID, - ProductID: m.ProductID, - OutletID: m.OutletID, - Price: m.Price, - CreatedAt: m.CreatedAt, - UpdatedAt: m.UpdatedAt, + ID: m.ID, + ProductID: m.ProductID, + OutletID: m.OutletID, + Price: m.Price, + PrintToChecker: m.PrintToChecker, + CreatedAt: m.CreatedAt, + UpdatedAt: m.UpdatedAt, } } diff --git a/internal/transformer/product_transformer.go b/internal/transformer/product_transformer.go index 47c309f..e6f0321 100644 --- a/internal/transformer/product_transformer.go +++ b/internal/transformer/product_transformer.go @@ -57,6 +57,7 @@ func CreateProductRequestToModel(apctx *appcontext.ContextInfo, req *contract.Cr BusinessType: businessType, ImageURL: req.ImageURL, PrinterType: req.PrinterType, + PrintToChecker: req.PrintToChecker, Metadata: metadata, Variants: variants, } @@ -75,17 +76,18 @@ func UpdateProductRequestToModel(apctx *appcontext.ContextInfo, req *contract.Up } return &models.UpdateProductRequest{ - OutletID: outletID, - CategoryID: req.CategoryID, - SKU: req.SKU, - Name: req.Name, - Description: req.Description, - Price: req.Price, - Cost: req.Cost, - ImageURL: req.ImageURL, - PrinterType: req.PrinterType, - Metadata: metadata, - IsActive: req.IsActive, + OutletID: outletID, + CategoryID: req.CategoryID, + SKU: req.SKU, + Name: req.Name, + Description: req.Description, + Price: req.Price, + Cost: req.Cost, + ImageURL: req.ImageURL, + PrinterType: req.PrinterType, + PrintToChecker: req.PrintToChecker, + Metadata: metadata, + IsActive: req.IsActive, } } @@ -119,9 +121,10 @@ func ProductModelResponseToResponse(prod *models.ProductResponse) *contract.Prod outletPriceResponses = make([]contract.ProductOutletPriceResponse, len(prod.OutletPrices)) for i, op := range prod.OutletPrices { outletPriceResponses[i] = contract.ProductOutletPriceResponse{ - OutletID: op.OutletID, - OutletName: op.OutletName, - Price: op.Price, + OutletID: op.OutletID, + OutletName: op.OutletName, + Price: op.Price, + PrintToChecker: op.PrintToChecker, } } } @@ -141,6 +144,7 @@ func ProductModelResponseToResponse(prod *models.ProductResponse) *contract.Prod BusinessType: string(prod.BusinessType), ImageURL: prod.ImageURL, PrinterType: prod.PrinterType, + PrintToChecker: prod.PrintToChecker, Metadata: prod.Metadata, IsActive: prod.IsActive, CreatedAt: prod.CreatedAt, diff --git a/migrations/000070_add_print_to_checker_to_product_outlet_prices.down.sql b/migrations/000070_add_print_to_checker_to_product_outlet_prices.down.sql new file mode 100644 index 0000000..a35ed5d --- /dev/null +++ b/migrations/000070_add_print_to_checker_to_product_outlet_prices.down.sql @@ -0,0 +1 @@ +ALTER TABLE product_outlet_prices DROP COLUMN IF EXISTS print_to_checker; diff --git a/migrations/000070_add_print_to_checker_to_product_outlet_prices.up.sql b/migrations/000070_add_print_to_checker_to_product_outlet_prices.up.sql new file mode 100644 index 0000000..930a67d --- /dev/null +++ b/migrations/000070_add_print_to_checker_to_product_outlet_prices.up.sql @@ -0,0 +1 @@ +ALTER TABLE product_outlet_prices ADD COLUMN print_to_checker BOOLEAN NOT NULL DEFAULT TRUE; From 84222fc7f463c28ed664c58dc1d4f5b1746b3132 Mon Sep 17 00:00:00 2001 From: Efril Date: Thu, 28 May 2026 15:30:18 +0700 Subject: [PATCH 07/11] update --- internal/contract/order_contract.go | 1 + internal/entities/product.go | 15 ++++++++------- internal/mappers/order_mapper.go | 18 ++++++++++++++---- internal/mappers/order_mapper_test.go | 6 +++--- internal/models/order.go | 1 + internal/processor/order_processor.go | 2 +- internal/repository/order_repository.go | 3 +++ internal/transformer/order_transformer.go | 2 ++ 8 files changed, 33 insertions(+), 15 deletions(-) diff --git a/internal/contract/order_contract.go b/internal/contract/order_contract.go index 0895ff8..237874f 100644 --- a/internal/contract/order_contract.go +++ b/internal/contract/order_contract.go @@ -110,6 +110,7 @@ type OrderItemResponse struct { CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` PrinterType string `json:"printer_type"` + PrintToChecker bool `json:"print_to_checker"` PaidQuantity int `json:"paid_quantity"` } diff --git a/internal/entities/product.go b/internal/entities/product.go index 3aba21e..dc8c524 100644 --- a/internal/entities/product.go +++ b/internal/entities/product.go @@ -26,13 +26,14 @@ type Product struct { CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` - Organization Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"` - Category Category `gorm:"foreignKey:CategoryID" json:"category,omitempty"` - Unit *Unit `gorm:"foreignKey:UnitID" json:"unit,omitempty"` - ProductVariants []ProductVariant `gorm:"foreignKey:ProductID" json:"variants,omitempty"` - ProductRecipes []ProductRecipe `gorm:"foreignKey:ProductID" json:"product_recipes,omitempty"` - Inventory []Inventory `gorm:"foreignKey:ProductID" json:"inventory,omitempty"` - OrderItems []OrderItem `gorm:"foreignKey:ProductID" json:"order_items,omitempty"` + Organization Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"` + Category Category `gorm:"foreignKey:CategoryID" json:"category,omitempty"` + Unit *Unit `gorm:"foreignKey:UnitID" json:"unit,omitempty"` + ProductVariants []ProductVariant `gorm:"foreignKey:ProductID" json:"variants,omitempty"` + ProductRecipes []ProductRecipe `gorm:"foreignKey:ProductID" json:"product_recipes,omitempty"` + Inventory []Inventory `gorm:"foreignKey:ProductID" json:"inventory,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 { diff --git a/internal/mappers/order_mapper.go b/internal/mappers/order_mapper.go index 4e336d5..b4c84da 100644 --- a/internal/mappers/order_mapper.go +++ b/internal/mappers/order_mapper.go @@ -82,7 +82,7 @@ func OrderEntityToResponse(order *entities.Order) *models.OrderResponse { } for i, item := range order.OrderItems { - resp := OrderItemEntityToResponse(&item) + resp := OrderItemEntityToResponse(&item, order.OutletID) if resp != nil { resp.PaidQuantity = paidQtyByOrderItem[item.ID] response.OrderItems[i] = *resp @@ -101,11 +101,20 @@ func OrderEntityToResponse(order *entities.Order) *models.OrderResponse { return response } -func OrderItemEntityToResponse(item *entities.OrderItem) *models.OrderItemResponse { +func OrderItemEntityToResponse(item *entities.OrderItem, outletID uuid.UUID) *models.OrderItemResponse { if item == 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{ ID: item.ID, OrderID: item.OrderID, @@ -130,6 +139,7 @@ func OrderItemEntityToResponse(item *entities.OrderItem) *models.OrderItemRespon CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt, PrinterType: item.Product.PrinterType, + PrintToChecker: printToChecker, } if item.Product.ID != uuid.Nil { @@ -324,14 +334,14 @@ func OrderEntitiesToResponses(orders []*entities.Order) []models.OrderResponse { return responses } -func OrderItemEntitiesToResponses(items []*entities.OrderItem) []models.OrderItemResponse { +func OrderItemEntitiesToResponses(items []*entities.OrderItem, outletID uuid.UUID) []models.OrderItemResponse { if items == nil { return nil } responses := make([]models.OrderItemResponse, len(items)) for i, item := range items { - response := OrderItemEntityToResponse(item) + response := OrderItemEntityToResponse(item, outletID) if response != nil { responses[i] = *response } diff --git a/internal/mappers/order_mapper_test.go b/internal/mappers/order_mapper_test.go index 58131ba..1ed76dd 100644 --- a/internal/mappers/order_mapper_test.go +++ b/internal/mappers/order_mapper_test.go @@ -45,7 +45,7 @@ func TestOrderItemEntityToResponse_WithProductNames(t *testing.T) { } // Act - result := OrderItemEntityToResponse(orderItem) + result := OrderItemEntityToResponse(orderItem, uuid.Nil) // Assert assert.NotNil(t, result) @@ -89,7 +89,7 @@ func TestOrderItemEntityToResponse_WithoutProductVariant(t *testing.T) { } // Act - result := OrderItemEntityToResponse(orderItem) + result := OrderItemEntityToResponse(orderItem, uuid.Nil) // Assert assert.NotNil(t, result) @@ -129,7 +129,7 @@ func TestOrderItemEntityToResponse_WithoutProductPreload(t *testing.T) { } // Act - result := OrderItemEntityToResponse(orderItem) + result := OrderItemEntityToResponse(orderItem, uuid.Nil) // Assert assert.NotNil(t, result) diff --git a/internal/models/order.go b/internal/models/order.go index 3a456af..313c496 100644 --- a/internal/models/order.go +++ b/internal/models/order.go @@ -209,6 +209,7 @@ type OrderItemResponse struct { CreatedAt time.Time UpdatedAt time.Time PrinterType string + PrintToChecker bool PaidQuantity int } diff --git a/internal/processor/order_processor.go b/internal/processor/order_processor.go index d146785..02e3d48 100644 --- a/internal/processor/order_processor.go +++ b/internal/processor/order_processor.go @@ -387,7 +387,7 @@ func (p *OrderProcessorImpl) AddToOrder(ctx context.Context, orderID uuid.UUID, return nil, fmt.Errorf("failed to create order item: %w", err) } - itemResponse := mappers.OrderItemEntityToResponse(orderItem) + itemResponse := mappers.OrderItemEntityToResponse(orderItem, order.OutletID) if itemResponse != nil { addedItemResponses = append(addedItemResponses, *itemResponse) } diff --git a/internal/repository/order_repository.go b/internal/repository/order_repository.go index 2a60aa9..9d13a48 100644 --- a/internal/repository/order_repository.go +++ b/internal/repository/order_repository.go @@ -61,6 +61,7 @@ func (r *OrderRepositoryImpl) GetWithRelations(ctx context.Context, id uuid.UUID Preload("OrderItems"). Preload("OrderItems.Product"). Preload("OrderItems.Product.Category"). + Preload("OrderItems.Product.ProductOutletPrices"). Preload("OrderItems.ProductVariant"). Preload("Payments"). Preload("Payments.PaymentMethod"). @@ -141,6 +142,7 @@ func (r *OrderRepositoryImpl) List(ctx context.Context, filters map[string]inter Preload("OrderItems"). Preload("OrderItems.Product"). Preload("OrderItems.Product.Category"). + Preload("OrderItems.Product.ProductOutletPrices"). Preload("OrderItems.ProductVariant"). Preload("Payments"). Preload("Payments.PaymentMethod"). @@ -158,6 +160,7 @@ func (r *OrderRepositoryImpl) ListBySessionID(ctx context.Context, sessionID str Preload("OrderItems"). Preload("OrderItems.Product"). Preload("OrderItems.Product.Category"). + Preload("OrderItems.Product.ProductOutletPrices"). Preload("OrderItems.ProductVariant"). Preload("Payments"). Preload("Payments.PaymentMethod"). diff --git a/internal/transformer/order_transformer.go b/internal/transformer/order_transformer.go index 7edbe3b..88f88d6 100644 --- a/internal/transformer/order_transformer.go +++ b/internal/transformer/order_transformer.go @@ -112,6 +112,7 @@ func OrderModelToContract(resp *models.OrderResponse) *contract.OrderResponse { CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt, PrinterType: item.PrinterType, + PrintToChecker: item.PrintToChecker, PaidQuantity: item.PaidQuantity, } } @@ -181,6 +182,7 @@ func AddToOrderModelToContract(resp *models.AddToOrderResponse) *contract.AddToO Status: string(item.Status), CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt, + PrintToChecker: item.PrintToChecker, } } return &contract.AddToOrderResponse{ From 1b7bec4f813cc89c1896d2987daa4d19a5254d9e Mon Sep 17 00:00:00 2001 From: ryan Date: Fri, 29 May 2026 13:25:38 +0700 Subject: [PATCH 08/11] update expense item name --- internal/contract/expense_contract.go | 6 ++--- internal/entities/analytics.go | 4 ++-- internal/entities/expense.go | 1 - internal/entities/expense_item.go | 1 + internal/mappers/expense_mapper.go | 4 +--- internal/models/expense.go | 8 +++---- internal/processor/analytics_processor.go | 2 +- internal/processor/expense_processor.go | 10 ++++---- internal/repository/analytics_repository.go | 4 ++-- internal/repository/expense_repository.go | 13 +++++++++-- internal/transformer/expense_transformer.go | 6 ++--- internal/validator/expense_validator.go | 14 +++++------ ...0072_add_expense_name_to_expenses.down.sql | 18 +++++++++++++-- ...000072_add_expense_name_to_expenses.up.sql | 23 +++++++++++++++++-- 14 files changed, 77 insertions(+), 37 deletions(-) diff --git a/internal/contract/expense_contract.go b/internal/contract/expense_contract.go index c451e69..dab91c7 100644 --- a/internal/contract/expense_contract.go +++ b/internal/contract/expense_contract.go @@ -7,7 +7,6 @@ import ( ) type CreateExpenseRequest struct { - ExpenseName string `json:"expense_name" validate:"required"` Receiver string `json:"receiver" validate:"required"` TransactionDate string `json:"transaction_date" validate:"required"` CodeNumber string `json:"code_number" validate:"required"` @@ -20,12 +19,12 @@ type CreateExpenseRequest struct { type CreateExpenseItemRequest struct { ChartOfAccountID string `json:"chart_of_account_id" validate:"required"` + Item string `json:"item" validate:"required"` Description *string `json:"description,omitempty"` Amount float64 `json:"amount" validate:"required"` } type UpdateExpenseRequest struct { - ExpenseName *string `json:"expense_name,omitempty"` Receiver *string `json:"receiver,omitempty"` TransactionDate *string `json:"transaction_date,omitempty"` CodeNumber *string `json:"code_number,omitempty"` @@ -39,6 +38,7 @@ type UpdateExpenseRequest struct { type UpdateExpenseItemRequest struct { ChartOfAccountID *string `json:"chart_of_account_id,omitempty"` + Item *string `json:"item,omitempty"` Description *string `json:"description,omitempty"` Amount *float64 `json:"amount,omitempty"` } @@ -47,7 +47,6 @@ type ExpenseResponse struct { ID uuid.UUID `json:"id"` OrganizationID uuid.UUID `json:"organization_id"` OutletID uuid.UUID `json:"outlet_id"` - ExpenseName string `json:"expense_name"` Receiver string `json:"receiver"` TransactionDate time.Time `json:"transaction_date"` CodeNumber string `json:"code_number"` @@ -65,6 +64,7 @@ type ExpenseItemResponse struct { ExpenseID uuid.UUID `json:"expense_id"` ChartOfAccountID uuid.UUID `json:"chart_of_account_id"` ChartOfAccountName string `json:"chart_of_account_name,omitempty"` + Item string `json:"item"` Description *string `json:"description"` Amount float64 `json:"amount"` CreatedAt time.Time `json:"created_at"` diff --git a/internal/entities/analytics.go b/internal/entities/analytics.go index f06aff9..5f0a894 100644 --- a/internal/entities/analytics.go +++ b/internal/entities/analytics.go @@ -129,6 +129,6 @@ type ExpenseCategoryTotal struct { } type OperationalExpenseItem struct { - Description string - Amount float64 + Item string + Amount float64 } diff --git a/internal/entities/expense.go b/internal/entities/expense.go index 622b955..a9ba53e 100644 --- a/internal/entities/expense.go +++ b/internal/entities/expense.go @@ -12,7 +12,6 @@ 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"` - ExpenseName string `gorm:"not null;size:255" json:"expense_name"` 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"` diff --git a/internal/entities/expense_item.go b/internal/entities/expense_item.go index 72b3c24..f5bd7fc 100644 --- a/internal/entities/expense_item.go +++ b/internal/entities/expense_item.go @@ -12,6 +12,7 @@ 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"` + 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"` diff --git a/internal/mappers/expense_mapper.go b/internal/mappers/expense_mapper.go index 17324a6..3bb111b 100644 --- a/internal/mappers/expense_mapper.go +++ b/internal/mappers/expense_mapper.go @@ -14,7 +14,6 @@ func ExpenseEntityToModel(entity *entities.Expense) *models.Expense { ID: entity.ID, OrganizationID: entity.OrganizationID, OutletID: entity.OutletID, - ExpenseName: entity.ExpenseName, Receiver: entity.Receiver, TransactionDate: entity.TransactionDate, CodeNumber: entity.CodeNumber, @@ -36,7 +35,6 @@ func ExpenseModelToEntity(model *models.Expense) *entities.Expense { ID: model.ID, OrganizationID: model.OrganizationID, OutletID: model.OutletID, - ExpenseName: model.ExpenseName, Receiver: model.Receiver, TransactionDate: model.TransactionDate, CodeNumber: model.CodeNumber, @@ -58,7 +56,6 @@ func ExpenseEntityToResponse(entity *entities.Expense) *models.ExpenseResponse { ID: entity.ID, OrganizationID: entity.OrganizationID, OutletID: entity.OutletID, - ExpenseName: entity.ExpenseName, Receiver: entity.Receiver, TransactionDate: entity.TransactionDate, CodeNumber: entity.CodeNumber, @@ -98,6 +95,7 @@ func ExpenseItemEntityToResponse(entity *entities.ExpenseItem) *models.ExpenseIt ID: entity.ID, ExpenseID: entity.ExpenseID, ChartOfAccountID: entity.ChartOfAccountID, + Item: entity.Item, Description: entity.Description, Amount: entity.Amount, CreatedAt: entity.CreatedAt, diff --git a/internal/models/expense.go b/internal/models/expense.go index 68e8645..8ee1011 100644 --- a/internal/models/expense.go +++ b/internal/models/expense.go @@ -10,7 +10,6 @@ type Expense struct { ID uuid.UUID `json:"id"` OrganizationID uuid.UUID `json:"organization_id"` OutletID uuid.UUID `json:"outlet_id"` - ExpenseName string `json:"expense_name"` Receiver string `json:"receiver"` TransactionDate time.Time `json:"transaction_date"` CodeNumber string `json:"code_number"` @@ -26,6 +25,7 @@ type ExpenseItem struct { ID uuid.UUID `json:"id"` ExpenseID uuid.UUID `json:"expense_id"` ChartOfAccountID uuid.UUID `json:"chart_of_account_id"` + Item string `json:"item"` Description *string `json:"description"` Amount float64 `json:"amount"` CreatedAt time.Time `json:"created_at"` @@ -36,7 +36,6 @@ type ExpenseResponse struct { ID uuid.UUID `json:"id"` OrganizationID uuid.UUID `json:"organization_id"` OutletID uuid.UUID `json:"outlet_id"` - ExpenseName string `json:"expense_name"` Receiver string `json:"receiver"` TransactionDate time.Time `json:"transaction_date"` CodeNumber string `json:"code_number"` @@ -54,6 +53,7 @@ type ExpenseItemResponse struct { ExpenseID uuid.UUID `json:"expense_id"` ChartOfAccountID uuid.UUID `json:"chart_of_account_id"` ChartOfAccountName string `json:"chart_of_account_name,omitempty"` + Item string `json:"item"` Description *string `json:"description"` Amount float64 `json:"amount"` CreatedAt time.Time `json:"created_at"` @@ -61,7 +61,6 @@ type ExpenseItemResponse struct { } type CreateExpenseRequest struct { - ExpenseName string `json:"expense_name"` Receiver string `json:"receiver"` TransactionDate string `json:"transaction_date"` CodeNumber string `json:"code_number"` @@ -74,12 +73,12 @@ type CreateExpenseRequest struct { type CreateExpenseItemRequest struct { ChartOfAccountID string `json:"chart_of_account_id"` + Item string `json:"item"` Description *string `json:"description,omitempty"` Amount float64 `json:"amount"` } type UpdateExpenseRequest struct { - ExpenseName *string `json:"expense_name,omitempty"` Receiver *string `json:"receiver,omitempty"` TransactionDate *string `json:"transaction_date,omitempty"` CodeNumber *string `json:"code_number,omitempty"` @@ -93,6 +92,7 @@ type UpdateExpenseRequest struct { type UpdateExpenseItemRequest struct { ChartOfAccountID *string `json:"chart_of_account_id,omitempty"` + Item *string `json:"item,omitempty"` Description *string `json:"description,omitempty"` Amount *float64 `json:"amount,omitempty"` } diff --git a/internal/processor/analytics_processor.go b/internal/processor/analytics_processor.go index c9ae596..41a5b33 100644 --- a/internal/processor/analytics_processor.go +++ b/internal/processor/analytics_processor.go @@ -498,7 +498,7 @@ func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req var opsTotal float64 for i, item := range result.OperationalExpenseItems { opsItems[i] = models.OperationalExpenseItem{ - Item: item.Description, + Item: item.Item, Nominal: item.Amount, } opsTotal += item.Amount diff --git a/internal/processor/expense_processor.go b/internal/processor/expense_processor.go index 76b30a8..29eaf55 100644 --- a/internal/processor/expense_processor.go +++ b/internal/processor/expense_processor.go @@ -44,7 +44,6 @@ func (p *ExpenseProcessorImpl) CreateExpense(ctx context.Context, organizationID expenseEntity := &entities.Expense{ OrganizationID: organizationID, OutletID: outletID, - ExpenseName: req.ExpenseName, Receiver: req.Receiver, TransactionDate: transactionDate, CodeNumber: req.CodeNumber, @@ -67,6 +66,7 @@ func (p *ExpenseProcessorImpl) CreateExpense(ctx context.Context, organizationID itemEntity := &entities.ExpenseItem{ ExpenseID: expenseEntity.ID, ChartOfAccountID: chartOfAccountID, + Item: itemReq.Item, Description: itemReq.Description, Amount: itemReq.Amount, } @@ -91,9 +91,6 @@ func (p *ExpenseProcessorImpl) UpdateExpense(ctx context.Context, id, organizati return nil, fmt.Errorf("expense not found: %w", err) } - if req.ExpenseName != nil { - expenseEntity.ExpenseName = *req.ExpenseName - } if req.Receiver != nil { expenseEntity.Receiver = *req.Receiver } @@ -146,10 +143,15 @@ func (p *ExpenseProcessorImpl) UpdateExpense(ctx context.Context, id, organizati if itemReq.Amount != nil { amount = *itemReq.Amount } + item := "" + if itemReq.Item != nil { + item = *itemReq.Item + } itemEntity := &entities.ExpenseItem{ ExpenseID: expenseEntity.ID, ChartOfAccountID: chartOfAccountID, + Item: item, Description: itemReq.Description, Amount: amount, } diff --git a/internal/repository/analytics_repository.go b/internal/repository/analytics_repository.go index b28863d..09d8631 100644 --- a/internal/repository/analytics_repository.go +++ b/internal/repository/analytics_repository.go @@ -531,7 +531,7 @@ func (r *AnalyticsRepositoryImpl) getOperationalExpenseItems(ctx context.Context query := r.db.WithContext(ctx). Table("expense_items ei"). - Select(`COALESCE(ei.description, coa.name) as description, COALESCE(SUM(ei.amount), 0) as amount`). + Select(`COALESCE(NULLIF(ei.item, ''), ei.description, coa.name) as item, COALESCE(SUM(ei.amount), 0) as amount`). 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). @@ -542,7 +542,7 @@ func (r *AnalyticsRepositoryImpl) getOperationalExpenseItems(ctx context.Context } err := query. - Group("COALESCE(ei.description, coa.name)"). + Group("COALESCE(NULLIF(ei.item, ''), ei.description, coa.name)"). Order("amount DESC"). Scan(&results).Error diff --git a/internal/repository/expense_repository.go b/internal/repository/expense_repository.go index 7711915..a66a3df 100644 --- a/internal/repository/expense_repository.go +++ b/internal/repository/expense_repository.go @@ -68,8 +68,17 @@ func (r *ExpenseRepositoryImpl) List(ctx context.Context, organizationID uuid.UU case "search": if searchStr, ok := value.(string); ok && searchStr != "" { searchPattern := "%" + strings.ToLower(searchStr) + "%" - query = query.Where("LOWER(expense_name) LIKE ? OR LOWER(receiver) LIKE ? OR LOWER(code_number) LIKE ? OR LOWER(description) LIKE ?", - searchPattern, searchPattern, searchPattern, searchPattern) + 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 { diff --git a/internal/transformer/expense_transformer.go b/internal/transformer/expense_transformer.go index bbf0ce7..d86714b 100644 --- a/internal/transformer/expense_transformer.go +++ b/internal/transformer/expense_transformer.go @@ -12,7 +12,6 @@ func CreateExpenseRequestToModel(req *contract.CreateExpenseRequest) *models.Cre } return &models.CreateExpenseRequest{ - ExpenseName: req.ExpenseName, Receiver: req.Receiver, TransactionDate: req.TransactionDate, CodeNumber: req.CodeNumber, @@ -27,6 +26,7 @@ func CreateExpenseRequestToModel(req *contract.CreateExpenseRequest) *models.Cre func CreateExpenseItemRequestToModel(req *contract.CreateExpenseItemRequest) models.CreateExpenseItemRequest { return models.CreateExpenseItemRequest{ ChartOfAccountID: req.ChartOfAccountID, + Item: req.Item, Description: req.Description, Amount: req.Amount, } @@ -34,7 +34,6 @@ func CreateExpenseItemRequestToModel(req *contract.CreateExpenseItemRequest) mod func UpdateExpenseRequestToModel(req *contract.UpdateExpenseRequest) *models.UpdateExpenseRequest { modelReq := &models.UpdateExpenseRequest{ - ExpenseName: req.ExpenseName, Receiver: req.Receiver, TransactionDate: req.TransactionDate, CodeNumber: req.CodeNumber, @@ -59,6 +58,7 @@ func UpdateExpenseRequestToModel(req *contract.UpdateExpenseRequest) *models.Upd func UpdateExpenseItemRequestToModel(req *contract.UpdateExpenseItemRequest) models.UpdateExpenseItemRequest { return models.UpdateExpenseItemRequest{ ChartOfAccountID: req.ChartOfAccountID, + Item: req.Item, Description: req.Description, Amount: req.Amount, } @@ -89,7 +89,6 @@ func ExpenseModelResponseToResponse(expense *models.ExpenseResponse) *contract.E ID: expense.ID, OrganizationID: expense.OrganizationID, OutletID: expense.OutletID, - ExpenseName: expense.ExpenseName, Receiver: expense.Receiver, TransactionDate: expense.TransactionDate, CodeNumber: expense.CodeNumber, @@ -109,6 +108,7 @@ func ExpenseItemModelResponseToResponse(item *models.ExpenseItemResponse) contra ExpenseID: item.ExpenseID, ChartOfAccountID: item.ChartOfAccountID, ChartOfAccountName: item.ChartOfAccountName, + Item: item.Item, Description: item.Description, Amount: item.Amount, CreatedAt: item.CreatedAt, diff --git a/internal/validator/expense_validator.go b/internal/validator/expense_validator.go index b0cb81d..7101f45 100644 --- a/internal/validator/expense_validator.go +++ b/internal/validator/expense_validator.go @@ -28,10 +28,6 @@ func (v *ExpenseValidatorImpl) ValidateCreateExpenseRequest(req *contract.Create return errors.New("request body is required"), constants.MissingFieldErrorCode } - if strings.TrimSpace(req.ExpenseName) == "" { - return errors.New("expense_name is required"), constants.MissingFieldErrorCode - } - if strings.TrimSpace(req.Receiver) == "" { return errors.New("receiver is required"), constants.MissingFieldErrorCode } @@ -68,6 +64,9 @@ func (v *ExpenseValidatorImpl) ValidateCreateExpenseRequest(req *contract.Create if strings.TrimSpace(item.ChartOfAccountID) == "" { return fmt.Errorf("item %d: chart_of_account_id is required", i), constants.MissingFieldErrorCode } + if strings.TrimSpace(item.Item) == "" { + return fmt.Errorf("item %d: item is required", i), constants.MissingFieldErrorCode + } if _, err := uuid.Parse(item.ChartOfAccountID); err != nil { return fmt.Errorf("item %d: chart_of_account_id must be a valid UUID", i), constants.MalformedFieldErrorCode } @@ -84,10 +83,6 @@ func (v *ExpenseValidatorImpl) ValidateUpdateExpenseRequest(req *contract.Update return errors.New("request body is required"), constants.MissingFieldErrorCode } - if req.ExpenseName != nil && strings.TrimSpace(*req.ExpenseName) == "" { - return errors.New("expense_name cannot be empty"), constants.MalformedFieldErrorCode - } - if req.Receiver != nil && strings.TrimSpace(*req.Receiver) == "" { return errors.New("receiver cannot be empty"), constants.MalformedFieldErrorCode } @@ -123,6 +118,9 @@ func (v *ExpenseValidatorImpl) ValidateUpdateExpenseRequest(req *contract.Update return fmt.Errorf("item %d: chart_of_account_id must be a valid UUID", i), constants.MalformedFieldErrorCode } } + if item.Item != nil && strings.TrimSpace(*item.Item) == "" { + return fmt.Errorf("item %d: item cannot be empty", i), constants.MalformedFieldErrorCode + } if item.Amount != nil && *item.Amount <= 0 { return fmt.Errorf("item %d: amount must be greater than 0", i), constants.MalformedFieldErrorCode } diff --git a/migrations/000072_add_expense_name_to_expenses.down.sql b/migrations/000072_add_expense_name_to_expenses.down.sql index 553fdc9..368f8f9 100644 --- a/migrations/000072_add_expense_name_to_expenses.down.sql +++ b/migrations/000072_add_expense_name_to_expenses.down.sql @@ -1,2 +1,16 @@ -DROP INDEX IF EXISTS idx_expenses_expense_name; -ALTER TABLE expenses DROP COLUMN expense_name; +ALTER TABLE expenses ADD COLUMN IF NOT EXISTS expense_name VARCHAR(255) NOT NULL DEFAULT ''; + +UPDATE expenses e +SET expense_name = first_item.item +FROM ( + SELECT DISTINCT ON (expense_id) expense_id, item + FROM expense_items + WHERE COALESCE(item, '') != '' + ORDER BY expense_id, created_at ASC +) first_item +WHERE e.id = first_item.expense_id + AND COALESCE(e.expense_name, '') = ''; + +CREATE INDEX IF NOT EXISTS idx_expenses_expense_name ON expenses(expense_name); +DROP INDEX IF EXISTS idx_expense_items_item; +ALTER TABLE expense_items DROP COLUMN IF EXISTS item; diff --git a/migrations/000072_add_expense_name_to_expenses.up.sql b/migrations/000072_add_expense_name_to_expenses.up.sql index 9cb91e7..2b87356 100644 --- a/migrations/000072_add_expense_name_to_expenses.up.sql +++ b/migrations/000072_add_expense_name_to_expenses.up.sql @@ -1,2 +1,21 @@ -ALTER TABLE expenses ADD COLUMN expense_name VARCHAR(255) NOT NULL DEFAULT ''; -CREATE INDEX idx_expenses_expense_name ON expenses(expense_name); +ALTER TABLE expense_items ADD COLUMN IF NOT EXISTS item VARCHAR(255) NOT NULL DEFAULT ''; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name = 'expenses' + AND column_name = 'expense_name' + ) THEN + UPDATE expense_items ei + SET item = e.expense_name + FROM expenses e + WHERE ei.expense_id = e.id + AND COALESCE(ei.item, '') = ''; + END IF; +END $$; + +DROP INDEX IF EXISTS idx_expenses_expense_name; +ALTER TABLE expenses DROP COLUMN IF EXISTS expense_name; +CREATE INDEX IF NOT EXISTS idx_expense_items_item ON expense_items(item); From d26f5c5354531edf6d083e71f49b4e9305d1fc8e Mon Sep 17 00:00:00 2001 From: ryan Date: Fri, 29 May 2026 15:44:59 +0700 Subject: [PATCH 09/11] add status to expense --- internal/contract/expense_contract.go | 4 ++++ internal/entities/expense.go | 1 + internal/handler/expense_handler.go | 4 ++++ internal/mappers/expense_mapper.go | 3 +++ internal/models/expense.go | 5 +++++ internal/processor/expense_processor.go | 10 ++++++++++ internal/repository/analytics_repository.go | 2 ++ internal/repository/expense_repository.go | 4 ++++ internal/service/expense_service.go | 3 +++ internal/transformer/expense_transformer.go | 4 ++++ internal/validator/expense_validator.go | 12 ++++++++++++ migrations/000073_add_status_to_expenses.down.sql | 3 +++ migrations/000073_add_status_to_expenses.up.sql | 10 ++++++++++ 13 files changed, 65 insertions(+) create mode 100644 migrations/000073_add_status_to_expenses.down.sql create mode 100644 migrations/000073_add_status_to_expenses.up.sql diff --git a/internal/contract/expense_contract.go b/internal/contract/expense_contract.go index dab91c7..a8ff07e 100644 --- a/internal/contract/expense_contract.go +++ b/internal/contract/expense_contract.go @@ -11,6 +11,7 @@ type CreateExpenseRequest struct { 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"` @@ -29,6 +30,7 @@ type UpdateExpenseRequest struct { 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"` @@ -50,6 +52,7 @@ type ExpenseResponse struct { 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"` @@ -76,6 +79,7 @@ type ListExpenseRequest struct { 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"` } diff --git a/internal/entities/expense.go b/internal/entities/expense.go index a9ba53e..93d9789 100644 --- a/internal/entities/expense.go +++ b/internal/entities/expense.go @@ -15,6 +15,7 @@ type Expense struct { 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"` diff --git a/internal/handler/expense_handler.go b/internal/handler/expense_handler.go index 1b3fc8e..c09cc79 100644 --- a/internal/handler/expense_handler.go +++ b/internal/handler/expense_handler.go @@ -164,6 +164,10 @@ func (h *ExpenseHandler) ListExpenses(c *gin.Context) { 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 { diff --git a/internal/mappers/expense_mapper.go b/internal/mappers/expense_mapper.go index 3bb111b..34015d4 100644 --- a/internal/mappers/expense_mapper.go +++ b/internal/mappers/expense_mapper.go @@ -17,6 +17,7 @@ func ExpenseEntityToModel(entity *entities.Expense) *models.Expense { Receiver: entity.Receiver, TransactionDate: entity.TransactionDate, CodeNumber: entity.CodeNumber, + Status: entity.Status, Description: entity.Description, Tax: entity.Tax, Total: entity.Total, @@ -38,6 +39,7 @@ func ExpenseModelToEntity(model *models.Expense) *entities.Expense { Receiver: model.Receiver, TransactionDate: model.TransactionDate, CodeNumber: model.CodeNumber, + Status: model.Status, Description: model.Description, Tax: model.Tax, Total: model.Total, @@ -59,6 +61,7 @@ func ExpenseEntityToResponse(entity *entities.Expense) *models.ExpenseResponse { Receiver: entity.Receiver, TransactionDate: entity.TransactionDate, CodeNumber: entity.CodeNumber, + Status: entity.Status, Description: entity.Description, Tax: entity.Tax, Total: entity.Total, diff --git a/internal/models/expense.go b/internal/models/expense.go index 8ee1011..52a34e5 100644 --- a/internal/models/expense.go +++ b/internal/models/expense.go @@ -13,6 +13,7 @@ type Expense struct { 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"` @@ -39,6 +40,7 @@ type ExpenseResponse struct { 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"` @@ -65,6 +67,7 @@ type CreateExpenseRequest struct { 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"` @@ -83,6 +86,7 @@ type UpdateExpenseRequest struct { 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"` @@ -102,6 +106,7 @@ type ListExpenseRequest struct { 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"` } diff --git a/internal/processor/expense_processor.go b/internal/processor/expense_processor.go index 29eaf55..4e0557b 100644 --- a/internal/processor/expense_processor.go +++ b/internal/processor/expense_processor.go @@ -5,6 +5,7 @@ import ( "fmt" "time" + "apskel-pos-be/internal/constants" "apskel-pos-be/internal/entities" "apskel-pos-be/internal/mappers" "apskel-pos-be/internal/models" @@ -41,12 +42,18 @@ func (p *ExpenseProcessorImpl) CreateExpense(ctx context.Context, organizationID 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 + } + 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, @@ -104,6 +111,9 @@ func (p *ExpenseProcessorImpl) UpdateExpense(ctx context.Context, id, organizati 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 { diff --git a/internal/repository/analytics_repository.go b/internal/repository/analytics_repository.go index 09d8631..821b060 100644 --- a/internal/repository/analytics_repository.go +++ b/internal/repository/analytics_repository.go @@ -512,6 +512,7 @@ func (r *AnalyticsRepositoryImpl) getExpenseByCategory(ctx context.Context, orga 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) if outletID != nil { @@ -535,6 +536,7 @@ func (r *AnalyticsRepositoryImpl) getOperationalExpenseItems(ctx context.Context 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) if outletID != nil { diff --git a/internal/repository/expense_repository.go b/internal/repository/expense_repository.go index a66a3df..335d0eb 100644 --- a/internal/repository/expense_repository.go +++ b/internal/repository/expense_repository.go @@ -84,6 +84,10 @@ func (r *ExpenseRepositoryImpl) List(ctx context.Context, organizationID uuid.UU 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) diff --git a/internal/service/expense_service.go b/internal/service/expense_service.go index 24af26c..bb8a417 100644 --- a/internal/service/expense_service.go +++ b/internal/service/expense_service.go @@ -87,6 +87,9 @@ func (s *ExpenseServiceImpl) ListExpenses(ctx context.Context, apctx *appcontext if modelReq.Search != "" { filters["search"] = modelReq.Search } + if modelReq.Status != "" { + filters["status"] = modelReq.Status + } if modelReq.OutletID != "" { outletID, err := uuid.Parse(modelReq.OutletID) if err == nil { diff --git a/internal/transformer/expense_transformer.go b/internal/transformer/expense_transformer.go index d86714b..5b3c47c 100644 --- a/internal/transformer/expense_transformer.go +++ b/internal/transformer/expense_transformer.go @@ -16,6 +16,7 @@ func CreateExpenseRequestToModel(req *contract.CreateExpenseRequest) *models.Cre TransactionDate: req.TransactionDate, CodeNumber: req.CodeNumber, OutletID: req.OutletID, + Status: req.Status, Description: req.Description, Tax: req.Tax, Total: req.Total, @@ -38,6 +39,7 @@ func UpdateExpenseRequestToModel(req *contract.UpdateExpenseRequest) *models.Upd TransactionDate: req.TransactionDate, CodeNumber: req.CodeNumber, OutletID: req.OutletID, + Status: req.Status, Description: req.Description, Tax: req.Tax, Total: req.Total, @@ -70,6 +72,7 @@ func ListExpenseRequestToModel(req *contract.ListExpenseRequest) *models.ListExp Limit: req.Limit, Search: req.Search, OutletID: req.OutletID, + Status: req.Status, StartDate: req.StartDate, EndDate: req.EndDate, } @@ -92,6 +95,7 @@ func ExpenseModelResponseToResponse(expense *models.ExpenseResponse) *contract.E Receiver: expense.Receiver, TransactionDate: expense.TransactionDate, CodeNumber: expense.CodeNumber, + Status: expense.Status, Description: expense.Description, Tax: expense.Tax, Total: expense.Total, diff --git a/internal/validator/expense_validator.go b/internal/validator/expense_validator.go index 7101f45..c9306eb 100644 --- a/internal/validator/expense_validator.go +++ b/internal/validator/expense_validator.go @@ -48,6 +48,10 @@ func (v *ExpenseValidatorImpl) ValidateCreateExpenseRequest(req *contract.Create return errors.New("outlet_id must be a valid UUID"), constants.MalformedFieldErrorCode } + if req.Status != nil && !constants.IsValidExpenseStatus(constants.ExpenseStatus(*req.Status)) { + return errors.New("status must be one of: draft, sent, approved, cancel"), constants.MalformedFieldErrorCode + } + if req.Total <= 0 { return errors.New("total must be greater than 0"), constants.MalformedFieldErrorCode } @@ -91,6 +95,10 @@ func (v *ExpenseValidatorImpl) ValidateUpdateExpenseRequest(req *contract.Update return errors.New("code_number cannot be empty"), constants.MalformedFieldErrorCode } + if req.Status != nil && !constants.IsValidExpenseStatus(constants.ExpenseStatus(*req.Status)) { + return errors.New("status must be one of: draft, sent, approved, cancel"), constants.MalformedFieldErrorCode + } + if req.OutletID != nil { if strings.TrimSpace(*req.OutletID) == "" { return errors.New("outlet_id cannot be empty"), constants.MalformedFieldErrorCode @@ -143,5 +151,9 @@ func (v *ExpenseValidatorImpl) ValidateListExpenseRequest(req *contract.ListExpe return errors.New("limit must be between 1 and 100"), constants.MalformedFieldErrorCode } + if req.Status != "" && !constants.IsValidExpenseStatus(constants.ExpenseStatus(req.Status)) { + return errors.New("status must be one of: draft, sent, approved, cancel"), constants.MalformedFieldErrorCode + } + return nil, "" } diff --git a/migrations/000073_add_status_to_expenses.down.sql b/migrations/000073_add_status_to_expenses.down.sql new file mode 100644 index 0000000..3a51baa --- /dev/null +++ b/migrations/000073_add_status_to_expenses.down.sql @@ -0,0 +1,3 @@ +DROP INDEX IF EXISTS idx_expenses_status; +ALTER TABLE expenses DROP CONSTRAINT IF EXISTS expenses_status_check; +ALTER TABLE expenses DROP COLUMN IF EXISTS status; diff --git a/migrations/000073_add_status_to_expenses.up.sql b/migrations/000073_add_status_to_expenses.up.sql new file mode 100644 index 0000000..5bffd13 --- /dev/null +++ b/migrations/000073_add_status_to_expenses.up.sql @@ -0,0 +1,10 @@ +ALTER TABLE expenses ADD COLUMN IF NOT EXISTS status VARCHAR(20) NOT NULL DEFAULT 'draft'; + +UPDATE expenses +SET status = 'approved' +WHERE status = 'draft'; + +ALTER TABLE expenses DROP CONSTRAINT IF EXISTS expenses_status_check; +ALTER TABLE expenses ADD CONSTRAINT expenses_status_check CHECK (status IN ('draft', 'sent', 'approved', 'cancel')); + +CREATE INDEX IF NOT EXISTS idx_expenses_status ON expenses(status); From dc13bb5f932fb01d3aafc919ba677f65f7e73658 Mon Sep 17 00:00:00 2001 From: ryan Date: Fri, 29 May 2026 18:24:14 +0700 Subject: [PATCH 10/11] update due date and range date --- internal/constants/expense.go | 28 ++++ internal/contract/analytics_contract.go | 6 +- internal/contract/purchase_order_contract.go | 8 +- internal/entities/purchase_order.go | 24 +-- internal/models/analytics.go | 6 +- internal/models/purchase_order.go | 28 ++-- internal/processor/analytics_processor.go | 17 +- .../processor/analytics_processor_test.go | 2 +- internal/processor/expense_processor_test.go | 136 +++++++++++++++ .../processor/purchase_order_processor.go | 2 +- internal/repository/analytics_repository.go | 8 +- internal/service/analytics_service.go | 12 +- internal/service/analytics_service_test.go | 47 ++++++ internal/service/report_service.go | 2 +- internal/transformer/analytics_transformer.go | 18 +- .../transformer/analytics_transformer_test.go | 37 ++++ .../transformer/purchase_order_transformer.go | 12 +- .../purchase_order_transformer_test.go | 43 +++++ internal/validator/expense_validator_test.go | 158 ++++++++++++++++++ .../validator/purchase_order_validator.go | 60 ++++--- .../purchase_order_validator_test.go | 62 +++++++ ..._purchase_order_due_date_nullable.down.sql | 6 + ...ke_purchase_order_due_date_nullable.up.sql | 2 + 23 files changed, 640 insertions(+), 84 deletions(-) create mode 100644 internal/constants/expense.go create mode 100644 internal/processor/expense_processor_test.go create mode 100644 internal/transformer/purchase_order_transformer_test.go create mode 100644 internal/validator/expense_validator_test.go create mode 100644 internal/validator/purchase_order_validator_test.go create mode 100644 migrations/000074_make_purchase_order_due_date_nullable.down.sql create mode 100644 migrations/000074_make_purchase_order_due_date_nullable.up.sql diff --git a/internal/constants/expense.go b/internal/constants/expense.go new file mode 100644 index 0000000..38576df --- /dev/null +++ b/internal/constants/expense.go @@ -0,0 +1,28 @@ +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 +} diff --git a/internal/contract/analytics_contract.go b/internal/contract/analytics_contract.go index d9d6556..09211d1 100644 --- a/internal/contract/analytics_contract.go +++ b/internal/contract/analytics_contract.go @@ -239,13 +239,15 @@ type DashboardOverview struct { type ProfitLossAnalyticsRequest struct { OrganizationID uuid.UUID OutletID *string `form:"outlet_id,omitempty"` - Date string `form:"date" validate:"required"` + DateFrom string `form:"date_from" validate:"required"` + DateTo string `form:"date_to" validate:"required"` } type ProfitLossAnalyticsResponse struct { OrganizationID uuid.UUID `json:"organization_id"` OutletID *uuid.UUID `json:"outlet_id,omitempty"` - Date time.Time `json:"date"` + DateFrom time.Time `json:"date_from"` + DateTo time.Time `json:"date_to"` MainSummary []ProfitLossSummaryRow `json:"main_summary"` OperationalExpenses []OperationalExpenseItem `json:"operational_expenses"` OperationalExpensesTotal float64 `json:"operational_expenses_total"` diff --git a/internal/contract/purchase_order_contract.go b/internal/contract/purchase_order_contract.go index 4e57cb4..e6f1c92 100644 --- a/internal/contract/purchase_order_contract.go +++ b/internal/contract/purchase_order_contract.go @@ -9,8 +9,8 @@ import ( type CreatePurchaseOrderRequest struct { VendorID uuid.UUID `json:"vendor_id" validate:"required"` PONumber string `json:"po_number" validate:"required,min=1,max=50"` - TransactionDate string `json:"transaction_date" validate:"required"` // Format: YYYY-MM-DD - DueDate string `json:"due_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 Reference *string `json:"reference,omitempty" validate:"omitempty,max=100"` Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved received cancelled"` Message *string `json:"message,omitempty" validate:"omitempty"` @@ -30,7 +30,7 @@ type UpdatePurchaseOrderRequest struct { VendorID *uuid.UUID `json:"vendor_id,omitempty" validate:"omitempty"` PONumber *string `json:"po_number,omitempty" validate:"omitempty,min=1,max=50"` TransactionDate *string `json:"transaction_date,omitempty" validate:"omitempty"` // Format: YYYY-MM-DD - DueDate *string `json:"due_date,omitempty" validate:"omitempty"` // Format: YYYY-MM-DD + DueDate *string `json:"due_date,omitempty" validate:"omitempty"` // Format: YYYY-MM-DD Reference *string `json:"reference,omitempty" validate:"omitempty,max=100"` Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved received cancelled"` Message *string `json:"message,omitempty" validate:"omitempty"` @@ -53,7 +53,7 @@ type PurchaseOrderResponse struct { VendorID uuid.UUID `json:"vendor_id"` PONumber string `json:"po_number"` TransactionDate time.Time `json:"transaction_date"` - DueDate time.Time `json:"due_date"` + DueDate *time.Time `json:"due_date"` Reference *string `json:"reference"` Status string `json:"status"` Message *string `json:"message"` diff --git a/internal/entities/purchase_order.go b/internal/entities/purchase_order.go index f445ef3..8fe74c6 100644 --- a/internal/entities/purchase_order.go +++ b/internal/entities/purchase_order.go @@ -9,18 +9,18 @@ import ( ) type PurchaseOrder struct { - 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"` - VendorID uuid.UUID `gorm:"type:uuid;not null" json:"vendor_id" validate:"required"` - 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"` - DueDate time.Time `gorm:"type:date;not null" json:"due_date" validate:"required"` - 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"` - Message *string `gorm:"type:text" json:"message" validate:"omitempty"` - TotalAmount float64 `gorm:"type:decimal(15,2);not null;default:0" json:"total_amount"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + 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"` + VendorID uuid.UUID `gorm:"type:uuid;not null" json:"vendor_id" validate:"required"` + 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"` + DueDate *time.Time `gorm:"type:date" json:"due_date" validate:"omitempty"` + 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"` + Message *string `gorm:"type:text" json:"message" validate:"omitempty"` + TotalAmount float64 `gorm:"type:decimal(15,2);not null;default:0" json:"total_amount"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` Organization *Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"` Vendor *Vendor `gorm:"foreignKey:VendorID" json:"vendor,omitempty"` diff --git a/internal/models/analytics.go b/internal/models/analytics.go index 2a76965..dab9d22 100644 --- a/internal/models/analytics.go +++ b/internal/models/analytics.go @@ -249,13 +249,15 @@ type DashboardOverview struct { type ProfitLossAnalyticsRequest struct { OrganizationID uuid.UUID `validate:"required"` OutletID *uuid.UUID `validate:"omitempty"` - Date time.Time `validate:"required"` + DateFrom time.Time `validate:"required"` + DateTo time.Time `validate:"required"` } type ProfitLossAnalyticsResponse struct { OrganizationID uuid.UUID `json:"organization_id"` OutletID *uuid.UUID `json:"outlet_id,omitempty"` - Date time.Time `json:"date"` + DateFrom time.Time `json:"date_from"` + DateTo time.Time `json:"date_to"` MainSummary []ProfitLossSummaryRow `json:"main_summary"` OperationalExpenses []OperationalExpenseItem `json:"operational_expenses"` OperationalExpensesTotal float64 `json:"operational_expenses_total"` diff --git a/internal/models/purchase_order.go b/internal/models/purchase_order.go index eebf009..95b598b 100644 --- a/internal/models/purchase_order.go +++ b/internal/models/purchase_order.go @@ -7,18 +7,18 @@ import ( ) type PurchaseOrder struct { - ID uuid.UUID `json:"id"` - OrganizationID uuid.UUID `json:"organization_id"` - VendorID uuid.UUID `json:"vendor_id"` - PONumber string `json:"po_number"` - TransactionDate time.Time `json:"transaction_date"` - DueDate time.Time `json:"due_date"` - Reference *string `json:"reference"` - Status string `json:"status"` - Message *string `json:"message"` - TotalAmount float64 `json:"total_amount"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uuid.UUID `json:"id"` + OrganizationID uuid.UUID `json:"organization_id"` + VendorID uuid.UUID `json:"vendor_id"` + PONumber string `json:"po_number"` + TransactionDate time.Time `json:"transaction_date"` + DueDate *time.Time `json:"due_date"` + Reference *string `json:"reference"` + Status string `json:"status"` + Message *string `json:"message"` + TotalAmount float64 `json:"total_amount"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type PurchaseOrderItem struct { @@ -46,7 +46,7 @@ type PurchaseOrderResponse struct { VendorID uuid.UUID `json:"vendor_id"` PONumber string `json:"po_number"` TransactionDate time.Time `json:"transaction_date"` - DueDate time.Time `json:"due_date"` + DueDate *time.Time `json:"due_date"` Reference *string `json:"reference"` Status string `json:"status"` Message *string `json:"message"` @@ -84,7 +84,7 @@ type CreatePurchaseOrderRequest struct { VendorID uuid.UUID `json:"vendor_id"` PONumber string `json:"po_number"` TransactionDate time.Time `json:"transaction_date"` - DueDate time.Time `json:"due_date"` + DueDate *time.Time `json:"due_date,omitempty"` Reference *string `json:"reference,omitempty"` Status *string `json:"status,omitempty"` Message *string `json:"message,omitempty"` diff --git a/internal/processor/analytics_processor.go b/internal/processor/analytics_processor.go index 41a5b33..22ad669 100644 --- a/internal/processor/analytics_processor.go +++ b/internal/processor/analytics_processor.go @@ -398,11 +398,19 @@ func (p *AnalyticsProcessorImpl) GetDashboardAnalytics(ctx context.Context, req } func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req *models.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsResponse, error) { - if req.Date.IsZero() { - return nil, fmt.Errorf("date is required") + if req.DateFrom.IsZero() { + return nil, fmt.Errorf("date_from is required") } - result, err := p.analyticsRepo.GetProfitLossAnalytics(ctx, req.OrganizationID, req.OutletID, req.Date) + if req.DateTo.IsZero() { + return nil, fmt.Errorf("date_to is required") + } + + if req.DateFrom.After(req.DateTo) { + return nil, fmt.Errorf("date_from cannot be after date_to") + } + + result, err := p.analyticsRepo.GetProfitLossAnalytics(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo) if err != nil { return nil, fmt.Errorf("failed to get profit/loss analytics: %w", err) } @@ -507,7 +515,8 @@ func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req return &models.ProfitLossAnalyticsResponse{ OrganizationID: req.OrganizationID, OutletID: req.OutletID, - Date: req.Date, + DateFrom: req.DateFrom, + DateTo: req.DateTo, MainSummary: mainSummary, OperationalExpenses: opsItems, OperationalExpensesTotal: opsTotal, diff --git a/internal/processor/analytics_processor_test.go b/internal/processor/analytics_processor_test.go index 6fb57c5..7121861 100644 --- a/internal/processor/analytics_processor_test.go +++ b/internal/processor/analytics_processor_test.go @@ -40,7 +40,7 @@ func (analyticsRepositoryStub) GetDashboardOverview(context.Context, uuid.UUID, return nil, nil } -func (analyticsRepositoryStub) GetProfitLossAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time) (*entities.ProfitLossAnalytics, error) { +func (analyticsRepositoryStub) GetProfitLossAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) (*entities.ProfitLossAnalytics, error) { return nil, nil } diff --git a/internal/processor/expense_processor_test.go b/internal/processor/expense_processor_test.go new file mode 100644 index 0000000..7afb0e7 --- /dev/null +++ b/internal/processor/expense_processor_test.go @@ -0,0 +1,136 @@ +package processor + +import ( + "context" + "testing" + + "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 +} + +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) 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{} + p := NewExpenseProcessorImpl(repo) + 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(), + 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.Len(t, resp.Items, 1) + require.Equal(t, "Cleaning supplies", resp.Items[0].Item) +} + +func TestExpenseProcessorCreateDefaultsStatusToDraft(t *testing.T) { + repo := &expenseRepositoryCaptureStub{} + p := NewExpenseProcessorImpl(repo) + + 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(), + 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{} + p := NewExpenseProcessorImpl(repo) + 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(), + 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) +} diff --git a/internal/processor/purchase_order_processor.go b/internal/processor/purchase_order_processor.go index 2804de1..fd5863e 100644 --- a/internal/processor/purchase_order_processor.go +++ b/internal/processor/purchase_order_processor.go @@ -175,7 +175,7 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id poEntity.TransactionDate = *req.TransactionDate } if req.DueDate != nil { - poEntity.DueDate = *req.DueDate + poEntity.DueDate = req.DueDate } if req.Reference != nil { poEntity.Reference = req.Reference diff --git a/internal/repository/analytics_repository.go b/internal/repository/analytics_repository.go index 821b060..1519a1a 100644 --- a/internal/repository/analytics_repository.go +++ b/internal/repository/analytics_repository.go @@ -17,7 +17,7 @@ type AnalyticsRepository interface { GetProductAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, limit int) ([]*entities.ProductAnalytics, error) GetProductAnalyticsPerCategory(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]*entities.ProductAnalyticsPerCategory, error) GetDashboardOverview(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (*entities.DashboardOverview, error) - GetProfitLossAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, date time.Time) (*entities.ProfitLossAnalytics, error) + GetProfitLossAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (*entities.ProfitLossAnalytics, error) } type AnalyticsRepositoryImpl struct { @@ -432,9 +432,9 @@ func (r *AnalyticsRepositoryImpl) GetDashboardOverview(ctx context.Context, orga return &result, nil } -func (r *AnalyticsRepositoryImpl) GetProfitLossAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, date time.Time) (*entities.ProfitLossAnalytics, error) { - mtdStart := time.Date(date.Year(), date.Month(), 1, 0, 0, 0, 0, date.Location()) - todayStart := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) +func (r *AnalyticsRepositoryImpl) GetProfitLossAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (*entities.ProfitLossAnalytics, error) { + mtdStart := time.Date(dateTo.Year(), dateTo.Month(), 1, 0, 0, 0, 0, dateTo.Location()) + todayStart := time.Date(dateTo.Year(), dateTo.Month(), dateTo.Day(), 0, 0, 0, 0, dateTo.Location()) todayEnd := todayStart.Add(24 * time.Hour).Add(-time.Nanosecond) type revenueCostResult struct { diff --git a/internal/service/analytics_service.go b/internal/service/analytics_service.go index c0483a7..8511a17 100644 --- a/internal/service/analytics_service.go +++ b/internal/service/analytics_service.go @@ -306,8 +306,16 @@ func (s *AnalyticsServiceImpl) validateProfitLossAnalyticsRequest(req *models.Pr return fmt.Errorf("organization_id is required") } - if req.Date.IsZero() { - return fmt.Errorf("date is required") + if req.DateFrom.IsZero() { + return fmt.Errorf("date_from is required") + } + + if req.DateTo.IsZero() { + return fmt.Errorf("date_to is required") + } + + if req.DateFrom.After(req.DateTo) { + return fmt.Errorf("date_from cannot be after date_to") } return nil diff --git a/internal/service/analytics_service_test.go b/internal/service/analytics_service_test.go index 665578d..dbd536e 100644 --- a/internal/service/analytics_service_test.go +++ b/internal/service/analytics_service_test.go @@ -119,3 +119,50 @@ func TestAnalyticsServiceGetPurchasingAnalyticsAllowsEmptyGroupBy(t *testing.T) require.NoError(t, err) require.NotNil(t, resp) } + +func TestAnalyticsServiceGetProfitLossAnalyticsValidation(t *testing.T) { + service := NewAnalyticsServiceImpl(analyticsProcessorStub{}) + now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) + + tests := []struct { + name string + req *models.ProfitLossAnalyticsRequest + wantErr string + }{ + { + name: "missing date_from", + req: &models.ProfitLossAnalyticsRequest{ + OrganizationID: uuid.New(), + DateTo: now, + }, + wantErr: "date_from is required", + }, + { + name: "missing date_to", + req: &models.ProfitLossAnalyticsRequest{ + OrganizationID: uuid.New(), + DateFrom: now, + }, + wantErr: "date_to is required", + }, + { + name: "reversed dates", + req: &models.ProfitLossAnalyticsRequest{ + OrganizationID: uuid.New(), + DateFrom: now.AddDate(0, 0, 1), + DateTo: now, + }, + wantErr: "date_from cannot be after date_to", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp, err := service.GetProfitLossAnalytics(context.Background(), tt.req) + + require.Nil(t, resp) + require.Error(t, err) + require.Contains(t, err.Error(), tt.wantErr) + }) + } +} diff --git a/internal/service/report_service.go b/internal/service/report_service.go index 915eb28..f24e98d 100644 --- a/internal/service/report_service.go +++ b/internal/service/report_service.go @@ -113,7 +113,7 @@ func (s *ReportServiceImpl) GenerateDailyTransactionPDF(ctx context.Context, org end := day.Add(24*time.Hour - time.Nanosecond) salesReq := &models.SalesAnalyticsRequest{OrganizationID: orgID, OutletID: &outID, DateFrom: start, DateTo: end, GroupBy: "day"} - plReq := &models.ProfitLossAnalyticsRequest{OrganizationID: orgID, OutletID: &outID, Date: day} + plReq := &models.ProfitLossAnalyticsRequest{OrganizationID: orgID, OutletID: &outID, DateFrom: start, DateTo: end} productReq := &models.ProductAnalyticsRequest{OrganizationID: orgID, OutletID: &outID, DateFrom: start, DateTo: end, Limit: 1000} sales, err := s.analyticsService.GetSalesAnalytics(ctx, salesReq) diff --git a/internal/transformer/analytics_transformer.go b/internal/transformer/analytics_transformer.go index c5436cc..2172cec 100644 --- a/internal/transformer/analytics_transformer.go +++ b/internal/transformer/analytics_transformer.go @@ -432,19 +432,24 @@ func ProfitLossAnalyticsContractToModel(req *contract.ProfitLossAnalyticsRequest return nil, fmt.Errorf("request cannot be nil") } - dateTime, err := util.ParseDateToJakartaTime(req.Date) + dateFrom, dateTo, err := util.ParseDateRangeToJakartaTime(req.DateFrom, req.DateTo) if err != nil { - return nil, fmt.Errorf("invalid date format: %w", err) + return nil, fmt.Errorf("invalid date range: %w", err) } - if dateTime == nil { - return nil, fmt.Errorf("date is required") + if dateFrom == nil { + return nil, fmt.Errorf("date_from is required") + } + + if dateTo == nil { + return nil, fmt.Errorf("date_to is required") } return &models.ProfitLossAnalyticsRequest{ OrganizationID: req.OrganizationID, OutletID: parseOutletID(req.OutletID), - Date: *dateTime, + DateFrom: *dateFrom, + DateTo: *dateTo, }, nil } @@ -469,7 +474,8 @@ func ProfitLossAnalyticsModelToContract(resp *models.ProfitLossAnalyticsResponse return &contract.ProfitLossAnalyticsResponse{ OrganizationID: resp.OrganizationID, OutletID: resp.OutletID, - Date: resp.Date, + DateFrom: resp.DateFrom, + DateTo: resp.DateTo, MainSummary: mainSummary, OperationalExpenses: opsItems, OperationalExpensesTotal: resp.OperationalExpensesTotal, diff --git a/internal/transformer/analytics_transformer_test.go b/internal/transformer/analytics_transformer_test.go index 0c94fbb..efbb87e 100644 --- a/internal/transformer/analytics_transformer_test.go +++ b/internal/transformer/analytics_transformer_test.go @@ -74,3 +74,40 @@ func TestPurchasingAnalyticsModelToContractOmitsNilOutletName(t *testing.T) { require.NoError(t, err) require.NotContains(t, string(payload), "outlet_name") } + +func TestProfitLossAnalyticsContractToModelParsesDateRange(t *testing.T) { + orgID := uuid.New() + outletID := uuid.New().String() + + result, err := ProfitLossAnalyticsContractToModel(&contract.ProfitLossAnalyticsRequest{ + OrganizationID: orgID, + OutletID: &outletID, + DateFrom: "01-05-2026", + DateTo: "29-05-2026", + }) + + require.NoError(t, err) + require.Equal(t, orgID, result.OrganizationID) + require.NotNil(t, result.OutletID) + require.Equal(t, outletID, result.OutletID.String()) + + location, err := time.LoadLocation("Asia/Jakarta") + require.NoError(t, err) + require.Equal(t, time.Date(2026, 5, 1, 0, 0, 0, 0, location), result.DateFrom) + require.Equal(t, time.Date(2026, 5, 29, 23, 59, 59, int(time.Second-time.Nanosecond), location), result.DateTo) +} + +func TestProfitLossAnalyticsModelToContractCopiesDateRange(t *testing.T) { + dateFrom := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) + dateTo := time.Date(2026, 5, 29, 23, 59, 59, int(time.Second-time.Nanosecond), time.UTC) + + result := ProfitLossAnalyticsModelToContract(&models.ProfitLossAnalyticsResponse{ + OrganizationID: uuid.New(), + DateFrom: dateFrom, + DateTo: dateTo, + }) + + require.NotNil(t, result) + require.Equal(t, dateFrom, result.DateFrom) + require.Equal(t, dateTo, result.DateTo) +} diff --git a/internal/transformer/purchase_order_transformer.go b/internal/transformer/purchase_order_transformer.go index a867df0..9860f8f 100644 --- a/internal/transformer/purchase_order_transformer.go +++ b/internal/transformer/purchase_order_transformer.go @@ -25,10 +25,14 @@ func CreatePurchaseOrderRequestToModel(req *contract.CreatePurchaseOrderRequest) return nil, err } - // Parse due date - dueDate, err := time.Parse("2006-01-02", req.DueDate) - if err != nil { - return nil, err + // Parse due date if provided + var dueDate *time.Time + if req.DueDate != nil && *req.DueDate != "" { + parsedDate, err := time.Parse("2006-01-02", *req.DueDate) + if err != nil { + return nil, err + } + dueDate = &parsedDate } return &models.CreatePurchaseOrderRequest{ diff --git a/internal/transformer/purchase_order_transformer_test.go b/internal/transformer/purchase_order_transformer_test.go new file mode 100644 index 0000000..24aea4c --- /dev/null +++ b/internal/transformer/purchase_order_transformer_test.go @@ -0,0 +1,43 @@ +package transformer + +import ( + "encoding/json" + "testing" + + "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/models" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +func TestCreatePurchaseOrderRequestToModelAllowsMissingDueDate(t *testing.T) { + result, err := CreatePurchaseOrderRequestToModel(&contract.CreatePurchaseOrderRequest{ + VendorID: uuid.New(), + PONumber: "PO-001", + TransactionDate: "2026-05-29", + Items: []contract.CreatePurchaseOrderItemRequest{ + { + IngredientID: uuid.New(), + Quantity: 1, + UnitID: uuid.New(), + Amount: 1000, + }, + }, + }) + + require.NoError(t, err) + require.Nil(t, result.DueDate) +} + +func TestPurchaseOrderModelResponseToResponseIncludesNullDueDate(t *testing.T) { + result := PurchaseOrderModelResponseToResponse(&models.PurchaseOrderResponse{ + ID: uuid.New(), + VendorID: uuid.New(), + PONumber: "PO-001", + }) + + payload, err := json.Marshal(result) + require.NoError(t, err) + require.Contains(t, string(payload), `"due_date":null`) +} diff --git a/internal/validator/expense_validator_test.go b/internal/validator/expense_validator_test.go new file mode 100644 index 0000000..d9ae15d --- /dev/null +++ b/internal/validator/expense_validator_test.go @@ -0,0 +1,158 @@ +package validator + +import ( + "testing" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/contract" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +func TestExpenseValidatorCreateRequiresItemName(t *testing.T) { + v := NewExpenseValidator() + + req := &contract.CreateExpenseRequest{ + Receiver: "Cashier", + TransactionDate: "2026-05-29", + CodeNumber: "EXP-001", + OutletID: uuid.NewString(), + Total: 10000, + Items: []contract.CreateExpenseItemRequest{ + { + ChartOfAccountID: uuid.NewString(), + Amount: 10000, + }, + }, + } + + err, code := v.ValidateCreateExpenseRequest(req) + + require.Error(t, err) + require.Equal(t, constants.MissingFieldErrorCode, code) + require.Contains(t, err.Error(), "item 0: item is required") +} + +func TestExpenseValidatorCreateDoesNotRequireHeaderExpenseName(t *testing.T) { + v := NewExpenseValidator() + + req := &contract.CreateExpenseRequest{ + Receiver: "Cashier", + TransactionDate: "2026-05-29", + CodeNumber: "EXP-001", + OutletID: uuid.NewString(), + Total: 10000, + Items: []contract.CreateExpenseItemRequest{ + { + ChartOfAccountID: uuid.NewString(), + Item: "Cleaning supplies", + Amount: 10000, + }, + }, + } + + err, code := v.ValidateCreateExpenseRequest(req) + + require.NoError(t, err) + require.Empty(t, code) +} + +func TestExpenseValidatorCreateAllowsValidOptionalStatus(t *testing.T) { + v := NewExpenseValidator() + status := "approved" + + req := &contract.CreateExpenseRequest{ + Receiver: "Cashier", + TransactionDate: "2026-05-29", + CodeNumber: "EXP-001", + OutletID: uuid.NewString(), + Status: &status, + Total: 10000, + Items: []contract.CreateExpenseItemRequest{ + { + ChartOfAccountID: uuid.NewString(), + Item: "Cleaning supplies", + Amount: 10000, + }, + }, + } + + err, code := v.ValidateCreateExpenseRequest(req) + + require.NoError(t, err) + require.Empty(t, code) +} + +func TestExpenseValidatorCreateRejectsInvalidStatus(t *testing.T) { + v := NewExpenseValidator() + status := "cancelled" + + req := &contract.CreateExpenseRequest{ + Receiver: "Cashier", + TransactionDate: "2026-05-29", + CodeNumber: "EXP-001", + OutletID: uuid.NewString(), + Status: &status, + Total: 10000, + Items: []contract.CreateExpenseItemRequest{ + { + ChartOfAccountID: uuid.NewString(), + Item: "Cleaning supplies", + Amount: 10000, + }, + }, + } + + err, code := v.ValidateCreateExpenseRequest(req) + + require.Error(t, err) + require.Equal(t, constants.MalformedFieldErrorCode, code) + require.Contains(t, err.Error(), "status must be one of: draft, sent, approved, cancel") +} + +func TestExpenseValidatorUpdateRejectsEmptyItemNameWhenProvided(t *testing.T) { + v := NewExpenseValidator() + empty := " " + + req := &contract.UpdateExpenseRequest{ + Items: []contract.UpdateExpenseItemRequest{ + {Item: &empty}, + }, + } + + err, code := v.ValidateUpdateExpenseRequest(req) + + require.Error(t, err) + require.Equal(t, constants.MalformedFieldErrorCode, code) + require.Contains(t, err.Error(), "item 0: item cannot be empty") +} + +func TestExpenseValidatorUpdateRejectsInvalidStatus(t *testing.T) { + v := NewExpenseValidator() + status := "cancelled" + + req := &contract.UpdateExpenseRequest{Status: &status} + + err, code := v.ValidateUpdateExpenseRequest(req) + + require.Error(t, err) + require.Equal(t, constants.MalformedFieldErrorCode, code) + require.Contains(t, err.Error(), "status must be one of: draft, sent, approved, cancel") +} + +func TestExpenseValidatorListRejectsInvalidStatus(t *testing.T) { + v := NewExpenseValidator() + + req := &contract.ListExpenseRequest{ + Page: 1, + Limit: 10, + Status: "cancelled", + } + + err, code := v.ValidateListExpenseRequest(req) + + require.Error(t, err) + require.Equal(t, constants.MalformedFieldErrorCode, code) + require.Contains(t, err.Error(), "status must be one of: draft, sent, approved, cancel") +} diff --git a/internal/validator/purchase_order_validator.go b/internal/validator/purchase_order_validator.go index f6706fa..824ea1b 100644 --- a/internal/validator/purchase_order_validator.go +++ b/internal/validator/purchase_order_validator.go @@ -47,18 +47,19 @@ func (v *PurchaseOrderValidatorImpl) ValidateCreatePurchaseOrderRequest(req *con return errors.New("transaction_date must be in YYYY-MM-DD format"), constants.MalformedFieldErrorCode } - // Validate due date - if strings.TrimSpace(req.DueDate) == "" { - return errors.New("due_date is required"), constants.MissingFieldErrorCode - } - dueDate, err := time.Parse("2006-01-02", req.DueDate) - if err != nil { - return errors.New("due_date must be in YYYY-MM-DD format"), constants.MalformedFieldErrorCode - } + if req.DueDate != nil { + if strings.TrimSpace(*req.DueDate) == "" { + return errors.New("due_date cannot be empty"), constants.MalformedFieldErrorCode + } - // Check if due date is after transaction date - if dueDate.Before(transactionDate) { - return errors.New("due_date must be after transaction_date"), constants.MalformedFieldErrorCode + dueDate, err := time.Parse("2006-01-02", *req.DueDate) + if err != nil { + return errors.New("due_date must be in YYYY-MM-DD format"), constants.MalformedFieldErrorCode + } + + if dueDate.Before(transactionDate) { + return errors.New("due_date must be after transaction_date"), constants.MalformedFieldErrorCode + } } if req.Reference != nil && len(*req.Reference) > 100 { @@ -100,22 +101,27 @@ func (v *PurchaseOrderValidatorImpl) ValidateUpdatePurchaseOrderRequest(req *con } } - // Validate dates if both are provided - if req.TransactionDate != nil && req.DueDate != nil { - if *req.TransactionDate != "" && *req.DueDate != "" { - transactionDate, err := time.Parse("2006-01-02", *req.TransactionDate) - if err != nil { - return errors.New("transaction_date must be in YYYY-MM-DD format"), constants.MalformedFieldErrorCode - } - - dueDate, err := time.Parse("2006-01-02", *req.DueDate) - if err != nil { - return errors.New("due_date must be in YYYY-MM-DD format"), constants.MalformedFieldErrorCode - } - - if dueDate.Before(transactionDate) { - return errors.New("due_date must be after transaction_date"), constants.MalformedFieldErrorCode - } + var transactionDate *time.Time + if req.TransactionDate != nil && *req.TransactionDate != "" { + parsedDate, err := time.Parse("2006-01-02", *req.TransactionDate) + if err != nil { + return errors.New("transaction_date must be in YYYY-MM-DD format"), constants.MalformedFieldErrorCode + } + transactionDate = &parsedDate + } + + if req.DueDate != nil { + if strings.TrimSpace(*req.DueDate) == "" { + return errors.New("due_date cannot be empty"), constants.MalformedFieldErrorCode + } + + dueDate, err := time.Parse("2006-01-02", *req.DueDate) + if err != nil { + return errors.New("due_date must be in YYYY-MM-DD format"), constants.MalformedFieldErrorCode + } + + if transactionDate != nil && dueDate.Before(*transactionDate) { + return errors.New("due_date must be after transaction_date"), constants.MalformedFieldErrorCode } } diff --git a/internal/validator/purchase_order_validator_test.go b/internal/validator/purchase_order_validator_test.go new file mode 100644 index 0000000..3cd821c --- /dev/null +++ b/internal/validator/purchase_order_validator_test.go @@ -0,0 +1,62 @@ +package validator + +import ( + "testing" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/contract" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +func validCreatePurchaseOrderRequest() *contract.CreatePurchaseOrderRequest { + return &contract.CreatePurchaseOrderRequest{ + VendorID: uuid.New(), + PONumber: "PO-001", + TransactionDate: "2026-05-29", + Items: []contract.CreatePurchaseOrderItemRequest{ + { + IngredientID: uuid.New(), + Quantity: 1, + UnitID: uuid.New(), + Amount: 1000, + }, + }, + } +} + +func TestPurchaseOrderValidatorCreateAllowsMissingDueDate(t *testing.T) { + validator := NewPurchaseOrderValidator() + + err, code := validator.ValidateCreatePurchaseOrderRequest(validCreatePurchaseOrderRequest()) + + require.NoError(t, err) + require.Empty(t, code) +} + +func TestPurchaseOrderValidatorCreateRejectsInvalidDueDate(t *testing.T) { + validator := NewPurchaseOrderValidator() + req := validCreatePurchaseOrderRequest() + dueDate := "29-05-2026" + req.DueDate = &dueDate + + err, code := validator.ValidateCreatePurchaseOrderRequest(req) + + require.Error(t, err) + require.Equal(t, constants.MalformedFieldErrorCode, code) + require.Contains(t, err.Error(), "due_date must be in YYYY-MM-DD format") +} + +func TestPurchaseOrderValidatorCreateRejectsDueDateBeforeTransactionDate(t *testing.T) { + validator := NewPurchaseOrderValidator() + req := validCreatePurchaseOrderRequest() + dueDate := "2026-05-28" + req.DueDate = &dueDate + + err, code := validator.ValidateCreatePurchaseOrderRequest(req) + + require.Error(t, err) + require.Equal(t, constants.MalformedFieldErrorCode, code) + require.Contains(t, err.Error(), "due_date must be after transaction_date") +} diff --git a/migrations/000074_make_purchase_order_due_date_nullable.down.sql b/migrations/000074_make_purchase_order_due_date_nullable.down.sql new file mode 100644 index 0000000..dcdc95c --- /dev/null +++ b/migrations/000074_make_purchase_order_due_date_nullable.down.sql @@ -0,0 +1,6 @@ +UPDATE purchase_orders +SET due_date = transaction_date +WHERE due_date IS NULL; + +ALTER TABLE purchase_orders + ALTER COLUMN due_date SET NOT NULL; diff --git a/migrations/000074_make_purchase_order_due_date_nullable.up.sql b/migrations/000074_make_purchase_order_due_date_nullable.up.sql new file mode 100644 index 0000000..8483bfb --- /dev/null +++ b/migrations/000074_make_purchase_order_due_date_nullable.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE purchase_orders + ALTER COLUMN due_date DROP NOT NULL; From 47fa21d7391f83372a17db9495ae098a743eec65 Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 1 Jun 2026 13:13:40 +0700 Subject: [PATCH 11/11] reinstate profit loss overview --- internal/contract/analytics_contract.go | 47 ++++++ internal/entities/analytics.go | 45 ++++++ internal/models/analytics.go | 47 ++++++ internal/processor/analytics_processor.go | 64 ++++++++- .../processor/analytics_processor_test.go | 81 ++++++++++- internal/repository/analytics_repository.go | 134 +++++++++++++++++- internal/service/analytics_service.go | 24 ++-- internal/service/analytics_service_test.go | 24 ++++ internal/transformer/analytics_transformer.go | 59 +++++++- .../transformer/analytics_transformer_test.go | 41 ++++++ 10 files changed, 541 insertions(+), 25 deletions(-) diff --git a/internal/contract/analytics_contract.go b/internal/contract/analytics_contract.go index 09211d1..89c3327 100644 --- a/internal/contract/analytics_contract.go +++ b/internal/contract/analytics_contract.go @@ -241,6 +241,7 @@ type ProfitLossAnalyticsRequest 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 ProfitLossAnalyticsResponse struct { @@ -248,11 +249,57 @@ type ProfitLossAnalyticsResponse struct { 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 ProfitLossSummary `json:"summary"` + Data []ProfitLossData `json:"data"` + ProductData []ProductProfitData `json:"product_data"` MainSummary []ProfitLossSummaryRow `json:"main_summary"` OperationalExpenses []OperationalExpenseItem `json:"operational_expenses"` OperationalExpensesTotal float64 `json:"operational_expenses_total"` } +type ProfitLossSummary struct { + TotalRevenue float64 `json:"total_revenue"` + TotalCost float64 `json:"total_cost"` + GrossProfit float64 `json:"gross_profit"` + GrossProfitMargin float64 `json:"gross_profit_margin"` + TotalTax float64 `json:"total_tax"` + TotalDiscount float64 `json:"total_discount"` + NetProfit float64 `json:"net_profit"` + NetProfitMargin float64 `json:"net_profit_margin"` + TotalOrders int64 `json:"total_orders"` + AverageProfit float64 `json:"average_profit"` + ProfitabilityRatio float64 `json:"profitability_ratio"` +} + +type ProfitLossData struct { + Date time.Time `json:"date"` + Revenue float64 `json:"revenue"` + Cost float64 `json:"cost"` + GrossProfit float64 `json:"gross_profit"` + GrossProfitMargin float64 `json:"gross_profit_margin"` + Tax float64 `json:"tax"` + Discount float64 `json:"discount"` + NetProfit float64 `json:"net_profit"` + NetProfitMargin float64 `json:"net_profit_margin"` + Orders int64 `json:"orders"` +} + +type ProductProfitData struct { + ProductID uuid.UUID `json:"product_id"` + ProductName string `json:"product_name"` + CategoryID uuid.UUID `json:"category_id"` + CategoryName string `json:"category_name"` + QuantitySold int64 `json:"quantity_sold"` + Revenue float64 `json:"revenue"` + Cost float64 `json:"cost"` + GrossProfit float64 `json:"gross_profit"` + GrossProfitMargin float64 `json:"gross_profit_margin"` + AveragePrice float64 `json:"average_price"` + AverageCost float64 `json:"average_cost"` + ProfitPerUnit float64 `json:"profit_per_unit"` +} + type ProfitLossSummaryRow struct { ID string `json:"id"` Label string `json:"label"` diff --git a/internal/entities/analytics.go b/internal/entities/analytics.go index 5f0a894..8cea304 100644 --- a/internal/entities/analytics.go +++ b/internal/entities/analytics.go @@ -114,6 +114,9 @@ type DashboardOverview struct { } type ProfitLossAnalytics struct { + Summary ProfitLossSummary + Data []ProfitLossData + ProductData []ProductProfitData TodayRevenue float64 TodayCost float64 MtdRevenue float64 @@ -123,6 +126,48 @@ type ProfitLossAnalytics struct { OperationalExpenseItems []OperationalExpenseItem } +type ProfitLossSummary struct { + TotalRevenue float64 + TotalCost float64 + GrossProfit float64 + GrossProfitMargin float64 + TotalTax float64 + TotalDiscount float64 + NetProfit float64 + NetProfitMargin float64 + TotalOrders int64 + AverageProfit float64 + ProfitabilityRatio float64 +} + +type ProfitLossData struct { + Date time.Time + Revenue float64 + Cost float64 + GrossProfit float64 + GrossProfitMargin float64 + Tax float64 + Discount float64 + NetProfit float64 + NetProfitMargin float64 + Orders int64 +} + +type ProductProfitData struct { + ProductID uuid.UUID + ProductName string + CategoryID uuid.UUID + CategoryName string + QuantitySold int64 + Revenue float64 + Cost float64 + GrossProfit float64 + GrossProfitMargin float64 + AveragePrice float64 + AverageCost float64 + ProfitPerUnit float64 +} + type ExpenseCategoryTotal struct { CategoryName string Amount float64 diff --git a/internal/models/analytics.go b/internal/models/analytics.go index dab9d22..4b94cba 100644 --- a/internal/models/analytics.go +++ b/internal/models/analytics.go @@ -251,6 +251,7 @@ type ProfitLossAnalyticsRequest struct { OutletID *uuid.UUID `validate:"omitempty"` DateFrom time.Time `validate:"required"` DateTo time.Time `validate:"required"` + GroupBy string `validate:"omitempty,oneof=day hour week month"` } type ProfitLossAnalyticsResponse struct { @@ -258,11 +259,57 @@ type ProfitLossAnalyticsResponse struct { 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 ProfitLossSummary `json:"summary"` + Data []ProfitLossData `json:"data"` + ProductData []ProductProfitData `json:"product_data"` MainSummary []ProfitLossSummaryRow `json:"main_summary"` OperationalExpenses []OperationalExpenseItem `json:"operational_expenses"` OperationalExpensesTotal float64 `json:"operational_expenses_total"` } +type ProfitLossSummary struct { + TotalRevenue float64 `json:"total_revenue"` + TotalCost float64 `json:"total_cost"` + GrossProfit float64 `json:"gross_profit"` + GrossProfitMargin float64 `json:"gross_profit_margin"` + TotalTax float64 `json:"total_tax"` + TotalDiscount float64 `json:"total_discount"` + NetProfit float64 `json:"net_profit"` + NetProfitMargin float64 `json:"net_profit_margin"` + TotalOrders int64 `json:"total_orders"` + AverageProfit float64 `json:"average_profit"` + ProfitabilityRatio float64 `json:"profitability_ratio"` +} + +type ProfitLossData struct { + Date time.Time `json:"date"` + Revenue float64 `json:"revenue"` + Cost float64 `json:"cost"` + GrossProfit float64 `json:"gross_profit"` + GrossProfitMargin float64 `json:"gross_profit_margin"` + Tax float64 `json:"tax"` + Discount float64 `json:"discount"` + NetProfit float64 `json:"net_profit"` + NetProfitMargin float64 `json:"net_profit_margin"` + Orders int64 `json:"orders"` +} + +type ProductProfitData struct { + ProductID uuid.UUID `json:"product_id"` + ProductName string `json:"product_name"` + CategoryID uuid.UUID `json:"category_id"` + CategoryName string `json:"category_name"` + QuantitySold int64 `json:"quantity_sold"` + Revenue float64 `json:"revenue"` + Cost float64 `json:"cost"` + GrossProfit float64 `json:"gross_profit"` + GrossProfitMargin float64 `json:"gross_profit_margin"` + AveragePrice float64 `json:"average_price"` + AverageCost float64 `json:"average_cost"` + ProfitPerUnit float64 `json:"profit_per_unit"` +} + type ProfitLossSummaryRow struct { ID string `json:"id"` Label string `json:"label"` diff --git a/internal/processor/analytics_processor.go b/internal/processor/analytics_processor.go index 22ad669..c8e41f6 100644 --- a/internal/processor/analytics_processor.go +++ b/internal/processor/analytics_processor.go @@ -410,11 +410,49 @@ func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req return nil, fmt.Errorf("date_from cannot be after date_to") } - result, err := p.analyticsRepo.GetProfitLossAnalytics(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo) + if req.GroupBy == "" { + req.GroupBy = "day" + } + + result, err := p.analyticsRepo.GetProfitLossAnalytics(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo, req.GroupBy) if err != nil { return nil, fmt.Errorf("failed to get profit/loss analytics: %w", err) } + data := make([]models.ProfitLossData, len(result.Data)) + for i, item := range result.Data { + data[i] = models.ProfitLossData{ + Date: item.Date, + Revenue: item.Revenue, + Cost: item.Cost, + GrossProfit: item.GrossProfit, + GrossProfitMargin: item.GrossProfitMargin, + Tax: item.Tax, + Discount: item.Discount, + NetProfit: item.NetProfit, + NetProfitMargin: item.NetProfitMargin, + Orders: item.Orders, + } + } + + productData := make([]models.ProductProfitData, len(result.ProductData)) + for i, item := range result.ProductData { + productData[i] = models.ProductProfitData{ + ProductID: item.ProductID, + ProductName: item.ProductName, + CategoryID: item.CategoryID, + CategoryName: item.CategoryName, + QuantitySold: item.QuantitySold, + Revenue: item.Revenue, + Cost: item.Cost, + GrossProfit: item.GrossProfit, + GrossProfitMargin: item.GrossProfitMargin, + AveragePrice: item.AveragePrice, + AverageCost: item.AverageCost, + ProfitPerUnit: item.ProfitPerUnit, + } + } + todayPromosi := getExpenseAmountByCategory(result.TodayExpenseByCategory, "promosi") todayLainLain := getExpenseAmountByCategory(result.TodayExpenseByCategory, "lain") todayTotalOps := todayPromosi + todayLainLain @@ -513,10 +551,26 @@ func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req } return &models.ProfitLossAnalyticsResponse{ - OrganizationID: req.OrganizationID, - OutletID: req.OutletID, - DateFrom: req.DateFrom, - DateTo: req.DateTo, + OrganizationID: req.OrganizationID, + OutletID: req.OutletID, + DateFrom: req.DateFrom, + DateTo: req.DateTo, + GroupBy: req.GroupBy, + Summary: models.ProfitLossSummary{ + TotalRevenue: result.Summary.TotalRevenue, + TotalCost: result.Summary.TotalCost, + GrossProfit: result.Summary.GrossProfit, + GrossProfitMargin: result.Summary.GrossProfitMargin, + TotalTax: result.Summary.TotalTax, + TotalDiscount: result.Summary.TotalDiscount, + NetProfit: result.Summary.NetProfit, + NetProfitMargin: result.Summary.NetProfitMargin, + TotalOrders: result.Summary.TotalOrders, + AverageProfit: result.Summary.AverageProfit, + ProfitabilityRatio: result.Summary.ProfitabilityRatio, + }, + Data: data, + ProductData: productData, MainSummary: mainSummary, OperationalExpenses: opsItems, OperationalExpensesTotal: opsTotal, diff --git a/internal/processor/analytics_processor_test.go b/internal/processor/analytics_processor_test.go index 7121861..998831a 100644 --- a/internal/processor/analytics_processor_test.go +++ b/internal/processor/analytics_processor_test.go @@ -14,6 +14,8 @@ import ( type analyticsRepositoryStub struct { purchasingResult *entities.PurchasingAnalytics + profitLossResult *entities.ProfitLossAnalytics + profitLossGroup string } func (analyticsRepositoryStub) GetPaymentMethodAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.PaymentMethodAnalytics, error) { @@ -40,8 +42,9 @@ func (analyticsRepositoryStub) GetDashboardOverview(context.Context, uuid.UUID, return nil, nil } -func (analyticsRepositoryStub) GetProfitLossAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) (*entities.ProfitLossAnalytics, 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 } type expenseRepositoryStub struct{} @@ -88,3 +91,77 @@ func TestAnalyticsProcessorGetPurchasingAnalyticsPassesOutletName(t *testing.T) require.Equal(t, outletName, *result.OutletName) require.Equal(t, float64(125), result.Summary.TotalPurchases) } + +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) +} diff --git a/internal/repository/analytics_repository.go b/internal/repository/analytics_repository.go index 1519a1a..b250dc9 100644 --- a/internal/repository/analytics_repository.go +++ b/internal/repository/analytics_repository.go @@ -17,7 +17,7 @@ type AnalyticsRepository interface { GetProductAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, limit int) ([]*entities.ProductAnalytics, error) GetProductAnalyticsPerCategory(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]*entities.ProductAnalyticsPerCategory, error) GetDashboardOverview(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (*entities.DashboardOverview, error) - GetProfitLossAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (*entities.ProfitLossAnalytics, error) + GetProfitLossAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) (*entities.ProfitLossAnalytics, error) } type AnalyticsRepositoryImpl struct { @@ -432,11 +432,138 @@ func (r *AnalyticsRepositoryImpl) GetDashboardOverview(ctx context.Context, orga return &result, nil } -func (r *AnalyticsRepositoryImpl) GetProfitLossAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (*entities.ProfitLossAnalytics, error) { +func (r *AnalyticsRepositoryImpl) GetProfitLossAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) (*entities.ProfitLossAnalytics, error) { mtdStart := time.Date(dateTo.Year(), dateTo.Month(), 1, 0, 0, 0, 0, dateTo.Location()) todayStart := time.Date(dateTo.Year(), dateTo.Month(), dateTo.Day(), 0, 0, 0, 0, dateTo.Location()) todayEnd := todayStart.Add(24 * time.Hour).Add(-time.Nanosecond) + var summary entities.ProfitLossSummary + summaryQuery := r.db.WithContext(ctx). + Table("orders o"). + Select(` + COALESCE(SUM(o.total_amount), 0) as total_revenue, + COALESCE(SUM(o.total_cost), 0) as total_cost, + COALESCE(SUM(o.total_amount - o.total_cost), 0) as gross_profit, + CASE + WHEN SUM(o.total_amount) > 0 + THEN (SUM(o.total_amount - o.total_cost) / SUM(o.total_amount)) * 100 + ELSE 0 + END as gross_profit_margin, + COALESCE(SUM(o.tax_amount), 0) as total_tax, + COALESCE(SUM(o.discount_amount), 0) as total_discount, + COALESCE(SUM(o.total_amount - o.total_cost - o.discount_amount), 0) as net_profit, + CASE + WHEN SUM(o.total_amount) > 0 + THEN (SUM(o.total_amount - o.total_cost - o.discount_amount) / SUM(o.total_amount)) * 100 + ELSE 0 + END as net_profit_margin, + COUNT(o.id) as total_orders, + CASE + WHEN COUNT(o.id) > 0 + THEN SUM(o.total_amount - o.total_cost - o.discount_amount) / COUNT(o.id) + ELSE 0 + END as average_profit, + CASE + WHEN SUM(o.total_cost) > 0 + THEN (SUM(o.total_amount - o.total_cost) / SUM(o.total_cost)) * 100 + ELSE 0 + END as profitability_ratio + `). + Where("o.organization_id = ?", organizationID). + Where("o.status = ?", entities.OrderStatusCompleted). + Where("o.payment_status = ?", entities.PaymentStatusCompleted). + Where("o.is_void = false AND o.is_refund = false"). + Where("o.created_at >= ? AND o.created_at <= ?", dateFrom, dateTo) + summaryQuery = r.resolveOutletID(summaryQuery, outletID, "o.outlet_id") + if err := summaryQuery.Scan(&summary).Error; err != nil { + return nil, err + } + + var timeFormat string + switch groupBy { + case "hour": + timeFormat = "DATE_TRUNC('hour', o.created_at)" + case "week": + timeFormat = "DATE_TRUNC('week', o.created_at)" + case "month": + timeFormat = "DATE_TRUNC('month', o.created_at)" + default: + timeFormat = "DATE_TRUNC('day', o.created_at)" + } + + var data []entities.ProfitLossData + dataQuery := r.db.WithContext(ctx). + Table("orders o"). + Select(` + `+timeFormat+` as date, + COALESCE(SUM(o.total_amount), 0) as revenue, + COALESCE(SUM(o.total_cost), 0) as cost, + COALESCE(SUM(o.total_amount - o.total_cost), 0) as gross_profit, + CASE + WHEN SUM(o.total_amount) > 0 + THEN (SUM(o.total_amount - o.total_cost) / SUM(o.total_amount)) * 100 + ELSE 0 + END as gross_profit_margin, + COALESCE(SUM(o.tax_amount), 0) as tax, + COALESCE(SUM(o.discount_amount), 0) as discount, + COALESCE(SUM(o.total_amount - o.total_cost - o.discount_amount), 0) as net_profit, + CASE + WHEN SUM(o.total_amount) > 0 + THEN (SUM(o.total_amount - o.total_cost - o.discount_amount) / SUM(o.total_amount)) * 100 + ELSE 0 + END as net_profit_margin, + COUNT(o.id) as orders + `). + Where("o.organization_id = ?", organizationID). + Where("o.status = ?", entities.OrderStatusCompleted). + Where("o.payment_status = ?", entities.PaymentStatusCompleted). + Where("o.is_void = false AND o.is_refund = false"). + Where("o.created_at >= ? AND o.created_at <= ?", dateFrom, dateTo). + Group(timeFormat). + Order(timeFormat) + dataQuery = r.resolveOutletID(dataQuery, outletID, "o.outlet_id") + if err := dataQuery.Scan(&data).Error; err != nil { + return nil, err + } + + var productData []entities.ProductProfitData + productQuery := r.db.WithContext(ctx). + Table("order_items oi"). + Select(` + p.id as product_id, + p.name as product_name, + c.id as category_id, + c.name as category_name, + SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END) as quantity_sold, + SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END) as revenue, + SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0)) ELSE 0 END) as cost, + SUM(CASE WHEN oi.is_fully_refunded = false THEN (oi.total_price - COALESCE(oi.refund_amount, 0)) - (oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0))) ELSE 0 END) as gross_profit, + CASE + WHEN SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END) > 0 + THEN (SUM(CASE WHEN oi.is_fully_refunded = false THEN (oi.total_price - COALESCE(oi.refund_amount, 0)) - (oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0))) ELSE 0 END) / SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END)) * 100 + ELSE 0 + END as gross_profit_margin, + AVG(CASE WHEN oi.is_fully_refunded = false THEN oi.unit_price ELSE NULL END) as average_price, + AVG(CASE WHEN oi.is_fully_refunded = false THEN oi.unit_cost ELSE NULL END) as average_cost, + AVG(CASE WHEN oi.is_fully_refunded = false THEN oi.unit_price - oi.unit_cost ELSE NULL END) as profit_per_unit + `). + Joins("JOIN orders o ON oi.order_id = o.id"). + Joins("JOIN products p ON oi.product_id = p.id"). + Joins("JOIN categories c ON p.category_id = c.id"). + Where("o.organization_id = ?", organizationID). + Where("o.status = ?", entities.OrderStatusCompleted). + Where("o.payment_status = ?", entities.PaymentStatusCompleted). + Where("o.is_void = false AND o.is_refund = false"). + Where("oi.status != ?", entities.OrderItemStatusCancelled). + Where("o.created_at >= ? AND o.created_at <= ?", dateFrom, dateTo). + Group("p.id, p.name, c.id, c.name"). + Order("p.name ASC"). + Limit(1000) + productQuery = r.resolveOutletID(productQuery, outletID, "o.outlet_id") + if err := productQuery.Scan(&productData).Error; err != nil { + return nil, err + } + type revenueCostResult struct { Revenue float64 Cost float64 @@ -492,6 +619,9 @@ func (r *AnalyticsRepositoryImpl) GetProfitLossAnalytics(ctx context.Context, or } return &entities.ProfitLossAnalytics{ + Summary: summary, + Data: data, + ProductData: productData, TodayRevenue: todayRC.Revenue, TodayCost: todayRC.Cost, MtdRevenue: mtdRC.Revenue, diff --git a/internal/service/analytics_service.go b/internal/service/analytics_service.go index 8511a17..5496ad2 100644 --- a/internal/service/analytics_service.go +++ b/internal/service/analytics_service.go @@ -134,18 +134,6 @@ func (s *AnalyticsServiceImpl) validatePaymentMethodAnalyticsRequest(req *models return fmt.Errorf("date_from cannot be after date_to") } - if req.GroupBy != "" { - validGroupBy := map[string]bool{ - "day": true, - "hour": true, - "week": true, - "month": true, - } - if !validGroupBy[req.GroupBy] { - return fmt.Errorf("invalid group_by value: %s", req.GroupBy) - } - } - return nil } @@ -318,5 +306,17 @@ func (s *AnalyticsServiceImpl) validateProfitLossAnalyticsRequest(req *models.Pr return fmt.Errorf("date_from cannot be after date_to") } + if req.GroupBy != "" { + validGroupBy := map[string]bool{ + "day": true, + "hour": true, + "week": true, + "month": true, + } + if !validGroupBy[req.GroupBy] { + return fmt.Errorf("invalid group_by value: %s", req.GroupBy) + } + } + return nil } diff --git a/internal/service/analytics_service_test.go b/internal/service/analytics_service_test.go index dbd536e..c43419d 100644 --- a/internal/service/analytics_service_test.go +++ b/internal/service/analytics_service_test.go @@ -154,6 +154,16 @@ func TestAnalyticsServiceGetProfitLossAnalyticsValidation(t *testing.T) { }, wantErr: "date_from cannot be after date_to", }, + { + name: "invalid group_by", + req: &models.ProfitLossAnalyticsRequest{ + OrganizationID: uuid.New(), + DateFrom: now, + DateTo: now, + GroupBy: "quarter", + }, + wantErr: "invalid group_by value: quarter", + }, } for _, tt := range tests { @@ -166,3 +176,17 @@ func TestAnalyticsServiceGetProfitLossAnalyticsValidation(t *testing.T) { }) } } + +func TestAnalyticsServiceGetProfitLossAnalyticsAllowsEmptyGroupBy(t *testing.T) { + service := NewAnalyticsServiceImpl(analyticsProcessorStub{}) + now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) + + resp, err := service.GetProfitLossAnalytics(context.Background(), &models.ProfitLossAnalyticsRequest{ + OrganizationID: uuid.New(), + DateFrom: now, + DateTo: now, + }) + + require.NoError(t, err) + require.Nil(t, resp) +} diff --git a/internal/transformer/analytics_transformer.go b/internal/transformer/analytics_transformer.go index 2172cec..f0538c4 100644 --- a/internal/transformer/analytics_transformer.go +++ b/internal/transformer/analytics_transformer.go @@ -450,6 +450,7 @@ func ProfitLossAnalyticsContractToModel(req *contract.ProfitLossAnalyticsRequest OutletID: parseOutletID(req.OutletID), DateFrom: *dateFrom, DateTo: *dateTo, + GroupBy: req.GroupBy, }, nil } @@ -463,6 +464,40 @@ func ProfitLossAnalyticsModelToContract(resp *models.ProfitLossAnalyticsResponse mainSummary[i] = profitLossSummaryRowModelToContract(row) } + data := make([]contract.ProfitLossData, len(resp.Data)) + for i, item := range resp.Data { + data[i] = contract.ProfitLossData{ + Date: item.Date, + Revenue: item.Revenue, + Cost: item.Cost, + GrossProfit: item.GrossProfit, + GrossProfitMargin: item.GrossProfitMargin, + Tax: item.Tax, + Discount: item.Discount, + NetProfit: item.NetProfit, + NetProfitMargin: item.NetProfitMargin, + Orders: item.Orders, + } + } + + productData := make([]contract.ProductProfitData, len(resp.ProductData)) + for i, item := range resp.ProductData { + productData[i] = contract.ProductProfitData{ + ProductID: item.ProductID, + ProductName: item.ProductName, + CategoryID: item.CategoryID, + CategoryName: item.CategoryName, + QuantitySold: item.QuantitySold, + Revenue: item.Revenue, + Cost: item.Cost, + GrossProfit: item.GrossProfit, + GrossProfitMargin: item.GrossProfitMargin, + AveragePrice: item.AveragePrice, + AverageCost: item.AverageCost, + ProfitPerUnit: item.ProfitPerUnit, + } + } + opsItems := make([]contract.OperationalExpenseItem, len(resp.OperationalExpenses)) for i, item := range resp.OperationalExpenses { opsItems[i] = contract.OperationalExpenseItem{ @@ -472,10 +507,26 @@ func ProfitLossAnalyticsModelToContract(resp *models.ProfitLossAnalyticsResponse } return &contract.ProfitLossAnalyticsResponse{ - OrganizationID: resp.OrganizationID, - OutletID: resp.OutletID, - DateFrom: resp.DateFrom, - DateTo: resp.DateTo, + OrganizationID: resp.OrganizationID, + OutletID: resp.OutletID, + DateFrom: resp.DateFrom, + DateTo: resp.DateTo, + GroupBy: resp.GroupBy, + Summary: contract.ProfitLossSummary{ + TotalRevenue: resp.Summary.TotalRevenue, + TotalCost: resp.Summary.TotalCost, + GrossProfit: resp.Summary.GrossProfit, + GrossProfitMargin: resp.Summary.GrossProfitMargin, + TotalTax: resp.Summary.TotalTax, + TotalDiscount: resp.Summary.TotalDiscount, + NetProfit: resp.Summary.NetProfit, + NetProfitMargin: resp.Summary.NetProfitMargin, + TotalOrders: resp.Summary.TotalOrders, + AverageProfit: resp.Summary.AverageProfit, + ProfitabilityRatio: resp.Summary.ProfitabilityRatio, + }, + Data: data, + ProductData: productData, MainSummary: mainSummary, OperationalExpenses: opsItems, OperationalExpensesTotal: resp.OperationalExpensesTotal, diff --git a/internal/transformer/analytics_transformer_test.go b/internal/transformer/analytics_transformer_test.go index efbb87e..2a7bc6c 100644 --- a/internal/transformer/analytics_transformer_test.go +++ b/internal/transformer/analytics_transformer_test.go @@ -84,12 +84,14 @@ func TestProfitLossAnalyticsContractToModelParsesDateRange(t *testing.T) { OutletID: &outletID, DateFrom: "01-05-2026", DateTo: "29-05-2026", + GroupBy: "week", }) require.NoError(t, err) require.Equal(t, orgID, result.OrganizationID) require.NotNil(t, result.OutletID) require.Equal(t, outletID, result.OutletID.String()) + require.Equal(t, "week", result.GroupBy) location, err := time.LoadLocation("Asia/Jakarta") require.NoError(t, err) @@ -100,14 +102,53 @@ func TestProfitLossAnalyticsContractToModelParsesDateRange(t *testing.T) { func TestProfitLossAnalyticsModelToContractCopiesDateRange(t *testing.T) { dateFrom := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) dateTo := time.Date(2026, 5, 29, 23, 59, 59, int(time.Second-time.Nanosecond), time.UTC) + productID := uuid.New() + categoryID := uuid.New() result := ProfitLossAnalyticsModelToContract(&models.ProfitLossAnalyticsResponse{ OrganizationID: uuid.New(), DateFrom: dateFrom, DateTo: dateTo, + GroupBy: "month", + Summary: models.ProfitLossSummary{ + TotalRevenue: 1000, + NetProfit: 500, + }, + Data: []models.ProfitLossData{ + { + Date: dateFrom, + Revenue: 1000, + NetProfit: 500, + }, + }, + ProductData: []models.ProductProfitData{ + { + ProductID: productID, + ProductName: "Nasi", + CategoryID: categoryID, + CategoryName: "Food", + Revenue: 1000, + GrossProfit: 500, + }, + }, + MainSummary: []models.ProfitLossSummaryRow{ + { + ID: "total_omset", + Label: "TOTAL OMSET", + TodayNominal: 1000, + }, + }, }) require.NotNil(t, result) require.Equal(t, dateFrom, result.DateFrom) require.Equal(t, dateTo, result.DateTo) + require.Equal(t, "month", result.GroupBy) + require.Equal(t, float64(1000), result.Summary.TotalRevenue) + require.Len(t, result.Data, 1) + require.Equal(t, float64(500), result.Data[0].NetProfit) + require.Len(t, result.ProductData, 1) + require.Equal(t, productID, result.ProductData[0].ProductID) + require.Len(t, result.MainSummary, 1) + require.Equal(t, "total_omset", result.MainSummary[0].ID) }