Compare commits

..

4 Commits

Author SHA1 Message Date
5fa9fc5070 Merge branch 'feature/exclusive-summary-revise' into feature/exclusive-summary 2026-06-17 18:32:11 +07:00
2921631ac3 Revert "Revert purchase order"
This reverts commit 657a201fc0367a9e8b38de99fe90c5e6ba2d6059.
2026-06-17 18:31:10 +07:00
07e8be0521 Add account balance 2026-06-17 17:53:01 +07:00
4b7b225f58 Add exclusive summary endpoint 2026-06-17 17:35:37 +07:00
17 changed files with 355 additions and 428 deletions

View File

@ -19,11 +19,11 @@ type CreatePurchaseOrderRequest struct {
}
type CreatePurchaseOrderItemRequest struct {
IngredientID uuid.UUID `json:"ingredient_id" validate:"required"`
IngredientID *uuid.UUID `json:"ingredient_id,omitempty" validate:"omitempty"`
PurchaseCategoryID uuid.UUID `json:"purchase_category_id" validate:"required"`
Description *string `json:"description,omitempty" validate:"omitempty"`
Quantity float64 `json:"quantity" validate:"required,gt=0"`
UnitID uuid.UUID `json:"unit_id" validate:"required"`
Quantity *float64 `json:"quantity,omitempty" validate:"omitempty,gt=0"`
UnitID *uuid.UUID `json:"unit_id,omitempty" validate:"omitempty"`
Amount float64 `json:"amount" validate:"required,gte=0"`
}
@ -40,12 +40,12 @@ type UpdatePurchaseOrderRequest struct {
}
type UpdatePurchaseOrderItemRequest struct {
ID *uuid.UUID `json:"id,omitempty"` // Ignored. Supplying items replaces all existing PO items.
IngredientID *uuid.UUID `json:"ingredient_id" validate:"required"`
PurchaseCategoryID *uuid.UUID `json:"purchase_category_id" validate:"required"`
ID *uuid.UUID `json:"id,omitempty"` // For existing items
IngredientID *uuid.UUID `json:"ingredient_id,omitempty" validate:"omitempty"`
PurchaseCategoryID *uuid.UUID `json:"purchase_category_id,omitempty" validate:"omitempty"`
Description *string `json:"description,omitempty" validate:"omitempty"`
Quantity *float64 `json:"quantity" validate:"required,gt=0"`
UnitID *uuid.UUID `json:"unit_id" validate:"required"`
Quantity *float64 `json:"quantity,omitempty" validate:"omitempty,gt=0"`
UnitID *uuid.UUID `json:"unit_id,omitempty" validate:"omitempty"`
Amount *float64 `json:"amount,omitempty" validate:"omitempty,gte=0"`
}
@ -70,11 +70,11 @@ type PurchaseOrderResponse struct {
type PurchaseOrderItemResponse struct {
ID uuid.UUID `json:"id"`
PurchaseOrderID uuid.UUID `json:"purchase_order_id"`
IngredientID uuid.UUID `json:"ingredient_id"`
IngredientID *uuid.UUID `json:"ingredient_id"`
PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
Description *string `json:"description"`
Quantity float64 `json:"quantity"`
UnitID uuid.UUID `json:"unit_id"`
Quantity *float64 `json:"quantity"`
UnitID *uuid.UUID `json:"unit_id"`
Amount float64 `json:"amount"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`

View File

@ -216,3 +216,11 @@ type ExclusiveSummaryDailyTransaction struct {
Amount float64
Source string
}
type ExclusiveSummaryBankBalance struct {
Bank string
AccountType string
OpeningBalance float64
ClosingBalance float64
Description *string
}

View File

@ -43,11 +43,11 @@ func (PurchaseOrder) TableName() string {
type PurchaseOrderItem struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
PurchaseOrderID uuid.UUID `gorm:"type:uuid;not null" json:"purchase_order_id" validate:"required"`
IngredientID uuid.UUID `gorm:"type:uuid;not null" json:"ingredient_id" validate:"required"`
IngredientID *uuid.UUID `gorm:"type:uuid" json:"ingredient_id" validate:"omitempty"`
PurchaseCategoryID uuid.UUID `gorm:"type:uuid;not null;index" json:"purchase_category_id" validate:"required"`
Description *string `gorm:"type:text" json:"description" validate:"omitempty"`
Quantity float64 `gorm:"type:decimal(10,3);not null" json:"quantity" validate:"required,gt=0"`
UnitID uuid.UUID `gorm:"type:uuid;not null" json:"unit_id" validate:"required"`
Quantity *float64 `gorm:"type:decimal(10,3)" json:"quantity" validate:"omitempty,gt=0"`
UnitID *uuid.UUID `gorm:"type:uuid" json:"unit_id" validate:"omitempty"`
Amount float64 `gorm:"type:decimal(15,2);not null" json:"amount" validate:"required,gte=0"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`

View File

@ -24,11 +24,11 @@ type PurchaseOrder struct {
type PurchaseOrderItem struct {
ID uuid.UUID `json:"id"`
PurchaseOrderID uuid.UUID `json:"purchase_order_id"`
IngredientID uuid.UUID `json:"ingredient_id"`
IngredientID *uuid.UUID `json:"ingredient_id"`
PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
Description *string `json:"description"`
Quantity float64 `json:"quantity"`
UnitID uuid.UUID `json:"unit_id"`
Quantity *float64 `json:"quantity"`
UnitID *uuid.UUID `json:"unit_id"`
Amount float64 `json:"amount"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
@ -62,11 +62,11 @@ type PurchaseOrderResponse struct {
type PurchaseOrderItemResponse struct {
ID uuid.UUID `json:"id"`
PurchaseOrderID uuid.UUID `json:"purchase_order_id"`
IngredientID uuid.UUID `json:"ingredient_id"`
IngredientID *uuid.UUID `json:"ingredient_id"`
PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
Description *string `json:"description"`
Quantity float64 `json:"quantity"`
UnitID uuid.UUID `json:"unit_id"`
Quantity *float64 `json:"quantity"`
UnitID *uuid.UUID `json:"unit_id"`
Amount float64 `json:"amount"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
@ -96,11 +96,11 @@ type CreatePurchaseOrderRequest struct {
}
type CreatePurchaseOrderItemRequest struct {
IngredientID uuid.UUID `json:"ingredient_id"`
IngredientID *uuid.UUID `json:"ingredient_id,omitempty"`
PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
Description *string `json:"description,omitempty"`
Quantity float64 `json:"quantity"`
UnitID uuid.UUID `json:"unit_id"`
Quantity *float64 `json:"quantity,omitempty"`
UnitID *uuid.UUID `json:"unit_id,omitempty"`
Amount float64 `json:"amount"`
}
@ -117,7 +117,7 @@ type UpdatePurchaseOrderRequest struct {
}
type UpdatePurchaseOrderItemRequest struct {
ID *uuid.UUID `json:"id,omitempty"` // Ignored. Supplying items replaces all existing PO items.
ID *uuid.UUID `json:"id,omitempty"` // For existing items
IngredientID *uuid.UUID `json:"ingredient_id,omitempty"`
PurchaseCategoryID *uuid.UUID `json:"purchase_category_id,omitempty"`
Description *string `json:"description,omitempty"`

View File

@ -9,6 +9,8 @@ import (
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
"github.com/google/uuid"
)
type AnalyticsProcessor interface {
@ -656,14 +658,6 @@ func slugify(s string) string {
}
func (p *AnalyticsProcessorImpl) GetExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error) {
if req.DateFrom.IsZero() {
return nil, fmt.Errorf("date_from is required")
}
if req.DateTo.IsZero() {
return nil, fmt.Errorf("date_to is required")
}
if req.DateFrom.After(req.DateTo) {
return nil, fmt.Errorf("date_from cannot be after date_to")
}
@ -672,10 +666,6 @@ func (p *AnalyticsProcessorImpl) GetExclusiveSummaryPeriod(ctx context.Context,
}
func (p *AnalyticsProcessorImpl) GetExclusiveSummaryMonthly(ctx context.Context, req *models.ExclusiveSummaryMonthlyRequest) (*models.ExclusiveSummaryMonthlyResponse, error) {
if req.Month.IsZero() {
return nil, fmt.Errorf("month is required")
}
monthStart := time.Date(req.Month.Year(), req.Month.Month(), 1, 0, 0, 0, 0, req.Month.Location())
monthEnd := monthStart.AddDate(0, 1, 0).Add(-time.Nanosecond)
@ -689,6 +679,11 @@ func (p *AnalyticsProcessorImpl) GetExclusiveSummaryMonthly(ctx context.Context,
return nil, err
}
bankBalance, err := p.buildExclusiveSummaryBankBalances(ctx, req.OrganizationID, req.OutletID)
if err != nil {
return nil, err
}
buckets := buildExclusiveSummaryMonthlyBuckets(monthStart)
periods := make([]models.ExclusiveSummaryMonthlyPeriod, 0, len(buckets))
for _, bucket := range buckets {
@ -702,7 +697,6 @@ func (p *AnalyticsProcessorImpl) GetExclusiveSummaryMonthly(ctx context.Context,
return nil, err
}
grossMargin := percentage(period.Summary.GrossProfit, period.Summary.Sales)
periods = append(periods, models.ExclusiveSummaryMonthlyPeriod{
Label: bucket.Label,
DateFrom: bucket.DateFrom,
@ -710,7 +704,7 @@ func (p *AnalyticsProcessorImpl) GetExclusiveSummaryMonthly(ctx context.Context,
Sales: period.Summary.Sales,
HPP: period.Summary.HPP,
GrossProfit: period.Summary.GrossProfit,
GrossMargin: grossMargin,
GrossMargin: percentage(period.Summary.GrossProfit, period.Summary.Sales),
})
}
@ -728,13 +722,38 @@ func (p *AnalyticsProcessorImpl) GetExclusiveSummaryMonthly(ctx context.Context,
NetProfitMargin: percentage(fullPeriod.Summary.NetProfit, fullPeriod.Summary.Sales),
},
Periods: periods,
BankBalance: []models.ExclusiveSummaryBankBalance{
{Bank: "BCA"},
{Bank: "BRI"},
},
BankBalance: bankBalance,
}, nil
}
func (p *AnalyticsProcessorImpl) buildExclusiveSummaryBankBalances(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) ([]models.ExclusiveSummaryBankBalance, error) {
balances, err := p.analyticsRepo.GetExclusiveSummaryBankBalances(ctx, organizationID, outletID)
if err != nil {
return nil, fmt.Errorf("failed to get exclusive summary bank balances: %w", err)
}
result := make([]models.ExclusiveSummaryBankBalance, len(balances))
for i, balance := range balances {
openingBalance := balance.OpeningBalance
closingBalance := balance.ClosingBalance
notes := strings.TrimSpace(balance.AccountType)
if balance.Description != nil && strings.TrimSpace(*balance.Description) != "" {
notes = strings.TrimSpace(*balance.Description)
}
result[i] = models.ExclusiveSummaryBankBalance{
Bank: balance.Bank,
OpeningBalance: &openingBalance,
ClosingBalance: &closingBalance,
}
if notes != "" {
result[i].Notes = &notes
}
}
return result, nil
}
func (p *AnalyticsProcessorImpl) buildExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error) {
result, err := p.analyticsRepo.GetExclusiveSummaryAnalytics(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo)
if err != nil {
@ -836,16 +855,16 @@ func exclusiveSummarySalaryBreakdown(transactions []entities.ExclusiveSummaryDai
var salaryOther float64
for _, transaction := range transactions {
if transaction.Source != "expense" || !isExclusiveSummarySalary(transaction.CategoryCode, transaction.CategoryName, transaction.Description) {
if !isExclusiveSummarySalary(transaction.CategoryCode, transaction.CategoryName, transaction.Description) {
continue
}
classification := strings.ToLower(transaction.CategoryCode + " " + transaction.CategoryName + " " + transaction.Description)
switch {
case strings.Contains(classification, "dw"):
salaryDW += transaction.Amount
case strings.Contains(classification, "staff") || strings.Contains(classification, "kary") || strings.Contains(classification, "karyawan"):
salaryStaff += transaction.Amount
case strings.Contains(classification, "dw"):
salaryDW += transaction.Amount
default:
salaryOther += transaction.Amount
}

View File

@ -16,6 +16,7 @@ type analyticsRepositoryStub struct {
purchasingResult *entities.PurchasingAnalytics
profitLossResult *entities.ProfitLossAnalytics
exclusiveResult *entities.ExclusiveSummaryAnalytics
bankBalances []entities.ExclusiveSummaryBankBalance
profitLossGroup string
}
@ -52,6 +53,10 @@ func (s analyticsRepositoryStub) GetExclusiveSummaryAnalytics(context.Context, u
return s.exclusiveResult, nil
}
func (s analyticsRepositoryStub) GetExclusiveSummaryBankBalances(context.Context, uuid.UUID, *uuid.UUID) ([]entities.ExclusiveSummaryBankBalance, error) {
return s.bankBalances, nil
}
type expenseRepositoryStub struct{}
func (expenseRepositoryStub) Create(context.Context, *entities.Expense) error { return nil }
@ -288,12 +293,13 @@ func TestAnalyticsProcessorGetExclusiveSummaryPeriodCalculatesSummaryAndReimburs
{CategoryCode: "hpp_nusantara", CategoryName: "Nusantara", Amount: 19010552},
},
OperationalExpenseBreakdown: []entities.ExclusiveSummaryCategoryTotal{
{CategoryCode: "biaya_gaji", CategoryName: "Gaji", Amount: 51758333},
{CategoryCode: "biaya_gaji_staff", CategoryName: "Gaji Staff", Amount: 48203333},
{CategoryCode: "biaya_gaji_dw", CategoryName: "Gaji DW", Amount: 3555000},
{CategoryCode: "biaya_lain", CategoryName: "Biaya Lain-lain", Amount: 1608605},
},
DailyTransactions: []entities.ExclusiveSummaryDailyTransaction{
{Date: now, CategoryCode: "biaya_gaji", CategoryName: "Gaji", Description: "gaji kary", Amount: 48203333, Source: "expense"},
{Date: now, CategoryCode: "biaya_gaji_dw", CategoryName: "Gaji DW", Description: "gaji karyawan", Amount: 3555000, Source: "expense"},
{Date: now, CategoryCode: "biaya_gaji_staff", CategoryName: "Gaji Staff", Description: "gaji kary", Amount: 48203333, Source: "purchase_order"},
{Date: now, CategoryCode: "biaya_gaji_dw", CategoryName: "Gaji DW", Description: "DW", Amount: 3555000, Source: "purchase_order"},
},
},
}, expenseRepositoryStub{})
@ -334,6 +340,10 @@ func TestAnalyticsProcessorGetExclusiveSummaryMonthlyBuildsCalendarBucketsAndBan
{CategoryCode: "ops", CategoryName: "OPS", Amount: 100},
},
},
bankBalances: []entities.ExclusiveSummaryBankBalance{
{Bank: "Rekening Bank", AccountType: "wallet", OpeningBalance: 1000, ClosingBalance: 2500},
{Bank: "Kas Utama", AccountType: "cash", OpeningBalance: 3000, ClosingBalance: 3500},
},
}, expenseRepositoryStub{})
result, err := processor.GetExclusiveSummaryMonthly(context.Background(), &models.ExclusiveSummaryMonthlyRequest{
@ -350,6 +360,14 @@ func TestAnalyticsProcessorGetExclusiveSummaryMonthlyBuildsCalendarBucketsAndBan
require.Equal(t, "1 - 3 Mei", result.Periods[0].Label)
require.Equal(t, "25 - 31 Mei", result.Periods[4].Label)
require.Len(t, result.BankBalance, 2)
require.Equal(t, "BCA", result.BankBalance[0].Bank)
require.Equal(t, "BRI", result.BankBalance[1].Bank)
require.Equal(t, "Rekening Bank", result.BankBalance[0].Bank)
require.NotNil(t, result.BankBalance[0].OpeningBalance)
require.Equal(t, float64(1000), *result.BankBalance[0].OpeningBalance)
require.NotNil(t, result.BankBalance[0].ClosingBalance)
require.Equal(t, float64(2500), *result.BankBalance[0].ClosingBalance)
require.NotNil(t, result.BankBalance[0].Notes)
require.Equal(t, "wallet", *result.BankBalance[0].Notes)
require.Nil(t, result.BankBalance[0].IncomingMutation)
require.Nil(t, result.BankBalance[0].OutgoingMutation)
require.Equal(t, "Kas Utama", result.BankBalance[1].Bank)
}

View File

@ -67,21 +67,41 @@ func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, or
return nil, fmt.Errorf("purchase order with PO number %s already exists in this organization", req.PONumber)
}
// Purchase orders are raw-material only because they affect ingredient stock.
// Validate categories and inventory fields per item type.
for i, item := range req.Items {
if err := p.validateRawMaterialPurchaseCategory(ctx, item.PurchaseCategoryID, organizationID, i); err != nil {
category, err := p.validatePurchaseCategory(ctx, item.PurchaseCategoryID, organizationID, i)
if err != nil {
return nil, err
}
_, err := p.ingredientRepo.GetByID(ctx, item.IngredientID, organizationID)
switch category.Type {
case entities.PurchaseCategoryTypeRawMaterial:
if item.IngredientID == nil {
return nil, fmt.Errorf("ingredient_id is required for raw_material item %d", i)
}
if item.Quantity == nil {
return nil, fmt.Errorf("quantity is required for raw_material item %d", i)
}
if item.UnitID == nil {
return nil, fmt.Errorf("unit_id is required for raw_material item %d", i)
}
_, err := p.ingredientRepo.GetByID(ctx, *item.IngredientID, organizationID)
if err != nil {
return nil, fmt.Errorf("ingredient not found for item %d: %w", i, err)
}
_, err = p.unitRepo.GetByID(ctx, item.UnitID, organizationID)
_, err = p.unitRepo.GetByID(ctx, *item.UnitID, organizationID)
if err != nil {
return nil, fmt.Errorf("unit not found for item %d: %w", i, err)
}
case entities.PurchaseCategoryTypeExpense:
if item.IngredientID != nil || item.Quantity != nil || item.UnitID != nil {
return nil, fmt.Errorf("ingredient_id, quantity, and unit_id must be empty for expense item %d", i)
}
default:
return nil, fmt.Errorf("purchase category for item %d has unsupported type %s", i, category.Type)
}
}
// Calculate total amount
@ -204,39 +224,49 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id
return nil, fmt.Errorf("purchase_category_id is required for item %d", i)
}
if itemReq.IngredientID == nil {
return nil, fmt.Errorf("ingredient_id is required for raw_material item %d", i)
}
if itemReq.Quantity == nil {
return nil, fmt.Errorf("quantity is required for raw_material item %d", i)
}
if itemReq.UnitID == nil {
return nil, fmt.Errorf("unit_id is required for raw_material item %d", i)
}
ingredientID := *itemReq.IngredientID
ingredientID := itemReq.IngredientID
purchaseCategoryID := *itemReq.PurchaseCategoryID
unitID := *itemReq.UnitID
quantity := *itemReq.Quantity
unitID := itemReq.UnitID
quantity := itemReq.Quantity
amount := 0.0
if itemReq.Amount != nil {
amount = *itemReq.Amount
}
description := itemReq.Description
if err := p.validateRawMaterialPurchaseCategory(ctx, purchaseCategoryID, organizationID, i); err != nil {
category, err := p.validatePurchaseCategory(ctx, purchaseCategoryID, organizationID, i)
if err != nil {
return nil, err
}
_, err := p.ingredientRepo.GetByID(ctx, ingredientID, organizationID)
switch category.Type {
case entities.PurchaseCategoryTypeRawMaterial:
if ingredientID == nil {
return nil, fmt.Errorf("ingredient_id is required for raw_material item %d", i)
}
if quantity == nil {
return nil, fmt.Errorf("quantity is required for raw_material item %d", i)
}
if unitID == nil {
return nil, fmt.Errorf("unit_id is required for raw_material item %d", i)
}
_, err := p.ingredientRepo.GetByID(ctx, *ingredientID, organizationID)
if err != nil {
return nil, fmt.Errorf("ingredient not found: %w", err)
}
_, err = p.unitRepo.GetByID(ctx, unitID, organizationID)
_, err = p.unitRepo.GetByID(ctx, *unitID, organizationID)
if err != nil {
return nil, fmt.Errorf("unit not found: %w", err)
}
case entities.PurchaseCategoryTypeExpense:
if ingredientID != nil || quantity != nil || unitID != nil {
return nil, fmt.Errorf("ingredient_id, quantity, and unit_id must be empty for expense item %d", i)
}
default:
return nil, fmt.Errorf("purchase category for item %d has unsupported type %s", i, category.Type)
}
items[i] = &entities.PurchaseOrderItem{
PurchaseOrderID: poEntity.ID,
@ -377,6 +407,8 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte
return nil, fmt.Errorf("purchase order not found: %w", err)
}
fmt.Println("status:", po.Status)
// Check if status is changing to "received" and current status is not "received"
if status == "received" && po.Status != "received" {
// Get purchase order with items for inventory update
@ -387,19 +419,27 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte
// Update inventory for each item
for _, item := range poWithItems.Items {
if item.PurchaseCategory != nil && item.PurchaseCategory.Type == entities.PurchaseCategoryTypeExpense {
continue
}
if item.IngredientID == nil || item.UnitID == nil || item.Quantity == nil {
return nil, fmt.Errorf("purchase order item %s is missing raw material inventory fields", item.ID)
}
// Get ingredient to find its base unit
ingredient, err := p.ingredientRepo.GetByID(ctx, item.IngredientID, organizationID)
ingredient, err := p.ingredientRepo.GetByID(ctx, *item.IngredientID, organizationID)
if err != nil {
return nil, fmt.Errorf("failed to get ingredient %s: %w", item.IngredientID, err)
return nil, fmt.Errorf("failed to get ingredient %s: %w", *item.IngredientID, err)
}
// Convert quantity to ingredient's base unit if needed
quantityToAdd := item.Quantity
if item.UnitID != ingredient.UnitID {
quantityToAdd := *item.Quantity
if *item.UnitID != ingredient.UnitID {
// Convert from purchase unit to ingredient's base unit
convertedQuantity, err := p.unitConverterRepo.ConvertQuantity(ctx, item.IngredientID, item.UnitID, ingredient.UnitID, organizationID, item.Quantity)
convertedQuantity, err := p.unitConverterRepo.ConvertQuantity(ctx, *item.IngredientID, *item.UnitID, ingredient.UnitID, organizationID, *item.Quantity)
if err != nil {
return nil, fmt.Errorf("failed to convert quantity for ingredient %s from unit %s to %s: %w", item.IngredientID, item.UnitID, ingredient.UnitID, err)
return nil, fmt.Errorf("failed to convert quantity for ingredient %s from unit %s to %s: %w", *item.IngredientID, *item.UnitID, ingredient.UnitID, err)
}
quantityToAdd = convertedQuantity
}
@ -417,7 +457,7 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte
err = p.inventoryMovementService.CreateIngredientMovement(
ctx,
item.IngredientID,
*item.IngredientID,
organizationID,
outletID,
userID,
@ -430,7 +470,7 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte
&item.ID,
)
if err != nil {
return nil, fmt.Errorf("failed to create inventory movement for ingredient %s: %w", item.IngredientID, err)
return nil, fmt.Errorf("failed to create inventory movement for ingredient %s: %w", *item.IngredientID, err)
}
}
}
@ -450,19 +490,19 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte
return mappers.PurchaseOrderEntityToResponse(updatedPO), nil
}
func (p *PurchaseOrderProcessorImpl) validateRawMaterialPurchaseCategory(ctx context.Context, categoryID, organizationID uuid.UUID, itemIndex int) error {
func (p *PurchaseOrderProcessorImpl) validatePurchaseCategory(ctx context.Context, categoryID, organizationID uuid.UUID, itemIndex int) (*entities.PurchaseCategory, error) {
category, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, categoryID, organizationID)
if err != nil {
return fmt.Errorf("purchase category not found for item %d: %w", itemIndex, err)
return nil, fmt.Errorf("purchase category not found for item %d: %w", itemIndex, err)
}
if !category.IsActive {
return fmt.Errorf("purchase category for item %d is inactive", itemIndex)
return nil, fmt.Errorf("purchase category for item %d is inactive", itemIndex)
}
if category.Type != entities.PurchaseCategoryTypeRawMaterial {
return fmt.Errorf("purchase category for item %d must be raw_material", itemIndex)
if category.Type != entities.PurchaseCategoryTypeRawMaterial && category.Type != entities.PurchaseCategoryTypeExpense {
return nil, fmt.Errorf("purchase category for item %d must be raw_material or expense", itemIndex)
}
return nil
return category, nil
}

View File

@ -19,6 +19,7 @@ type AnalyticsRepository interface {
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)
GetExclusiveSummaryAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (*entities.ExclusiveSummaryAnalytics, error)
GetExclusiveSummaryBankBalances(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) ([]entities.ExclusiveSummaryBankBalance, error)
}
type AnalyticsRepositoryImpl struct {
@ -153,11 +154,7 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex
Table("purchase_orders po").
Select(`
COALESCE(SUM(poi.amount), 0) as total_purchases,
COALESCE(SUM(poi.amount), 0) as raw_material_purchases,
0 as expense_purchases,
COUNT(DISTINCT po.id) as total_purchase_orders,
COUNT(DISTINCT po.id) as raw_material_purchase_orders,
0 as expense_count,
COALESCE(SUM(poi.quantity), 0) as total_quantity,
CASE
WHEN COUNT(DISTINCT po.id) > 0
@ -167,13 +164,11 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex
COUNT(DISTINCT i.id) as total_ingredients,
COUNT(DISTINCT po.vendor_id) as total_vendors
`).
Joins("JOIN purchase_order_items poi ON poi.purchase_order_id = po.id").
Joins("JOIN purchase_categories pc ON poi.purchase_category_id = pc.id").
Joins("JOIN ingredients i ON poi.ingredient_id = i.id").
Joins("JOIN units u ON poi.unit_id = u.id").
Joins("LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id").
Joins("LEFT JOIN ingredients i ON poi.ingredient_id = i.id").
Joins("LEFT JOIN units u ON poi.unit_id = u.id").
Where("po.organization_id = ?", organizationID).
Where("pc.type = ?", entities.PurchaseCategoryTypeRawMaterial).
Where("po.status = ?", "received").
Where("po.status != ?", "cancelled").
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo)
summaryQuery = r.applyPurchaseOrderItemOutletFilter(summaryQuery, outletID)
@ -199,22 +194,16 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex
Select(`
`+dateFormat+` as date,
COALESCE(SUM(poi.amount), 0) as purchases,
COALESCE(SUM(poi.amount), 0) as raw_material_purchases,
0 as expense_purchases,
COUNT(DISTINCT po.id) as purchase_orders,
COUNT(DISTINCT po.id) as raw_material_purchase_orders,
0 as expense_count,
COALESCE(SUM(poi.quantity), 0) as quantity,
COUNT(DISTINCT i.id) as ingredients,
COUNT(DISTINCT po.vendor_id) as vendors
`).
Joins("JOIN purchase_order_items poi ON poi.purchase_order_id = po.id").
Joins("JOIN purchase_categories pc ON poi.purchase_category_id = pc.id").
Joins("JOIN ingredients i ON poi.ingredient_id = i.id").
Joins("JOIN units u ON poi.unit_id = u.id").
Joins("LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id").
Joins("LEFT JOIN ingredients i ON poi.ingredient_id = i.id").
Joins("LEFT JOIN units u ON poi.unit_id = u.id").
Where("po.organization_id = ?", organizationID).
Where("pc.type = ?", entities.PurchaseCategoryTypeRawMaterial).
Where("po.status = ?", "received").
Where("po.status != ?", "cancelled").
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo).
Group(dateFormat).
Order(dateFormat)
@ -240,12 +229,10 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex
COUNT(DISTINCT po.id) as purchase_order_count
`).
Joins("JOIN purchase_orders po ON poi.purchase_order_id = po.id").
Joins("JOIN purchase_categories pc ON poi.purchase_category_id = pc.id").
Joins("JOIN ingredients i ON poi.ingredient_id = i.id").
Joins("LEFT JOIN units u ON poi.unit_id = u.id").
Where("po.organization_id = ?", organizationID).
Where("pc.type = ?", entities.PurchaseCategoryTypeRawMaterial).
Where("po.status = ?", "received").
Where("po.status != ?", "cancelled").
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo).
Group("i.id, i.name").
Order("total_cost DESC")
@ -267,13 +254,11 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex
COALESCE(SUM(poi.quantity), 0) as quantity
`).
Joins("JOIN vendors v ON po.vendor_id = v.id").
Joins("JOIN purchase_order_items poi ON poi.purchase_order_id = po.id").
Joins("JOIN purchase_categories pc ON poi.purchase_category_id = pc.id").
Joins("JOIN ingredients i ON poi.ingredient_id = i.id").
Joins("JOIN units u ON poi.unit_id = u.id").
Joins("LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id").
Joins("LEFT JOIN ingredients i ON poi.ingredient_id = i.id").
Joins("LEFT JOIN units u ON poi.unit_id = u.id").
Where("po.organization_id = ?", organizationID).
Where("pc.type = ?", entities.PurchaseCategoryTypeRawMaterial).
Where("po.status = ?", "received").
Where("po.status != ?", "cancelled").
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo).
Group("v.id, v.name").
Order("total_cost DESC")
@ -296,15 +281,7 @@ func (r *AnalyticsRepositoryImpl) applyPurchaseOrderItemOutletFilter(query *gorm
if outletID == nil {
return query
}
return query.Where(`
EXISTS (
SELECT 1
FROM inventory_movements im
WHERE im.purchase_order_item_id = poi.id
AND im.movement_type = ?
AND im.outlet_id = ?
)
`, entities.InventoryMovementTypePurchase, *outletID)
return query.Where("(i.outlet_id = ? OR u.outlet_id = ?)", *outletID, *outletID)
}
func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, limit int) ([]*entities.ProductAnalytics, error) {
@ -315,7 +292,6 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organ
Select(`
p.id as product_id,
p.name as product_name,
p.price as product_price,
c.id as category_id,
c.name as category_name,
c.order as category_order,
@ -374,7 +350,7 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organ
query = r.resolveOutletID(query, outletID, "o.outlet_id")
err := query.
Group("p.id, p.name, p.price, p.cost, c.id, c.name, c.order, mahpp.hpp_per_unit").
Group("p.id, p.name, p.cost, c.id, c.name, c.order, mahpp.hpp_per_unit").
Order("revenue DESC").
Limit(limit).
Scan(&results).Error
@ -669,11 +645,11 @@ func (r *AnalyticsRepositoryImpl) getExpenseByCategory(ctx context.Context, orga
query := r.db.WithContext(ctx).
Table("expense_items ei").
Select(`pc.name as category_name, COALESCE(SUM(ei.amount), 0) as amount`).
Select(`COALESCE(parent_coa.name, 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 purchase_categories pc ON ei.purchase_category_id = pc.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("pc.type = ?", entities.PurchaseCategoryTypeExpense).
Where("e.status = ?", "approved").
Where("e.transaction_date >= ? AND e.transaction_date <= ?", dateFrom, dateTo)
@ -682,8 +658,8 @@ func (r *AnalyticsRepositoryImpl) getExpenseByCategory(ctx context.Context, orga
}
err := query.
Group("pc.id, pc.name, pc.sort_order").
Order("pc.sort_order ASC, pc.name ASC").
Group("COALESCE(parent_coa.name, coa.name, 'Lain-lain')").
Order("COALESCE(parent_coa.name, coa.name, 'Lain-lain')").
Scan(&results).Error
return results, err
@ -694,11 +670,10 @@ func (r *AnalyticsRepositoryImpl) getOperationalExpenseItems(ctx context.Context
query := r.db.WithContext(ctx).
Table("expense_items ei").
Select(`COALESCE(NULLIF(ei.item, ''), ei.description, pc.name) as item, 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 purchase_categories pc ON ei.purchase_category_id = pc.id").
Joins("JOIN chart_of_accounts coa ON ei.chart_of_account_id = coa.id").
Where("e.organization_id = ?", organizationID).
Where("pc.type = ?", entities.PurchaseCategoryTypeExpense).
Where("e.status = ?", "approved").
Where("e.transaction_date >= ? AND e.transaction_date <= ?", dateFrom, dateTo)
@ -707,7 +682,7 @@ func (r *AnalyticsRepositoryImpl) getOperationalExpenseItems(ctx context.Context
}
err := query.
Group("COALESCE(NULLIF(ei.item, ''), ei.description, pc.name)").
Group("COALESCE(NULLIF(ei.item, ''), ei.description, coa.name)").
Order("amount DESC").
Scan(&results).Error
@ -742,7 +717,7 @@ func (r *AnalyticsRepositoryImpl) GetExclusiveSummaryAnalytics(ctx context.Conte
return nil, err
}
operationalExpenseBreakdown, err := r.getExclusiveSummaryOperationalExpenseBreakdown(ctx, organizationID, outletID, dateFrom, dateTo)
operationalExpenseBreakdown, err := r.getExclusiveSummaryOperationalExpenseBreakdown(ctx, organizationID, dateFrom, dateTo)
if err != nil {
return nil, err
}
@ -779,41 +754,14 @@ func (r *AnalyticsRepositoryImpl) getExclusiveSummaryHPPBreakdown(ctx context.Co
`).
Joins("JOIN purchase_orders po ON poi.purchase_order_id = po.id").
Joins("JOIN purchase_categories pc ON poi.purchase_category_id = pc.id").
Joins("JOIN ingredients i ON poi.ingredient_id = i.id").
Joins("LEFT JOIN ingredients i ON poi.ingredient_id = i.id").
Joins("LEFT JOIN units u ON poi.unit_id = u.id").
Where("po.organization_id = ?", organizationID).
Where("pc.type = ?", entities.PurchaseCategoryTypeRawMaterial).
Where("po.status = ?", "received").
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo)
query = r.applyPurchaseOrderItemOutletFilter(query, outletID)
err := query.
Group("pc.id, pc.code, pc.name, pc.sort_order").
Order("pc.sort_order ASC, pc.name ASC").
Scan(&results).Error
return results, err
}
func (r *AnalyticsRepositoryImpl) getExclusiveSummaryOperationalExpenseBreakdown(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]entities.ExclusiveSummaryCategoryTotal, error) {
var results []entities.ExclusiveSummaryCategoryTotal
query := r.db.WithContext(ctx).
Table("expense_items ei").
Select(`
pc.code as category_code,
pc.name as category_name,
COALESCE(SUM(ei.amount), 0) as amount
`).
Joins("JOIN expenses e ON ei.expense_id = e.id").
Joins("JOIN purchase_categories pc ON ei.purchase_category_id = pc.id").
Where("e.organization_id = ?", organizationID).
Where("pc.type = ?", entities.PurchaseCategoryTypeExpense).
Where("e.status = ?", "approved").
Where("e.transaction_date >= ? AND e.transaction_date <= ?", dateFrom, dateTo)
if outletID != nil {
query = query.Where("e.outlet_id = ?", *outletID)
query = query.Where("(i.outlet_id = ? OR u.outlet_id = ?)", *outletID, *outletID)
}
err := query.
@ -824,9 +772,32 @@ func (r *AnalyticsRepositoryImpl) getExclusiveSummaryOperationalExpenseBreakdown
return results, err
}
func (r *AnalyticsRepositoryImpl) getExclusiveSummaryOperationalExpenseBreakdown(ctx context.Context, organizationID uuid.UUID, dateFrom, dateTo time.Time) ([]entities.ExclusiveSummaryCategoryTotal, error) {
var results []entities.ExclusiveSummaryCategoryTotal
err := r.db.WithContext(ctx).
Table("purchase_order_items poi").
Select(`
pc.code as category_code,
pc.name as category_name,
COALESCE(SUM(poi.amount), 0) as amount
`).
Joins("JOIN purchase_orders po ON poi.purchase_order_id = po.id").
Joins("JOIN purchase_categories pc ON poi.purchase_category_id = pc.id").
Where("po.organization_id = ?", organizationID).
Where("pc.type = ?", entities.PurchaseCategoryTypeNonInventory).
Where("po.status = ?", "received").
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo).
Group("pc.id, pc.code, pc.name, pc.sort_order").
Order("pc.sort_order ASC, pc.name ASC").
Scan(&results).Error
return results, err
}
func (r *AnalyticsRepositoryImpl) getExclusiveSummaryDailySummary(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]entities.ExclusiveSummaryDailySummary, error) {
var results []entities.ExclusiveSummaryDailySummary
rawQuery, args := r.exclusiveSummaryTransactionUnionQuery(organizationID, outletID, dateFrom, dateTo)
rawQuery, args := r.exclusiveSummaryPurchaseOrderItemsQuery(organizationID, outletID, dateFrom, dateTo)
err := r.db.WithContext(ctx).Raw(`
SELECT date, COUNT(*) as transaction_count, COALESCE(SUM(amount), 0) as total_cost
@ -840,50 +811,30 @@ func (r *AnalyticsRepositoryImpl) getExclusiveSummaryDailySummary(ctx context.Co
func (r *AnalyticsRepositoryImpl) getExclusiveSummaryDailyTransactions(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]entities.ExclusiveSummaryDailyTransaction, error) {
var results []entities.ExclusiveSummaryDailyTransaction
rawQuery, args := r.exclusiveSummaryTransactionUnionQuery(organizationID, outletID, dateFrom, dateTo)
rawQuery, args := r.exclusiveSummaryPurchaseOrderItemsQuery(organizationID, outletID, dateFrom, dateTo)
err := r.db.WithContext(ctx).Raw(`
SELECT date, category_code, category_name, description, amount, source
FROM (`+rawQuery+`) transactions
ORDER BY date ASC, source ASC, category_name ASC, description ASC
ORDER BY date ASC, category_name ASC, description ASC
`, args...).Scan(&results).Error
return results, err
}
func (r *AnalyticsRepositoryImpl) exclusiveSummaryTransactionUnionQuery(organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (string, []interface{}) {
poOutletFilter := ""
expenseOutletFilter := ""
func (r *AnalyticsRepositoryImpl) exclusiveSummaryPurchaseOrderItemsQuery(organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (string, []interface{}) {
args := []interface{}{
organizationID,
entities.PurchaseCategoryTypeRawMaterial,
entities.PurchaseCategoryTypeNonInventory,
"received",
dateFrom,
dateTo,
}
outletFilter := ""
if outletID != nil {
poOutletFilter = `AND EXISTS (
SELECT 1
FROM inventory_movements im
WHERE im.purchase_order_item_id = poi.id
AND im.movement_type = 'purchase'
AND im.outlet_id = ?
)`
args = append(args, *outletID)
}
args = append(args,
organizationID,
entities.PurchaseCategoryTypeExpense,
"approved",
dateFrom,
dateTo,
)
if outletID != nil {
expenseOutletFilter = "AND e.outlet_id = ?"
args = append(args, *outletID)
outletFilter = "AND (pc.type = ? OR i.outlet_id = ? OR u.outlet_id = ?)"
args = append(args, entities.PurchaseCategoryTypeNonInventory, *outletID, *outletID)
}
query := `
@ -897,32 +848,41 @@ func (r *AnalyticsRepositoryImpl) exclusiveSummaryTransactionUnionQuery(organiza
FROM purchase_order_items poi
JOIN purchase_orders po ON poi.purchase_order_id = po.id
JOIN purchase_categories pc ON poi.purchase_category_id = pc.id
JOIN ingredients i ON poi.ingredient_id = i.id
LEFT JOIN ingredients i ON poi.ingredient_id = i.id
LEFT JOIN units u ON poi.unit_id = u.id
WHERE po.organization_id = ?
AND pc.type = ?
AND pc.type IN (?, ?)
AND po.status = ?
AND po.transaction_date >= ? AND po.transaction_date <= ?
` + poOutletFilter + `
UNION ALL
SELECT
DATE(e.transaction_date) as date,
pc.code as category_code,
pc.name as category_name,
COALESCE(NULLIF(ei.item, ''), NULLIF(ei.description, ''), pc.name) as description,
ei.amount as amount,
'expense' as source
FROM expense_items ei
JOIN expenses e ON ei.expense_id = e.id
JOIN purchase_categories pc ON ei.purchase_category_id = pc.id
WHERE e.organization_id = ?
AND pc.type = ?
AND e.status = ?
AND e.transaction_date >= ? AND e.transaction_date <= ?
` + expenseOutletFilter + `
` + outletFilter + `
`
return query, args
}
func (r *AnalyticsRepositoryImpl) GetExclusiveSummaryBankBalances(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) ([]entities.ExclusiveSummaryBankBalance, error) {
var results []entities.ExclusiveSummaryBankBalance
query := r.db.WithContext(ctx).
Table("accounts").
Select(`
name as bank,
account_type,
opening_balance,
current_balance as closing_balance,
description
`).
Where("organization_id = ?", organizationID).
Where("is_active = ?", true).
Where("account_type IN ?", []entities.AccountType{entities.AccountTypeBank, entities.AccountTypeWallet, entities.AccountTypeCash})
if outletID != nil {
query = query.Where("outlet_id = ? OR outlet_id IS NULL", *outletID)
}
err := query.
Order("CASE account_type WHEN 'bank' THEN 1 WHEN 'wallet' THEN 2 WHEN 'cash' THEN 3 ELSE 4 END, number ASC, name ASC").
Scan(&results).Error
return results, err
}

View File

@ -753,18 +753,18 @@ func parseISODateToJakartaTime(dateStr string, endOfDay bool) (*time.Time, error
return nil, nil
}
date, err := time.Parse("2006-01-02", dateStr)
if err != nil {
return nil, err
}
location, err := time.LoadLocation("Asia/Jakarta")
if err != nil {
return nil, err
}
date, err := time.ParseInLocation("2006-01-02", dateStr, location)
if err != nil {
return nil, err
}
if endOfDay {
result := time.Date(date.Year(), date.Month(), date.Day(), 23, 59, 59, 999999999, location)
result := time.Date(date.Year(), date.Month(), date.Day(), 23, 59, 59, int(time.Second-time.Nanosecond), location)
return &result, nil
}

View File

@ -209,15 +209,50 @@ func TestExclusiveSummaryPeriodContractToModelParsesISODateRange(t *testing.T) {
func TestExclusiveSummaryMonthlyContractToModelParsesMonth(t *testing.T) {
orgID := uuid.New()
outletID := uuid.New().String()
result, err := ExclusiveSummaryMonthlyContractToModel(&contract.ExclusiveSummaryMonthlyRequest{
OrganizationID: orgID,
OutletID: &outletID,
Month: "2026-05",
})
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.Month)
}
func TestExclusiveSummaryPeriodModelToContractCopiesBreakdowns(t *testing.T) {
dateFrom := time.Date(2026, 5, 26, 0, 0, 0, 0, time.UTC)
dateTo := time.Date(2026, 5, 31, 23, 59, 59, int(time.Second-time.Nanosecond), time.UTC)
result := ExclusiveSummaryPeriodModelToContract(&models.ExclusiveSummaryPeriodResponse{
OrganizationID: uuid.New(),
Period: models.ExclusiveSummaryPeriodRange{
DateFrom: dateFrom,
DateTo: dateTo,
},
Summary: models.ExclusiveSummaryPeriodSummary{
Sales: 1000,
HPP: 400,
TotalCost: 550,
NetProfit: 450,
SalaryStaff: 100,
},
HPPBreakdown: []models.ExclusiveSummaryCategoryBreakdown{
{CategoryCode: "hpp", CategoryName: "HPP", Amount: 400, Percentage: 100},
},
})
require.NotNil(t, result)
require.Equal(t, dateFrom, result.Period.DateFrom)
require.Equal(t, dateTo, result.Period.DateTo)
require.Equal(t, float64(1000), result.Summary.Sales)
require.Len(t, result.HPPBreakdown, 1)
require.Equal(t, "hpp", result.HPPBreakdown[0].CategoryCode)
}

View File

@ -12,15 +12,19 @@ import (
)
func TestCreatePurchaseOrderRequestToModelAllowsMissingDueDate(t *testing.T) {
ingredientID := uuid.New()
quantity := 1.0
unitID := uuid.New()
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(),
IngredientID: &ingredientID,
Quantity: &quantity,
UnitID: &unitID,
Amount: 1000,
},
},

View File

@ -181,20 +181,20 @@ func (v *PurchaseOrderValidatorImpl) ValidateListPurchaseOrdersRequest(req *cont
}
func (v *PurchaseOrderValidatorImpl) validatePurchaseOrderItem(item *contract.CreatePurchaseOrderItemRequest, index int) (error, string) {
if item.IngredientID == uuid.Nil {
return errors.New("items[" + strconv.Itoa(index) + "].ingredient_id is required"), constants.MissingFieldErrorCode
}
if item.PurchaseCategoryID == uuid.Nil {
return errors.New("items[" + strconv.Itoa(index) + "].purchase_category_id is required"), constants.MissingFieldErrorCode
}
if item.Quantity <= 0 {
if item.IngredientID != nil && *item.IngredientID == uuid.Nil {
return errors.New("items[" + strconv.Itoa(index) + "].ingredient_id cannot be empty"), constants.MalformedFieldErrorCode
}
if item.Quantity != nil && *item.Quantity <= 0 {
return errors.New("items[" + strconv.Itoa(index) + "].quantity must be greater than 0"), constants.MalformedFieldErrorCode
}
if item.UnitID == uuid.Nil {
return errors.New("items[" + strconv.Itoa(index) + "].unit_id is required"), constants.MissingFieldErrorCode
if item.UnitID != nil && *item.UnitID == uuid.Nil {
return errors.New("items[" + strconv.Itoa(index) + "].unit_id cannot be empty"), constants.MalformedFieldErrorCode
}
if item.Amount < 0 {
@ -209,15 +209,15 @@ func (v *PurchaseOrderValidatorImpl) validateUpdatePurchaseOrderItem(item *contr
return errors.New("items[" + strconv.Itoa(index) + "].purchase_category_id is required"), constants.MissingFieldErrorCode
}
if item.IngredientID == nil || *item.IngredientID == uuid.Nil {
return errors.New("items[" + strconv.Itoa(index) + "].ingredient_id is required"), constants.MissingFieldErrorCode
if item.IngredientID != nil && *item.IngredientID == uuid.Nil {
return errors.New("items[" + strconv.Itoa(index) + "].ingredient_id cannot be empty"), constants.MalformedFieldErrorCode
}
if item.UnitID == nil || *item.UnitID == uuid.Nil {
return errors.New("items[" + strconv.Itoa(index) + "].unit_id is required"), constants.MissingFieldErrorCode
if item.UnitID != nil && *item.UnitID == uuid.Nil {
return errors.New("items[" + strconv.Itoa(index) + "].unit_id cannot be empty"), constants.MalformedFieldErrorCode
}
if item.Quantity == nil || *item.Quantity <= 0 {
if item.Quantity != nil && *item.Quantity <= 0 {
return errors.New("items[" + strconv.Itoa(index) + "].quantity must be greater than 0"), constants.MalformedFieldErrorCode
}

View File

@ -11,34 +11,26 @@ import (
)
func validCreatePurchaseOrderRequest() *contract.CreatePurchaseOrderRequest {
ingredientID := uuid.New()
quantity := 1.0
unitID := uuid.New()
return &contract.CreatePurchaseOrderRequest{
VendorID: uuid.New(),
PONumber: "PO-001",
TransactionDate: "2026-05-29",
Items: []contract.CreatePurchaseOrderItemRequest{
{
IngredientID: uuid.New(),
IngredientID: &ingredientID,
PurchaseCategoryID: uuid.New(),
Quantity: 1,
UnitID: uuid.New(),
Quantity: &quantity,
UnitID: &unitID,
Amount: 1000,
},
},
}
}
func TestPurchaseOrderValidatorCreateRejectsMissingRawMaterialFields(t *testing.T) {
validator := NewPurchaseOrderValidator()
req := validCreatePurchaseOrderRequest()
req.Items[0].IngredientID = uuid.Nil
err, code := validator.ValidateCreatePurchaseOrderRequest(req)
require.Error(t, err)
require.Equal(t, constants.MissingFieldErrorCode, code)
require.Contains(t, err.Error(), "ingredient_id is required")
}
func TestPurchaseOrderValidatorCreateAllowsMissingDueDate(t *testing.T) {
validator := NewPurchaseOrderValidator()
@ -73,31 +65,3 @@ func TestPurchaseOrderValidatorCreateRejectsDueDateBeforeTransactionDate(t *test
require.Equal(t, constants.MalformedFieldErrorCode, code)
require.Contains(t, err.Error(), "due_date must be after transaction_date")
}
func TestPurchaseOrderValidatorUpdateItemsRequireFullReplacementFields(t *testing.T) {
validator := NewPurchaseOrderValidator()
req := &contract.UpdatePurchaseOrderRequest{
Items: []contract.UpdatePurchaseOrderItemRequest{
{
PurchaseCategoryID: ptrUUID(uuid.New()),
Quantity: ptrFloat64(1),
UnitID: ptrUUID(uuid.New()),
Amount: ptrFloat64(1000),
},
},
}
err, code := validator.ValidateUpdatePurchaseOrderRequest(req)
require.Error(t, err)
require.Equal(t, constants.MissingFieldErrorCode, code)
require.Contains(t, err.Error(), "ingredient_id is required")
}
func ptrUUID(id uuid.UUID) *uuid.UUID {
return &id
}
func ptrFloat64(value float64) *float64 {
return &value
}

View File

@ -1,8 +0,0 @@
DROP TRIGGER IF EXISTS trigger_validate_purchase_order_item_raw_material ON purchase_order_items;
DROP FUNCTION IF EXISTS validate_purchase_order_item_raw_material();
ALTER TABLE purchase_order_items
ALTER COLUMN purchase_category_id DROP NOT NULL,
ALTER COLUMN ingredient_id DROP NOT NULL,
ALTER COLUMN quantity DROP NOT NULL,
ALTER COLUMN unit_id DROP NOT NULL;

View File

@ -1,53 +0,0 @@
UPDATE purchase_order_items poi
SET purchase_category_id = pc.id
FROM purchase_orders po
JOIN purchase_categories pc ON pc.organization_id = po.organization_id
AND pc.code = 'bahan_baku'
AND pc.type = 'raw_material'
WHERE poi.purchase_order_id = po.id
AND poi.purchase_category_id IS NULL;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM purchase_order_items poi
LEFT JOIN purchase_categories pc ON pc.id = poi.purchase_category_id
WHERE poi.purchase_category_id IS NULL
OR pc.id IS NULL
OR pc.type <> 'raw_material'
OR poi.ingredient_id IS NULL
OR poi.quantity IS NULL
OR poi.unit_id IS NULL
) THEN
RAISE EXCEPTION 'purchase_order_items contains non-raw-material or incomplete raw-material rows. Move expense rows to expenses and fill ingredient_id, quantity, and unit_id before running this migration.';
END IF;
END $$;
ALTER TABLE purchase_order_items
ALTER COLUMN purchase_category_id SET NOT NULL,
ALTER COLUMN ingredient_id SET NOT NULL,
ALTER COLUMN quantity SET NOT NULL,
ALTER COLUMN unit_id SET NOT NULL;
CREATE OR REPLACE FUNCTION validate_purchase_order_item_raw_material()
RETURNS TRIGGER AS $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM purchase_categories pc
WHERE pc.id = NEW.purchase_category_id
AND pc.type = 'raw_material'
) THEN
RAISE EXCEPTION 'purchase_order_items.purchase_category_id must reference a raw_material purchase category';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trigger_validate_purchase_order_item_raw_material ON purchase_order_items;
CREATE TRIGGER trigger_validate_purchase_order_item_raw_material
BEFORE INSERT OR UPDATE OF purchase_category_id ON purchase_order_items
FOR EACH ROW
EXECUTE FUNCTION validate_purchase_order_item_raw_material();

View File

@ -1,5 +0,0 @@
DROP TRIGGER IF EXISTS trigger_validate_expense_item_expense_category ON expense_items;
DROP FUNCTION IF EXISTS validate_expense_item_expense_category();
ALTER TABLE expense_items
ALTER COLUMN purchase_category_id DROP NOT NULL;

View File

@ -1,55 +0,0 @@
UPDATE expense_items ei
SET purchase_category_id = pc.id
FROM expenses e
JOIN purchase_categories pc ON pc.organization_id = e.organization_id
AND pc.code = 'biaya_lain_lain'
AND pc.type = 'expense'
WHERE ei.expense_id = e.id
AND (
ei.purchase_category_id IS NULL
OR NOT EXISTS (
SELECT 1
FROM purchase_categories current_pc
WHERE current_pc.id = ei.purchase_category_id
AND current_pc.type = 'expense'
)
);
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM expense_items ei
LEFT JOIN purchase_categories pc ON pc.id = ei.purchase_category_id
WHERE ei.purchase_category_id IS NULL
OR pc.id IS NULL
OR pc.type <> 'expense'
) THEN
RAISE EXCEPTION 'expense_items contains missing or non-expense purchase categories. Assign valid expense categories before running this migration.';
END IF;
END $$;
ALTER TABLE expense_items
ALTER COLUMN purchase_category_id SET NOT NULL;
CREATE OR REPLACE FUNCTION validate_expense_item_expense_category()
RETURNS TRIGGER AS $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM purchase_categories pc
WHERE pc.id = NEW.purchase_category_id
AND pc.type = 'expense'
) THEN
RAISE EXCEPTION 'expense_items.purchase_category_id must reference an expense purchase category';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trigger_validate_expense_item_expense_category ON expense_items;
CREATE TRIGGER trigger_validate_expense_item_expense_category
BEFORE INSERT OR UPDATE OF purchase_category_id ON expense_items
FOR EACH ROW
EXECUTE FUNCTION validate_expense_item_expense_category();