feature/expense #13
28
internal/constants/expense.go
Normal file
28
internal/constants/expense.go
Normal file
@ -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
|
||||
}
|
||||
@ -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"`
|
||||
|
||||
@ -10,7 +10,7 @@ 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
|
||||
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"`
|
||||
|
||||
@ -14,7 +14,7 @@ type PurchaseOrder struct {
|
||||
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"`
|
||||
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"`
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -12,7 +12,7 @@ type PurchaseOrder 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"`
|
||||
@ -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"`
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
136
internal/processor/expense_processor_test.go
Normal file
136
internal/processor/expense_processor_test.go
Normal file
@ -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)
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -25,11 +25,15 @@ func CreatePurchaseOrderRequestToModel(req *contract.CreatePurchaseOrderRequest)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse due date
|
||||
dueDate, err := time.Parse("2006-01-02", req.DueDate)
|
||||
// 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{
|
||||
VendorID: req.VendorID,
|
||||
|
||||
43
internal/transformer/purchase_order_transformer_test.go
Normal file
43
internal/transformer/purchase_order_transformer_test.go
Normal file
@ -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`)
|
||||
}
|
||||
158
internal/validator/expense_validator_test.go
Normal file
158
internal/validator/expense_validator_test.go
Normal file
@ -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")
|
||||
}
|
||||
@ -47,19 +47,20 @@ 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
|
||||
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)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Check if due date is after transaction date
|
||||
if dueDate.Before(transactionDate) {
|
||||
return errors.New("due_date must be after transaction_date"), constants.MalformedFieldErrorCode
|
||||
}
|
||||
}
|
||||
|
||||
if req.Reference != nil && len(*req.Reference) > 100 {
|
||||
return errors.New("reference must be at most 100 characters"), constants.MalformedFieldErrorCode
|
||||
@ -100,24 +101,29 @@ 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)
|
||||
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 dueDate.Before(transactionDate) {
|
||||
if transactionDate != nil && dueDate.Before(*transactionDate) {
|
||||
return errors.New("due_date must be after transaction_date"), constants.MalformedFieldErrorCode
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if req.Reference != nil && len(*req.Reference) > 100 {
|
||||
return errors.New("reference must be at most 100 characters"), constants.MalformedFieldErrorCode
|
||||
|
||||
62
internal/validator/purchase_order_validator_test.go
Normal file
62
internal/validator/purchase_order_validator_test.go
Normal file
@ -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")
|
||||
}
|
||||
@ -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;
|
||||
@ -0,0 +1,2 @@
|
||||
ALTER TABLE purchase_orders
|
||||
ALTER COLUMN due_date DROP NOT NULL;
|
||||
Loading…
x
Reference in New Issue
Block a user