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