From 2921631ac32b8baa1067889007a52ac4c2446c85 Mon Sep 17 00:00:00 2001 From: ryan Date: Wed, 17 Jun 2026 18:31:10 +0700 Subject: [PATCH] Revert "Revert purchase order" This reverts commit 657a201fc0367a9e8b38de99fe90c5e6ba2d6059. --- internal/contract/analytics_contract.go | 111 ------- internal/contract/purchase_order_contract.go | 28 +- internal/entities/analytics.go | 30 -- internal/entities/purchase_order.go | 20 +- internal/handler/analytics_handler.go | 56 ---- internal/models/analytics.go | 111 ------- internal/models/purchase_order.go | 40 +-- internal/processor/analytics_processor.go | 266 ---------------- .../processor/analytics_processor_test.go | 80 ----- .../processor/purchase_order_processor.go | 128 +++++--- internal/repository/analytics_repository.go | 286 ++---------------- internal/router/router.go | 2 - internal/service/analytics_service.go | 68 ----- internal/service/analytics_service_test.go | 8 - internal/transformer/analytics_transformer.go | 226 -------------- .../transformer/analytics_transformer_test.go | 39 --- .../purchase_order_transformer_test.go | 10 +- .../validator/purchase_order_validator.go | 24 +- .../purchase_order_validator_test.go | 50 +-- ...raw_material_purchase_order_items.down.sql | 8 - ...e_raw_material_purchase_order_items.up.sql | 53 ---- ..._expense_items_expense_categories.down.sql | 5 - ...ce_expense_items_expense_categories.up.sql | 55 ---- 23 files changed, 177 insertions(+), 1527 deletions(-) delete mode 100644 migrations/000081_enforce_raw_material_purchase_order_items.down.sql delete mode 100644 migrations/000081_enforce_raw_material_purchase_order_items.up.sql delete mode 100644 migrations/000082_enforce_expense_items_expense_categories.down.sql delete mode 100644 migrations/000082_enforce_expense_items_expense_categories.up.sql diff --git a/internal/contract/analytics_contract.go b/internal/contract/analytics_contract.go index 8e3c19d..786a90d 100644 --- a/internal/contract/analytics_contract.go +++ b/internal/contract/analytics_contract.go @@ -324,114 +324,3 @@ type OperationalExpenseItem struct { Item string `json:"item"` Nominal float64 `json:"nominal"` } - -type ExclusiveSummaryPeriodRequest struct { - OrganizationID uuid.UUID - OutletID *string `form:"outlet_id,omitempty"` - DateFrom string `form:"date_from" validate:"required"` - DateTo string `form:"date_to" validate:"required"` - ExcludeGajiStaffFromReimburse bool `form:"exclude_gaji_staff_from_reimburse"` -} - -type ExclusiveSummaryMonthlyRequest struct { - OrganizationID uuid.UUID - OutletID *string `form:"outlet_id,omitempty"` - Month string `form:"month" validate:"required"` -} - -type ExclusiveSummaryPeriodResponse struct { - OrganizationID uuid.UUID `json:"organization_id"` - OutletID *uuid.UUID `json:"outlet_id,omitempty"` - Period ExclusiveSummaryPeriodRange `json:"period"` - Summary ExclusiveSummaryPeriodSummary `json:"summary"` - Reimburse ExclusiveSummaryReimburse `json:"reimburse"` - HPPBreakdown []ExclusiveSummaryCategoryBreakdown `json:"hpp_breakdown"` - OperationalExpenseBreakdown []ExclusiveSummaryCategoryBreakdown `json:"operational_expense_breakdown"` - DailySummary []ExclusiveSummaryDailySummary `json:"daily_summary"` - DailyTransactions []ExclusiveSummaryDailyTransaction `json:"daily_transactions"` -} - -type ExclusiveSummaryPeriodRange struct { - DateFrom time.Time `json:"date_from"` - DateTo time.Time `json:"date_to"` -} - -type ExclusiveSummaryPeriodSummary struct { - Sales float64 `json:"sales"` - HPP float64 `json:"hpp"` - GrossProfit float64 `json:"gross_profit"` - SalaryTotal float64 `json:"salary_total"` - SalaryDW float64 `json:"salary_dw"` - SalaryStaff float64 `json:"salary_staff"` - SalaryOther float64 `json:"salary_other"` - OtherOperationalExpenses float64 `json:"other_operational_expenses"` - OperationalExpensesTotal float64 `json:"operational_expenses_total"` - TotalCost float64 `json:"total_cost"` - NetProfit float64 `json:"net_profit"` -} - -type ExclusiveSummaryReimburse struct { - TotalCost float64 `json:"total_cost"` - ExcludedSalaryStaff float64 `json:"excluded_salary_staff"` - TotalReimburse float64 `json:"total_reimburse"` -} - -type ExclusiveSummaryCategoryBreakdown struct { - CategoryCode string `json:"category_code"` - CategoryName string `json:"category_name"` - Amount float64 `json:"amount"` - Percentage float64 `json:"percentage"` -} - -type ExclusiveSummaryDailySummary struct { - Date time.Time `json:"date"` - TransactionCount int64 `json:"transaction_count"` - TotalCost float64 `json:"total_cost"` -} - -type ExclusiveSummaryDailyTransaction struct { - Date time.Time `json:"date"` - CategoryCode string `json:"category_code"` - CategoryName string `json:"category_name"` - Description string `json:"description"` - Amount float64 `json:"amount"` - Source string `json:"source"` -} - -type ExclusiveSummaryMonthlyResponse struct { - OrganizationID uuid.UUID `json:"organization_id"` - OutletID *uuid.UUID `json:"outlet_id,omitempty"` - Month string `json:"month"` - Summary ExclusiveSummaryMonthlySummary `json:"summary"` - Periods []ExclusiveSummaryMonthlyPeriod `json:"periods"` - BankBalance []ExclusiveSummaryBankBalance `json:"bank_balance"` -} - -type ExclusiveSummaryMonthlySummary struct { - TotalSales float64 `json:"total_sales"` - HPP float64 `json:"hpp"` - GrossProfit float64 `json:"gross_profit"` - OperationalExpensesTotal float64 `json:"operational_expenses_total"` - TotalCost float64 `json:"total_cost"` - NetProfit float64 `json:"net_profit"` - NetProfitMargin float64 `json:"net_profit_margin"` -} - -type ExclusiveSummaryMonthlyPeriod struct { - Label string `json:"label"` - DateFrom time.Time `json:"date_from"` - DateTo time.Time `json:"date_to"` - Sales float64 `json:"sales"` - HPP float64 `json:"hpp"` - GrossProfit float64 `json:"gross_profit"` - GrossMargin float64 `json:"gross_margin"` -} - -type ExclusiveSummaryBankBalance struct { - Bank string `json:"bank"` - OpeningBalance *float64 `json:"opening_balance"` - IncomingMutation *float64 `json:"incoming_mutation"` - OutgoingMutation *float64 `json:"outgoing_mutation"` - ClosingBalance *float64 `json:"closing_balance"` - Notes *string `json:"notes"` -} diff --git a/internal/contract/purchase_order_contract.go b/internal/contract/purchase_order_contract.go index 5ab9024..907316a 100644 --- a/internal/contract/purchase_order_contract.go +++ b/internal/contract/purchase_order_contract.go @@ -19,12 +19,12 @@ type CreatePurchaseOrderRequest struct { } type CreatePurchaseOrderItemRequest struct { - IngredientID uuid.UUID `json:"ingredient_id" validate:"required"` - 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"` - Amount float64 `json:"amount" validate:"required,gte=0"` + 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,omitempty" validate:"omitempty,gt=0"` + UnitID *uuid.UUID `json:"unit_id,omitempty" validate:"omitempty"` + Amount float64 `json:"amount" validate:"required,gte=0"` } type UpdatePurchaseOrderRequest struct { @@ -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"` diff --git a/internal/entities/analytics.go b/internal/entities/analytics.go index 4e4e175..66b37d0 100644 --- a/internal/entities/analytics.go +++ b/internal/entities/analytics.go @@ -186,33 +186,3 @@ type OperationalExpenseItem struct { Item string Amount float64 } - -type ExclusiveSummaryAnalytics struct { - SalesTotal float64 - SalesCount int64 - HPPBreakdown []ExclusiveSummaryCategoryTotal - OperationalExpenseBreakdown []ExclusiveSummaryCategoryTotal - DailySummary []ExclusiveSummaryDailySummary - DailyTransactions []ExclusiveSummaryDailyTransaction -} - -type ExclusiveSummaryCategoryTotal struct { - CategoryCode string - CategoryName string - Amount float64 -} - -type ExclusiveSummaryDailySummary struct { - Date time.Time - TransactionCount int64 - TotalCost float64 -} - -type ExclusiveSummaryDailyTransaction struct { - Date time.Time - CategoryCode string - CategoryName string - Description string - Amount float64 - Source string -} diff --git a/internal/entities/purchase_order.go b/internal/entities/purchase_order.go index 3a455db..b98006a 100644 --- a/internal/entities/purchase_order.go +++ b/internal/entities/purchase_order.go @@ -41,16 +41,16 @@ 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"` - 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"` - 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"` + 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" 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)" 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"` PurchaseOrder *PurchaseOrder `gorm:"foreignKey:PurchaseOrderID" json:"purchase_order,omitempty"` Ingredient *Ingredient `gorm:"foreignKey:IngredientID" json:"ingredient,omitempty"` diff --git a/internal/handler/analytics_handler.go b/internal/handler/analytics_handler.go index a1db87d..a8945e0 100644 --- a/internal/handler/analytics_handler.go +++ b/internal/handler/analytics_handler.go @@ -210,59 +210,3 @@ func (h *AnalyticsHandler) GetProfitLossAnalytics(c *gin.Context) { contractResp := transformer.ProfitLossAnalyticsModelToContract(response) util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetProfitLossAnalytics") } - -func (h *AnalyticsHandler) GetExclusiveSummaryPeriod(c *gin.Context) { - ctx := c.Request.Context() - contextInfo := appcontext.FromGinContext(ctx) - - var req contract.ExclusiveSummaryPeriodRequest - if err := c.ShouldBindQuery(&req); err != nil { - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetExclusiveSummaryPeriod", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryPeriod") - return - } - - req.OrganizationID = contextInfo.OrganizationID - req.OutletID = h.resolveOutletID(c, contextInfo.OutletID) - modelReq, err := transformer.ExclusiveSummaryPeriodContractToModel(&req) - if err != nil { - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetExclusiveSummaryPeriod", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryPeriod") - return - } - - response, err := h.analyticsService.GetExclusiveSummaryPeriod(ctx, modelReq) - if err != nil { - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "AnalyticsHandler::GetExclusiveSummaryPeriod", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryPeriod") - return - } - - contractResp := transformer.ExclusiveSummaryPeriodModelToContract(response) - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetExclusiveSummaryPeriod") -} - -func (h *AnalyticsHandler) GetExclusiveSummaryMonthly(c *gin.Context) { - ctx := c.Request.Context() - contextInfo := appcontext.FromGinContext(ctx) - - var req contract.ExclusiveSummaryMonthlyRequest - if err := c.ShouldBindQuery(&req); err != nil { - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetExclusiveSummaryMonthly", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryMonthly") - return - } - - req.OrganizationID = contextInfo.OrganizationID - req.OutletID = h.resolveOutletID(c, contextInfo.OutletID) - modelReq, err := transformer.ExclusiveSummaryMonthlyContractToModel(&req) - if err != nil { - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetExclusiveSummaryMonthly", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryMonthly") - return - } - - response, err := h.analyticsService.GetExclusiveSummaryMonthly(ctx, modelReq) - if err != nil { - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "AnalyticsHandler::GetExclusiveSummaryMonthly", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryMonthly") - return - } - - contractResp := transformer.ExclusiveSummaryMonthlyModelToContract(response) - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetExclusiveSummaryMonthly") -} diff --git a/internal/models/analytics.go b/internal/models/analytics.go index 170c048..e72e3e0 100644 --- a/internal/models/analytics.go +++ b/internal/models/analytics.go @@ -334,114 +334,3 @@ type OperationalExpenseItem struct { Item string `json:"item"` Nominal float64 `json:"nominal"` } - -type ExclusiveSummaryPeriodRequest struct { - OrganizationID uuid.UUID `validate:"required"` - OutletID *uuid.UUID `validate:"omitempty"` - DateFrom time.Time `validate:"required"` - DateTo time.Time `validate:"required"` - ExcludeGajiStaffFromReimburse bool `validate:"omitempty"` -} - -type ExclusiveSummaryMonthlyRequest struct { - OrganizationID uuid.UUID `validate:"required"` - OutletID *uuid.UUID `validate:"omitempty"` - Month time.Time `validate:"required"` -} - -type ExclusiveSummaryPeriodResponse struct { - OrganizationID uuid.UUID `json:"organization_id"` - OutletID *uuid.UUID `json:"outlet_id,omitempty"` - Period ExclusiveSummaryPeriodRange `json:"period"` - Summary ExclusiveSummaryPeriodSummary `json:"summary"` - Reimburse ExclusiveSummaryReimburse `json:"reimburse"` - HPPBreakdown []ExclusiveSummaryCategoryBreakdown `json:"hpp_breakdown"` - OperationalExpenseBreakdown []ExclusiveSummaryCategoryBreakdown `json:"operational_expense_breakdown"` - DailySummary []ExclusiveSummaryDailySummary `json:"daily_summary"` - DailyTransactions []ExclusiveSummaryDailyTransaction `json:"daily_transactions"` -} - -type ExclusiveSummaryPeriodRange struct { - DateFrom time.Time `json:"date_from"` - DateTo time.Time `json:"date_to"` -} - -type ExclusiveSummaryPeriodSummary struct { - Sales float64 `json:"sales"` - HPP float64 `json:"hpp"` - GrossProfit float64 `json:"gross_profit"` - SalaryTotal float64 `json:"salary_total"` - SalaryDW float64 `json:"salary_dw"` - SalaryStaff float64 `json:"salary_staff"` - SalaryOther float64 `json:"salary_other"` - OtherOperationalExpenses float64 `json:"other_operational_expenses"` - OperationalExpensesTotal float64 `json:"operational_expenses_total"` - TotalCost float64 `json:"total_cost"` - NetProfit float64 `json:"net_profit"` -} - -type ExclusiveSummaryReimburse struct { - TotalCost float64 `json:"total_cost"` - ExcludedSalaryStaff float64 `json:"excluded_salary_staff"` - TotalReimburse float64 `json:"total_reimburse"` -} - -type ExclusiveSummaryCategoryBreakdown struct { - CategoryCode string `json:"category_code"` - CategoryName string `json:"category_name"` - Amount float64 `json:"amount"` - Percentage float64 `json:"percentage"` -} - -type ExclusiveSummaryDailySummary struct { - Date time.Time `json:"date"` - TransactionCount int64 `json:"transaction_count"` - TotalCost float64 `json:"total_cost"` -} - -type ExclusiveSummaryDailyTransaction struct { - Date time.Time `json:"date"` - CategoryCode string `json:"category_code"` - CategoryName string `json:"category_name"` - Description string `json:"description"` - Amount float64 `json:"amount"` - Source string `json:"source"` -} - -type ExclusiveSummaryMonthlyResponse struct { - OrganizationID uuid.UUID `json:"organization_id"` - OutletID *uuid.UUID `json:"outlet_id,omitempty"` - Month string `json:"month"` - Summary ExclusiveSummaryMonthlySummary `json:"summary"` - Periods []ExclusiveSummaryMonthlyPeriod `json:"periods"` - BankBalance []ExclusiveSummaryBankBalance `json:"bank_balance"` -} - -type ExclusiveSummaryMonthlySummary struct { - TotalSales float64 `json:"total_sales"` - HPP float64 `json:"hpp"` - GrossProfit float64 `json:"gross_profit"` - OperationalExpensesTotal float64 `json:"operational_expenses_total"` - TotalCost float64 `json:"total_cost"` - NetProfit float64 `json:"net_profit"` - NetProfitMargin float64 `json:"net_profit_margin"` -} - -type ExclusiveSummaryMonthlyPeriod struct { - Label string `json:"label"` - DateFrom time.Time `json:"date_from"` - DateTo time.Time `json:"date_to"` - Sales float64 `json:"sales"` - HPP float64 `json:"hpp"` - GrossProfit float64 `json:"gross_profit"` - GrossMargin float64 `json:"gross_margin"` -} - -type ExclusiveSummaryBankBalance struct { - Bank string `json:"bank"` - OpeningBalance *float64 `json:"opening_balance"` - IncomingMutation *float64 `json:"incoming_mutation"` - OutgoingMutation *float64 `json:"outgoing_mutation"` - ClosingBalance *float64 `json:"closing_balance"` - Notes *string `json:"notes"` -} diff --git a/internal/models/purchase_order.go b/internal/models/purchase_order.go index 452536c..562271e 100644 --- a/internal/models/purchase_order.go +++ b/internal/models/purchase_order.go @@ -22,16 +22,16 @@ type PurchaseOrder struct { } type PurchaseOrderItem struct { - ID uuid.UUID `json:"id"` - PurchaseOrderID uuid.UUID `json:"purchase_order_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"` - Amount float64 `json:"amount"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uuid.UUID `json:"id"` + PurchaseOrderID uuid.UUID `json:"purchase_order_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"` + Amount float64 `json:"amount"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type PurchaseOrderAttachment struct { @@ -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,12 +96,12 @@ type CreatePurchaseOrderRequest struct { } type CreatePurchaseOrderItemRequest struct { - IngredientID uuid.UUID `json:"ingredient_id"` - PurchaseCategoryID uuid.UUID `json:"purchase_category_id"` - Description *string `json:"description,omitempty"` - Quantity float64 `json:"quantity"` - UnitID uuid.UUID `json:"unit_id"` - Amount float64 `json:"amount"` + IngredientID *uuid.UUID `json:"ingredient_id,omitempty"` + PurchaseCategoryID uuid.UUID `json:"purchase_category_id"` + Description *string `json:"description,omitempty"` + Quantity *float64 `json:"quantity,omitempty"` + UnitID *uuid.UUID `json:"unit_id,omitempty"` + Amount float64 `json:"amount"` } type UpdatePurchaseOrderRequest struct { @@ -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"` diff --git a/internal/processor/analytics_processor.go b/internal/processor/analytics_processor.go index e4894e3..895f3b0 100644 --- a/internal/processor/analytics_processor.go +++ b/internal/processor/analytics_processor.go @@ -6,7 +6,6 @@ import ( "strings" "time" - "apskel-pos-be/internal/entities" "apskel-pos-be/internal/models" "apskel-pos-be/internal/repository" ) @@ -19,8 +18,6 @@ type AnalyticsProcessor interface { GetProductAnalyticsPerCategory(ctx context.Context, req *models.ProductAnalyticsPerCategoryRequest) (*models.ProductAnalyticsPerCategoryResponse, error) GetDashboardAnalytics(ctx context.Context, req *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error) GetProfitLossAnalytics(ctx context.Context, req *models.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsResponse, error) - GetExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error) - GetExclusiveSummaryMonthly(ctx context.Context, req *models.ExclusiveSummaryMonthlyRequest) (*models.ExclusiveSummaryMonthlyResponse, error) } type AnalyticsProcessorImpl struct { @@ -654,266 +651,3 @@ func slugify(s string) string { } return string(result) } - -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") - } - - return p.buildExclusiveSummaryPeriod(ctx, req) -} - -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) - - fullPeriod, err := p.buildExclusiveSummaryPeriod(ctx, &models.ExclusiveSummaryPeriodRequest{ - OrganizationID: req.OrganizationID, - OutletID: req.OutletID, - DateFrom: monthStart, - DateTo: monthEnd, - }) - if err != nil { - return nil, err - } - - buckets := buildExclusiveSummaryMonthlyBuckets(monthStart) - periods := make([]models.ExclusiveSummaryMonthlyPeriod, 0, len(buckets)) - for _, bucket := range buckets { - period, err := p.buildExclusiveSummaryPeriod(ctx, &models.ExclusiveSummaryPeriodRequest{ - OrganizationID: req.OrganizationID, - OutletID: req.OutletID, - DateFrom: bucket.DateFrom, - DateTo: bucket.DateTo, - }) - if err != nil { - return nil, err - } - - grossMargin := percentage(period.Summary.GrossProfit, period.Summary.Sales) - periods = append(periods, models.ExclusiveSummaryMonthlyPeriod{ - Label: bucket.Label, - DateFrom: bucket.DateFrom, - DateTo: bucket.DateTo, - Sales: period.Summary.Sales, - HPP: period.Summary.HPP, - GrossProfit: period.Summary.GrossProfit, - GrossMargin: grossMargin, - }) - } - - return &models.ExclusiveSummaryMonthlyResponse{ - OrganizationID: req.OrganizationID, - OutletID: req.OutletID, - Month: monthStart.Format("2006-01"), - Summary: models.ExclusiveSummaryMonthlySummary{ - TotalSales: fullPeriod.Summary.Sales, - HPP: fullPeriod.Summary.HPP, - GrossProfit: fullPeriod.Summary.GrossProfit, - OperationalExpensesTotal: fullPeriod.Summary.OperationalExpensesTotal, - TotalCost: fullPeriod.Summary.TotalCost, - NetProfit: fullPeriod.Summary.NetProfit, - NetProfitMargin: percentage(fullPeriod.Summary.NetProfit, fullPeriod.Summary.Sales), - }, - Periods: periods, - BankBalance: []models.ExclusiveSummaryBankBalance{ - {Bank: "BCA"}, - {Bank: "BRI"}, - }, - }, 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 { - return nil, fmt.Errorf("failed to get exclusive summary analytics: %w", err) - } - - hppBreakdown, hppTotal := exclusiveSummaryCategoryBreakdown(result.HPPBreakdown) - operationalBreakdown, operationalTotal := exclusiveSummaryCategoryBreakdown(result.OperationalExpenseBreakdown) - salaryDW, salaryStaff, salaryOther := exclusiveSummarySalaryBreakdown(result.DailyTransactions) - salaryTotal := salaryDW + salaryStaff + salaryOther - otherOperationalExpenses := operationalTotal - salaryTotal - if otherOperationalExpenses < 0 { - otherOperationalExpenses = 0 - } - - grossProfit := result.SalesTotal - hppTotal - totalCost := hppTotal + operationalTotal - netProfit := result.SalesTotal - totalCost - excludedSalaryStaff := 0.0 - if req.ExcludeGajiStaffFromReimburse { - excludedSalaryStaff = salaryStaff - } - - dailySummary := make([]models.ExclusiveSummaryDailySummary, len(result.DailySummary)) - for i, item := range result.DailySummary { - dailySummary[i] = models.ExclusiveSummaryDailySummary{ - Date: item.Date, - TransactionCount: item.TransactionCount, - TotalCost: item.TotalCost, - } - } - - dailyTransactions := make([]models.ExclusiveSummaryDailyTransaction, len(result.DailyTransactions)) - for i, item := range result.DailyTransactions { - dailyTransactions[i] = models.ExclusiveSummaryDailyTransaction{ - Date: item.Date, - CategoryCode: item.CategoryCode, - CategoryName: item.CategoryName, - Description: item.Description, - Amount: item.Amount, - Source: item.Source, - } - } - - return &models.ExclusiveSummaryPeriodResponse{ - OrganizationID: req.OrganizationID, - OutletID: req.OutletID, - Period: models.ExclusiveSummaryPeriodRange{ - DateFrom: req.DateFrom, - DateTo: req.DateTo, - }, - Summary: models.ExclusiveSummaryPeriodSummary{ - Sales: result.SalesTotal, - HPP: hppTotal, - GrossProfit: grossProfit, - SalaryTotal: salaryTotal, - SalaryDW: salaryDW, - SalaryStaff: salaryStaff, - SalaryOther: salaryOther, - OtherOperationalExpenses: otherOperationalExpenses, - OperationalExpensesTotal: operationalTotal, - TotalCost: totalCost, - NetProfit: netProfit, - }, - Reimburse: models.ExclusiveSummaryReimburse{ - TotalCost: totalCost, - ExcludedSalaryStaff: excludedSalaryStaff, - TotalReimburse: totalCost - excludedSalaryStaff, - }, - HPPBreakdown: hppBreakdown, - OperationalExpenseBreakdown: operationalBreakdown, - DailySummary: dailySummary, - DailyTransactions: dailyTransactions, - }, nil -} - -func exclusiveSummaryCategoryBreakdown(items []entities.ExclusiveSummaryCategoryTotal) ([]models.ExclusiveSummaryCategoryBreakdown, float64) { - var total float64 - for _, item := range items { - total += item.Amount - } - - breakdown := make([]models.ExclusiveSummaryCategoryBreakdown, len(items)) - for i, item := range items { - breakdown[i] = models.ExclusiveSummaryCategoryBreakdown{ - CategoryCode: item.CategoryCode, - CategoryName: item.CategoryName, - Amount: item.Amount, - Percentage: percentage(item.Amount, total), - } - } - - return breakdown, total -} - -func exclusiveSummarySalaryBreakdown(transactions []entities.ExclusiveSummaryDailyTransaction) (float64, float64, float64) { - var salaryDW float64 - var salaryStaff float64 - var salaryOther float64 - - for _, transaction := range transactions { - if transaction.Source != "expense" || !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 - default: - salaryOther += transaction.Amount - } - } - - return salaryDW, salaryStaff, salaryOther -} - -func isExclusiveSummarySalary(parts ...string) bool { - text := strings.ToLower(strings.Join(parts, " ")) - return strings.Contains(text, "gaji") || strings.Contains(text, "salary") -} - -func percentage(numerator, denominator float64) float64 { - if denominator == 0 { - return 0 - } - return (numerator / denominator) * 100 -} - -type exclusiveSummaryMonthlyBucket struct { - Label string - DateFrom time.Time - DateTo time.Time -} - -func buildExclusiveSummaryMonthlyBuckets(monthStart time.Time) []exclusiveSummaryMonthlyBucket { - monthEnd := monthStart.AddDate(0, 1, 0).Add(-time.Nanosecond) - buckets := make([]exclusiveSummaryMonthlyBucket, 0, 6) - currentStart := monthStart - - for !currentStart.After(monthEnd) { - currentEnd := currentStart - for currentEnd.Weekday() != time.Sunday && currentEnd.Day() < monthEnd.Day() { - currentEnd = currentEnd.AddDate(0, 0, 1) - } - - bucketEnd := time.Date(currentEnd.Year(), currentEnd.Month(), currentEnd.Day(), 23, 59, 59, int(time.Second-time.Nanosecond), currentEnd.Location()) - if bucketEnd.After(monthEnd) { - bucketEnd = monthEnd - } - - buckets = append(buckets, exclusiveSummaryMonthlyBucket{ - Label: fmt.Sprintf("%d - %d %s", currentStart.Day(), bucketEnd.Day(), indonesianMonthName(currentStart.Month())), - DateFrom: currentStart, - DateTo: bucketEnd, - }) - - currentStart = time.Date(bucketEnd.Year(), bucketEnd.Month(), bucketEnd.Day(), 0, 0, 0, 0, bucketEnd.Location()).AddDate(0, 0, 1) - } - - return buckets -} - -func indonesianMonthName(month time.Month) string { - names := map[time.Month]string{ - time.January: "Januari", - time.February: "Februari", - time.March: "Maret", - time.April: "April", - time.May: "Mei", - time.June: "Juni", - time.July: "Juli", - time.August: "Agustus", - time.September: "September", - time.October: "Oktober", - time.November: "November", - time.December: "Desember", - } - return names[month] -} diff --git a/internal/processor/analytics_processor_test.go b/internal/processor/analytics_processor_test.go index 11373de..b50c462 100644 --- a/internal/processor/analytics_processor_test.go +++ b/internal/processor/analytics_processor_test.go @@ -15,7 +15,6 @@ import ( type analyticsRepositoryStub struct { purchasingResult *entities.PurchasingAnalytics profitLossResult *entities.ProfitLossAnalytics - exclusiveResult *entities.ExclusiveSummaryAnalytics profitLossGroup string } @@ -48,10 +47,6 @@ func (s analyticsRepositoryStub) GetProfitLossAnalytics(_ context.Context, _ uui return s.profitLossResult, nil } -func (s analyticsRepositoryStub) GetExclusiveSummaryAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) (*entities.ExclusiveSummaryAnalytics, error) { - return s.exclusiveResult, nil -} - type expenseRepositoryStub struct{} func (expenseRepositoryStub) Create(context.Context, *entities.Expense) error { return nil } @@ -278,78 +273,3 @@ func TestAnalyticsProcessorGetProfitLossAnalyticsDynamicExpenseCategories(t *tes require.Equal(t, float64(7400), result.MainSummary[6].MtdNominal) require.True(t, result.MainSummary[6].IsBold) } - -func TestAnalyticsProcessorGetExclusiveSummaryPeriodCalculatesSummaryAndReimburse(t *testing.T) { - now := time.Date(2026, 5, 26, 0, 0, 0, 0, time.UTC) - processor := NewAnalyticsProcessorImpl(analyticsRepositoryStub{ - exclusiveResult: &entities.ExclusiveSummaryAnalytics{ - SalesTotal: 35619000, - HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{ - {CategoryCode: "hpp_nusantara", CategoryName: "Nusantara", Amount: 19010552}, - }, - OperationalExpenseBreakdown: []entities.ExclusiveSummaryCategoryTotal{ - {CategoryCode: "biaya_gaji", CategoryName: "Gaji", Amount: 51758333}, - {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"}, - }, - }, - }, expenseRepositoryStub{}) - - result, err := processor.GetExclusiveSummaryPeriod(context.Background(), &models.ExclusiveSummaryPeriodRequest{ - OrganizationID: uuid.New(), - DateFrom: now, - DateTo: now, - ExcludeGajiStaffFromReimburse: true, - }) - - require.NoError(t, err) - require.NotNil(t, result) - require.Equal(t, float64(35619000), result.Summary.Sales) - require.Equal(t, float64(19010552), result.Summary.HPP) - require.Equal(t, float64(16608448), result.Summary.GrossProfit) - require.Equal(t, float64(51758333), result.Summary.SalaryTotal) - require.Equal(t, float64(3555000), result.Summary.SalaryDW) - require.Equal(t, float64(48203333), result.Summary.SalaryStaff) - require.Equal(t, float64(53366938), result.Summary.OperationalExpensesTotal) - require.Equal(t, float64(72377490), result.Summary.TotalCost) - require.Equal(t, float64(-36758490), result.Summary.NetProfit) - require.Equal(t, float64(48203333), result.Reimburse.ExcludedSalaryStaff) - require.Equal(t, float64(24174157), result.Reimburse.TotalReimburse) -} - -func TestAnalyticsProcessorGetExclusiveSummaryMonthlyBuildsCalendarBucketsAndBankTemplate(t *testing.T) { - location, err := time.LoadLocation("Asia/Jakarta") - require.NoError(t, err) - month := time.Date(2026, 5, 1, 0, 0, 0, 0, location) - processor := NewAnalyticsProcessorImpl(analyticsRepositoryStub{ - exclusiveResult: &entities.ExclusiveSummaryAnalytics{ - SalesTotal: 1000, - HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{ - {CategoryCode: "hpp", CategoryName: "HPP", Amount: 400}, - }, - OperationalExpenseBreakdown: []entities.ExclusiveSummaryCategoryTotal{ - {CategoryCode: "ops", CategoryName: "OPS", Amount: 100}, - }, - }, - }, expenseRepositoryStub{}) - - result, err := processor.GetExclusiveSummaryMonthly(context.Background(), &models.ExclusiveSummaryMonthlyRequest{ - OrganizationID: uuid.New(), - Month: month, - }) - - require.NoError(t, err) - require.NotNil(t, result) - require.Equal(t, "2026-05", result.Month) - require.Equal(t, float64(1000), result.Summary.TotalSales) - require.Equal(t, float64(500), result.Summary.NetProfit) - require.Len(t, result.Periods, 5) - require.Equal(t, "1 - 3 Mei", result.Periods[0].Label) - require.Equal(t, "25 - 31 Mei", result.Periods[4].Label) - require.Len(t, result.BankBalance, 2) - require.Equal(t, "BCA", result.BankBalance[0].Bank) - require.Equal(t, "BRI", result.BankBalance[1].Bank) -} diff --git a/internal/processor/purchase_order_processor.go b/internal/processor/purchase_order_processor.go index ca0819e..7d87e90 100644 --- a/internal/processor/purchase_order_processor.go +++ b/internal/processor/purchase_order_processor.go @@ -67,20 +67,40 @@ 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) - if err != nil { - return nil, fmt.Errorf("ingredient not found for item %d: %w", i, err) - } + switch category.Type { + case entities.PurchaseCategoryTypeRawMaterial: + if item.IngredientID == nil { + return nil, fmt.Errorf("ingredient_id is required for raw_material item %d", i) + } + if item.Quantity == nil { + return nil, fmt.Errorf("quantity is required for raw_material item %d", i) + } + if item.UnitID == nil { + return nil, fmt.Errorf("unit_id is required for raw_material item %d", i) + } - _, err = p.unitRepo.GetByID(ctx, item.UnitID, organizationID) - if err != nil { - return nil, fmt.Errorf("unit not found for item %d: %w", i, err) + _, 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) + 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) } } @@ -204,38 +224,48 @@ 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) - if err != nil { - return nil, fmt.Errorf("ingredient not found: %w", err) - } + switch category.Type { + case entities.PurchaseCategoryTypeRawMaterial: + if ingredientID == nil { + return nil, fmt.Errorf("ingredient_id is required for raw_material item %d", i) + } + if quantity == nil { + return nil, fmt.Errorf("quantity is required for raw_material item %d", i) + } + if unitID == nil { + return nil, fmt.Errorf("unit_id is required for raw_material item %d", i) + } - _, err = p.unitRepo.GetByID(ctx, unitID, organizationID) - if err != nil { - return nil, fmt.Errorf("unit not found: %w", err) + _, err := p.ingredientRepo.GetByID(ctx, *ingredientID, organizationID) + if err != nil { + return nil, fmt.Errorf("ingredient not found: %w", err) + } + + _, err = p.unitRepo.GetByID(ctx, *unitID, organizationID) + if err != nil { + return nil, fmt.Errorf("unit not found: %w", err) + } + case entities.PurchaseCategoryTypeExpense: + if ingredientID != nil || quantity != nil || unitID != nil { + return nil, fmt.Errorf("ingredient_id, quantity, and unit_id must be empty for expense item %d", i) + } + default: + return nil, fmt.Errorf("purchase category for item %d has unsupported type %s", i, category.Type) } items[i] = &entities.PurchaseOrderItem{ @@ -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 } diff --git a/internal/repository/analytics_repository.go b/internal/repository/analytics_repository.go index 6b2be98..9667788 100644 --- a/internal/repository/analytics_repository.go +++ b/internal/repository/analytics_repository.go @@ -18,7 +18,6 @@ type AnalyticsRepository interface { GetProductAnalyticsPerCategory(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]*entities.ProductAnalyticsPerCategory, error) GetDashboardOverview(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (*entities.DashboardOverview, error) GetProfitLossAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) (*entities.ProfitLossAnalytics, error) - GetExclusiveSummaryAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (*entities.ExclusiveSummaryAnalytics, error) } type AnalyticsRepositoryImpl struct { @@ -153,11 +152,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 +162,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 +192,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 +227,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 +252,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 +279,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 +290,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 +348,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 +643,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 +656,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 +668,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,222 +680,9 @@ 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 return results, err } - -func (r *AnalyticsRepositoryImpl) GetExclusiveSummaryAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (*entities.ExclusiveSummaryAnalytics, error) { - type salesResult struct { - SalesTotal float64 - SalesCount int64 - } - - var sales salesResult - salesQuery := r.db.WithContext(ctx). - Table("orders o"). - Select(` - COALESCE(SUM(o.total_amount), 0) as sales_total, - COUNT(o.id) as sales_count - `). - Where("o.organization_id = ?", organizationID). - Where("o.status = ?", entities.OrderStatusCompleted). - Where("o.payment_status = ?", entities.PaymentStatusCompleted). - Where("o.is_void = false AND o.is_refund = false"). - Where("o.created_at >= ? AND o.created_at <= ?", dateFrom, dateTo) - salesQuery = r.resolveOutletID(salesQuery, outletID, "o.outlet_id") - if err := salesQuery.Scan(&sales).Error; err != nil { - return nil, err - } - - hppBreakdown, err := r.getExclusiveSummaryHPPBreakdown(ctx, organizationID, outletID, dateFrom, dateTo) - if err != nil { - return nil, err - } - - operationalExpenseBreakdown, err := r.getExclusiveSummaryOperationalExpenseBreakdown(ctx, organizationID, outletID, dateFrom, dateTo) - if err != nil { - return nil, err - } - - dailySummary, err := r.getExclusiveSummaryDailySummary(ctx, organizationID, outletID, dateFrom, dateTo) - if err != nil { - return nil, err - } - - dailyTransactions, err := r.getExclusiveSummaryDailyTransactions(ctx, organizationID, outletID, dateFrom, dateTo) - if err != nil { - return nil, err - } - - return &entities.ExclusiveSummaryAnalytics{ - SalesTotal: sales.SalesTotal, - SalesCount: sales.SalesCount, - HPPBreakdown: hppBreakdown, - OperationalExpenseBreakdown: operationalExpenseBreakdown, - DailySummary: dailySummary, - DailyTransactions: dailyTransactions, - }, nil -} - -func (r *AnalyticsRepositoryImpl) getExclusiveSummaryHPPBreakdown(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("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"). - 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.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) - } - - 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) 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) - - err := r.db.WithContext(ctx).Raw(` - SELECT date, COUNT(*) as transaction_count, COALESCE(SUM(amount), 0) as total_cost - FROM (`+rawQuery+`) transactions - GROUP BY date - ORDER BY date ASC - `, args...).Scan(&results).Error - - return results, err -} - -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) - - 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 - `, 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 := "" - args := []interface{}{ - organizationID, - entities.PurchaseCategoryTypeRawMaterial, - "received", - dateFrom, - dateTo, - } - - 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) - } - - query := ` - SELECT - DATE(po.transaction_date) as date, - pc.code as category_code, - pc.name as category_name, - COALESCE(NULLIF(poi.description, ''), i.name, pc.name) as description, - poi.amount as amount, - 'purchase_order' as source - 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 units u ON poi.unit_id = u.id - WHERE po.organization_id = ? - AND pc.type = ? - 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 + ` - ` - - return query, args -} diff --git a/internal/router/router.go b/internal/router/router.go index 35aec41..8db5e7b 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -337,8 +337,6 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { analytics.GET("/categories", r.analyticsHandler.GetProductAnalyticsPerCategory) analytics.GET("/dashboard", r.analyticsHandler.GetDashboardAnalytics) analytics.GET("/profit-loss", r.analyticsHandler.GetProfitLossAnalytics) - analytics.GET("/exclusive-summary/period", r.analyticsHandler.GetExclusiveSummaryPeriod) - analytics.GET("/exclusive-summary/monthly", r.analyticsHandler.GetExclusiveSummaryMonthly) } tables := protected.Group("/tables") diff --git a/internal/service/analytics_service.go b/internal/service/analytics_service.go index b9dc137..5496ad2 100644 --- a/internal/service/analytics_service.go +++ b/internal/service/analytics_service.go @@ -18,8 +18,6 @@ type AnalyticsService interface { GetProductAnalyticsPerCategory(ctx context.Context, req *models.ProductAnalyticsPerCategoryRequest) (*models.ProductAnalyticsPerCategoryResponse, error) GetDashboardAnalytics(ctx context.Context, req *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error) GetProfitLossAnalytics(ctx context.Context, req *models.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsResponse, error) - GetExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error) - GetExclusiveSummaryMonthly(ctx context.Context, req *models.ExclusiveSummaryMonthlyRequest) (*models.ExclusiveSummaryMonthlyResponse, error) } type AnalyticsServiceImpl struct { @@ -322,69 +320,3 @@ func (s *AnalyticsServiceImpl) validateProfitLossAnalyticsRequest(req *models.Pr return nil } - -func (s *AnalyticsServiceImpl) GetExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error) { - if err := s.validateExclusiveSummaryPeriodRequest(req); err != nil { - return nil, fmt.Errorf("validation error: %w", err) - } - - response, err := s.analyticsProcessor.GetExclusiveSummaryPeriod(ctx, req) - if err != nil { - return nil, fmt.Errorf("failed to get exclusive summary period: %w", err) - } - - return response, nil -} - -func (s *AnalyticsServiceImpl) GetExclusiveSummaryMonthly(ctx context.Context, req *models.ExclusiveSummaryMonthlyRequest) (*models.ExclusiveSummaryMonthlyResponse, error) { - if err := s.validateExclusiveSummaryMonthlyRequest(req); err != nil { - return nil, fmt.Errorf("validation error: %w", err) - } - - response, err := s.analyticsProcessor.GetExclusiveSummaryMonthly(ctx, req) - if err != nil { - return nil, fmt.Errorf("failed to get exclusive summary monthly: %w", err) - } - - return response, nil -} - -func (s *AnalyticsServiceImpl) validateExclusiveSummaryPeriodRequest(req *models.ExclusiveSummaryPeriodRequest) error { - if req == nil { - return fmt.Errorf("request cannot be nil") - } - - if req.OrganizationID == uuid.Nil { - return fmt.Errorf("organization_id is required") - } - - if req.DateFrom.IsZero() { - return fmt.Errorf("date_from is required") - } - - if req.DateTo.IsZero() { - return fmt.Errorf("date_to is required") - } - - if req.DateFrom.After(req.DateTo) { - return fmt.Errorf("date_from cannot be after date_to") - } - - return nil -} - -func (s *AnalyticsServiceImpl) validateExclusiveSummaryMonthlyRequest(req *models.ExclusiveSummaryMonthlyRequest) error { - if req == nil { - return fmt.Errorf("request cannot be nil") - } - - if req.OrganizationID == uuid.Nil { - return fmt.Errorf("organization_id is required") - } - - if req.Month.IsZero() { - return fmt.Errorf("month is required") - } - - return nil -} diff --git a/internal/service/analytics_service_test.go b/internal/service/analytics_service_test.go index 49b42ce..c43419d 100644 --- a/internal/service/analytics_service_test.go +++ b/internal/service/analytics_service_test.go @@ -41,14 +41,6 @@ func (analyticsProcessorStub) GetProfitLossAnalytics(context.Context, *models.Pr return nil, nil } -func (analyticsProcessorStub) GetExclusiveSummaryPeriod(context.Context, *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error) { - return &models.ExclusiveSummaryPeriodResponse{}, nil -} - -func (analyticsProcessorStub) GetExclusiveSummaryMonthly(context.Context, *models.ExclusiveSummaryMonthlyRequest) (*models.ExclusiveSummaryMonthlyResponse, error) { - return &models.ExclusiveSummaryMonthlyResponse{}, nil -} - func TestAnalyticsServiceGetPurchasingAnalyticsValidation(t *testing.T) { service := NewAnalyticsServiceImpl(analyticsProcessorStub{}) now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) diff --git a/internal/transformer/analytics_transformer.go b/internal/transformer/analytics_transformer.go index 9cd5dcd..fa3c42d 100644 --- a/internal/transformer/analytics_transformer.go +++ b/internal/transformer/analytics_transformer.go @@ -559,229 +559,3 @@ func profitLossSummaryRowModelToContract(row models.ProfitLossSummaryRow) contra SubItems: subItems, } } - -func ExclusiveSummaryPeriodContractToModel(req *contract.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodRequest, error) { - if req == nil { - return nil, fmt.Errorf("request cannot be nil") - } - - dateFrom, dateTo, err := parseFlexibleDateRangeToJakartaTime(req.DateFrom, req.DateTo) - if err != nil { - return nil, fmt.Errorf("invalid date range: %w", err) - } - - if dateFrom == nil { - return nil, fmt.Errorf("date_from is required") - } - - if dateTo == nil { - return nil, fmt.Errorf("date_to is required") - } - - return &models.ExclusiveSummaryPeriodRequest{ - OrganizationID: req.OrganizationID, - OutletID: parseOutletID(req.OutletID), - DateFrom: *dateFrom, - DateTo: *dateTo, - ExcludeGajiStaffFromReimburse: req.ExcludeGajiStaffFromReimburse, - }, nil -} - -func ExclusiveSummaryMonthlyContractToModel(req *contract.ExclusiveSummaryMonthlyRequest) (*models.ExclusiveSummaryMonthlyRequest, error) { - if req == nil { - return nil, fmt.Errorf("request cannot be nil") - } - - month, err := parseMonthToJakartaTime(req.Month) - if err != nil { - return nil, fmt.Errorf("invalid month: %w", err) - } - - return &models.ExclusiveSummaryMonthlyRequest{ - OrganizationID: req.OrganizationID, - OutletID: parseOutletID(req.OutletID), - Month: month, - }, nil -} - -func ExclusiveSummaryPeriodModelToContract(resp *models.ExclusiveSummaryPeriodResponse) *contract.ExclusiveSummaryPeriodResponse { - if resp == nil { - return nil - } - - hppBreakdown := make([]contract.ExclusiveSummaryCategoryBreakdown, len(resp.HPPBreakdown)) - for i, item := range resp.HPPBreakdown { - hppBreakdown[i] = exclusiveSummaryCategoryBreakdownModelToContract(item) - } - - operationalBreakdown := make([]contract.ExclusiveSummaryCategoryBreakdown, len(resp.OperationalExpenseBreakdown)) - for i, item := range resp.OperationalExpenseBreakdown { - operationalBreakdown[i] = exclusiveSummaryCategoryBreakdownModelToContract(item) - } - - dailySummary := make([]contract.ExclusiveSummaryDailySummary, len(resp.DailySummary)) - for i, item := range resp.DailySummary { - dailySummary[i] = contract.ExclusiveSummaryDailySummary{ - Date: item.Date, - TransactionCount: item.TransactionCount, - TotalCost: item.TotalCost, - } - } - - dailyTransactions := make([]contract.ExclusiveSummaryDailyTransaction, len(resp.DailyTransactions)) - for i, item := range resp.DailyTransactions { - dailyTransactions[i] = contract.ExclusiveSummaryDailyTransaction{ - Date: item.Date, - CategoryCode: item.CategoryCode, - CategoryName: item.CategoryName, - Description: item.Description, - Amount: item.Amount, - Source: item.Source, - } - } - - return &contract.ExclusiveSummaryPeriodResponse{ - OrganizationID: resp.OrganizationID, - OutletID: resp.OutletID, - Period: contract.ExclusiveSummaryPeriodRange{ - DateFrom: resp.Period.DateFrom, - DateTo: resp.Period.DateTo, - }, - Summary: contract.ExclusiveSummaryPeriodSummary{ - Sales: resp.Summary.Sales, - HPP: resp.Summary.HPP, - GrossProfit: resp.Summary.GrossProfit, - SalaryTotal: resp.Summary.SalaryTotal, - SalaryDW: resp.Summary.SalaryDW, - SalaryStaff: resp.Summary.SalaryStaff, - SalaryOther: resp.Summary.SalaryOther, - OtherOperationalExpenses: resp.Summary.OtherOperationalExpenses, - OperationalExpensesTotal: resp.Summary.OperationalExpensesTotal, - TotalCost: resp.Summary.TotalCost, - NetProfit: resp.Summary.NetProfit, - }, - Reimburse: contract.ExclusiveSummaryReimburse{ - TotalCost: resp.Reimburse.TotalCost, - ExcludedSalaryStaff: resp.Reimburse.ExcludedSalaryStaff, - TotalReimburse: resp.Reimburse.TotalReimburse, - }, - HPPBreakdown: hppBreakdown, - OperationalExpenseBreakdown: operationalBreakdown, - DailySummary: dailySummary, - DailyTransactions: dailyTransactions, - } -} - -func ExclusiveSummaryMonthlyModelToContract(resp *models.ExclusiveSummaryMonthlyResponse) *contract.ExclusiveSummaryMonthlyResponse { - if resp == nil { - return nil - } - - periods := make([]contract.ExclusiveSummaryMonthlyPeriod, len(resp.Periods)) - for i, item := range resp.Periods { - periods[i] = contract.ExclusiveSummaryMonthlyPeriod{ - Label: item.Label, - DateFrom: item.DateFrom, - DateTo: item.DateTo, - Sales: item.Sales, - HPP: item.HPP, - GrossProfit: item.GrossProfit, - GrossMargin: item.GrossMargin, - } - } - - bankBalance := make([]contract.ExclusiveSummaryBankBalance, len(resp.BankBalance)) - for i, item := range resp.BankBalance { - bankBalance[i] = contract.ExclusiveSummaryBankBalance{ - Bank: item.Bank, - OpeningBalance: item.OpeningBalance, - IncomingMutation: item.IncomingMutation, - OutgoingMutation: item.OutgoingMutation, - ClosingBalance: item.ClosingBalance, - Notes: item.Notes, - } - } - - return &contract.ExclusiveSummaryMonthlyResponse{ - OrganizationID: resp.OrganizationID, - OutletID: resp.OutletID, - Month: resp.Month, - Summary: contract.ExclusiveSummaryMonthlySummary{ - TotalSales: resp.Summary.TotalSales, - HPP: resp.Summary.HPP, - GrossProfit: resp.Summary.GrossProfit, - OperationalExpensesTotal: resp.Summary.OperationalExpensesTotal, - TotalCost: resp.Summary.TotalCost, - NetProfit: resp.Summary.NetProfit, - NetProfitMargin: resp.Summary.NetProfitMargin, - }, - Periods: periods, - BankBalance: bankBalance, - } -} - -func exclusiveSummaryCategoryBreakdownModelToContract(item models.ExclusiveSummaryCategoryBreakdown) contract.ExclusiveSummaryCategoryBreakdown { - return contract.ExclusiveSummaryCategoryBreakdown{ - CategoryCode: item.CategoryCode, - CategoryName: item.CategoryName, - Amount: item.Amount, - Percentage: item.Percentage, - } -} - -func parseFlexibleDateRangeToJakartaTime(dateFrom, dateTo string) (*time.Time, *time.Time, error) { - fromTime, toTime, err := util.ParseDateRangeToJakartaTime(dateFrom, dateTo) - if err == nil { - return fromTime, toTime, nil - } - - fromTime, err = parseISODateToJakartaTime(dateFrom, false) - if err != nil { - return nil, nil, err - } - - toTime, err = parseISODateToJakartaTime(dateTo, true) - if err != nil { - return nil, nil, err - } - - return fromTime, toTime, nil -} - -func parseISODateToJakartaTime(dateStr string, endOfDay bool) (*time.Time, error) { - if dateStr == "" { - 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 - } - - if endOfDay { - result := time.Date(date.Year(), date.Month(), date.Day(), 23, 59, 59, 999999999, location) - return &result, nil - } - - result := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, location) - return &result, nil -} - -func parseMonthToJakartaTime(month string) (time.Time, error) { - location, err := time.LoadLocation("Asia/Jakarta") - if err != nil { - return time.Time{}, err - } - - parsed, err := time.ParseInLocation("2006-01", month, location) - if err != nil { - return time.Time{}, err - } - - return time.Date(parsed.Year(), parsed.Month(), 1, 0, 0, 0, 0, location), nil -} diff --git a/internal/transformer/analytics_transformer_test.go b/internal/transformer/analytics_transformer_test.go index 270945d..4fca5cf 100644 --- a/internal/transformer/analytics_transformer_test.go +++ b/internal/transformer/analytics_transformer_test.go @@ -182,42 +182,3 @@ func TestProfitLossAnalyticsModelToContractCopiesDateRange(t *testing.T) { require.Len(t, result.MainSummary, 1) require.Equal(t, "total_omset", result.MainSummary[0].ID) } - -func TestExclusiveSummaryPeriodContractToModelParsesISODateRange(t *testing.T) { - orgID := uuid.New() - outletID := uuid.New().String() - - result, err := ExclusiveSummaryPeriodContractToModel(&contract.ExclusiveSummaryPeriodRequest{ - OrganizationID: orgID, - OutletID: &outletID, - DateFrom: "2026-05-26", - DateTo: "2026-05-31", - ExcludeGajiStaffFromReimburse: true, - }) - - require.NoError(t, err) - require.Equal(t, orgID, result.OrganizationID) - require.NotNil(t, result.OutletID) - require.Equal(t, outletID, result.OutletID.String()) - require.True(t, result.ExcludeGajiStaffFromReimburse) - - location, err := time.LoadLocation("Asia/Jakarta") - require.NoError(t, err) - require.Equal(t, time.Date(2026, 5, 26, 0, 0, 0, 0, location), result.DateFrom) - require.Equal(t, time.Date(2026, 5, 31, 23, 59, 59, int(time.Second-time.Nanosecond), location), result.DateTo) -} - -func TestExclusiveSummaryMonthlyContractToModelParsesMonth(t *testing.T) { - orgID := uuid.New() - - result, err := ExclusiveSummaryMonthlyContractToModel(&contract.ExclusiveSummaryMonthlyRequest{ - OrganizationID: orgID, - Month: "2026-05", - }) - - require.NoError(t, err) - require.Equal(t, orgID, result.OrganizationID) - 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) -} diff --git a/internal/transformer/purchase_order_transformer_test.go b/internal/transformer/purchase_order_transformer_test.go index 24aea4c..4f481a6 100644 --- a/internal/transformer/purchase_order_transformer_test.go +++ b/internal/transformer/purchase_order_transformer_test.go @@ -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, }, }, diff --git a/internal/validator/purchase_order_validator.go b/internal/validator/purchase_order_validator.go index a62a7c7..1b67023 100644 --- a/internal/validator/purchase_order_validator.go +++ b/internal/validator/purchase_order_validator.go @@ -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 } diff --git a/internal/validator/purchase_order_validator_test.go b/internal/validator/purchase_order_validator_test.go index 34c39f2..4c37b90 100644 --- a/internal/validator/purchase_order_validator_test.go +++ b/internal/validator/purchase_order_validator_test.go @@ -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 -} diff --git a/migrations/000081_enforce_raw_material_purchase_order_items.down.sql b/migrations/000081_enforce_raw_material_purchase_order_items.down.sql deleted file mode 100644 index 802b92d..0000000 --- a/migrations/000081_enforce_raw_material_purchase_order_items.down.sql +++ /dev/null @@ -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; diff --git a/migrations/000081_enforce_raw_material_purchase_order_items.up.sql b/migrations/000081_enforce_raw_material_purchase_order_items.up.sql deleted file mode 100644 index d394149..0000000 --- a/migrations/000081_enforce_raw_material_purchase_order_items.up.sql +++ /dev/null @@ -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(); diff --git a/migrations/000082_enforce_expense_items_expense_categories.down.sql b/migrations/000082_enforce_expense_items_expense_categories.down.sql deleted file mode 100644 index 2d79281..0000000 --- a/migrations/000082_enforce_expense_items_expense_categories.down.sql +++ /dev/null @@ -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; diff --git a/migrations/000082_enforce_expense_items_expense_categories.up.sql b/migrations/000082_enforce_expense_items_expense_categories.up.sql deleted file mode 100644 index e218504..0000000 --- a/migrations/000082_enforce_expense_items_expense_categories.up.sql +++ /dev/null @@ -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();