Compare commits

..
Author SHA1 Message Date
ryan fbc65c5606 Add grouping by outlet_id 2026-06-24 16:30:40 +07:00
Efril 9b0fc9a63b feat: updat analytic profit loss add purchasing 2026-06-24 00:11:04 +07:00
Efril 793919cf10 feat: add outlet name at analytic response and new overview dashboard 2026-06-23 22:18:16 +07:00
aefril 25024c210a Merge pull request 'fix: product price' (#21) from feature/exclusive-summary into main
Reviewed-on: #21
2026-06-22 06:34:02 +00:00
efrilm 3977370079 fix: product price 2026-06-22 13:33:25 +07:00
Efril 37bcb90ab0 feat: ensure all role 2026-06-19 13:58:36 +07:00
Efril e345aeee97 feat: new users role 2026-06-19 13:31:33 +07:00
Efril 486d94335b Merge branch 'main' of https://gits.altru.id/apksel-dev/apskel-pos-backend 2026-06-18 19:55:04 +07:00
aefril 7d5acb33e8 Merge pull request 'Update profit-loss' (#20) from feature/exclusive-summary into main
Reviewed-on: #20
2026-06-18 12:53:23 +00:00
ryan 2138b44c53 Update profit-loss 2026-06-18 19:51:43 +07:00
Efril 503fb5734f fix: migration 82 2026-06-18 16:34:37 +07:00
aefril ac06a4bbe9 Merge pull request 'feature/exclusive-summary' (#19) from feature/exclusive-summary into main
Reviewed-on: #19
2026-06-18 09:12:13 +00:00
ryan 87540fa1b7 Update exclusive summary 2026-06-18 15:44:15 +07:00
ryan 66d4c9f0af Update purchase order with outlet id 2026-06-18 15:27:20 +07:00
ryan 55119b3e91 Add MTD 2026-06-18 14:19:45 +07:00
aefril 67a5c076e7 Merge pull request 'Make vendor nullable' (#18) from feature/exclusive-summary into main
Reviewed-on: #18
2026-06-18 04:12:08 +00:00
ryan c1d859ebdd Make vendor nullable 2026-06-18 11:04:59 +07:00
aefril 7a2060efdc Merge pull request 'Fix total_amount to use base price' (#17) from feature/exclusive-summary into main
Reviewed-on: #17
2026-06-18 03:18:04 +00:00
ryan 2ad9e2f85f Fix total_amount to use base price 2026-06-18 09:52:16 +07:00
aefril a8d62bc5e8 Merge pull request 'feature/exclusive-summary' (#16) from feature/exclusive-summary into main
Reviewed-on: #16
2026-06-18 01:11:14 +00:00
ryan 8816e4addc Merge branch 'feature/expense-revisi' into feature/exclusive-summary 2026-06-17 18:36:46 +07:00
ryan 2921631ac3 Revert "Revert purchase order"
This reverts commit 657a201fc0.
2026-06-17 18:31:10 +07:00
ryan 0db838e2c4 Fix bank 2026-06-17 18:26:40 +07:00
ryan 4b6cbb69c1 Add exclusive-summary 2026-06-17 18:17:08 +07:00
aefril 9e0ba0ce56 Merge pull request 'feature/exclusive-summary' (#15) from feature/exclusive-summary into main
Reviewed-on: #15
2026-06-17 09:26:24 +00:00
ryan 6c19876a47 Fix issue 2026-06-15 17:44:25 +07:00
ryan b2db56f855 Add excusive-summary endpoint 2026-06-15 17:01:47 +07:00
ryan 8c4d9c69d0 Update analytic to support new categories change 2026-06-15 14:17:48 +07:00
ryan 657a201fc0 Revert purchase order 2026-06-15 13:52:08 +07:00
Efril 7a7ac25dcf fix conflict 2026-06-11 16:47:03 +07:00
Efril d0a548f44e merger 2026-06-11 16:43:53 +07:00
ryan f4172fcea7 Fix purchase analytic by outlet 2026-06-11 16:27:50 +07:00
ryan d5216e7994 Fix purchase analytic zero for group by today 2026-06-11 15:57:19 +07:00
ryan 1718c5adab Fix expense to be nullable without raw material. 2026-06-10 14:25:23 +07:00
ryan d0c090a657 Update purchase analytics 2026-06-10 13:18:49 +07:00
ryan c3db919531 Update expense for product category (non-inventory type) 2026-06-10 12:42:53 +07:00
ryan e09feff36d Update purchase for product category (inventory type) 2026-06-09 15:59:34 +07:00
66 changed files with 4486 additions and 689 deletions
+1
View File
@@ -0,0 +1 @@
{}
+2 -1
View File
@@ -351,6 +351,8 @@ github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM=
github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
@@ -380,7 +382,6 @@ go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI=
go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
+2 -2
View File
@@ -372,7 +372,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
ingredientProcessor: processor.NewIngredientProcessor(repos.ingredientRepo, repos.unitRepo, repos.ingredientCompositionRepo),
productRecipeProcessor: processor.NewProductRecipeProcessor(repos.productRecipeRepo, repos.productRepo, repos.ingredientRepo),
vendorProcessor: processor.NewVendorProcessorImpl(repos.vendorRepo),
purchaseOrderProcessor: processor.NewPurchaseOrderProcessorImpl(repos.purchaseOrderRepo, repos.vendorRepo, repos.ingredientRepo, repos.unitRepo, repos.fileRepo, inventoryMovementService, repos.unitConverterRepo),
purchaseOrderProcessor: processor.NewPurchaseOrderProcessorImpl(repos.purchaseOrderRepo, repos.vendorRepo, repos.ingredientRepo, repos.purchaseCategoryRepo, repos.unitRepo, repos.fileRepo, inventoryMovementService, repos.unitConverterRepo),
purchaseCategoryProcessor: processor.NewPurchaseCategoryProcessorImpl(repos.purchaseCategoryRepo),
unitConverterProcessor: processor.NewIngredientUnitConverterProcessorImpl(repos.unitConverterRepo, repos.ingredientRepo, repos.unitRepo),
chartOfAccountTypeProcessor: processor.NewChartOfAccountTypeProcessorImpl(repos.chartOfAccountTypeRepo),
@@ -396,7 +396,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
userDeviceProcessor: processor.NewUserDeviceProcessorImpl(repos.userDeviceRepo),
notificationProcessor: buildNotificationProcessor(cfg, repos),
productOutletPriceProcessor: processor.NewProductOutletPriceProcessorImpl(repos.productOutletPriceRepo, repos.productRepo, repos.outletRepo),
expenseProcessor: processor.NewExpenseProcessorImpl(repos.expenseRepo),
expenseProcessor: processor.NewExpenseProcessorImpl(repos.expenseRepo, repos.purchaseCategoryRepo),
}
}
+7 -5
View File
@@ -3,11 +3,12 @@ package constants
type UserRole string
const (
RoleAdmin UserRole = "admin"
RoleManager UserRole = "manager"
RoleCashier UserRole = "cashier"
RoleWaiter UserRole = "waiter"
RoleOwner UserRole = "owner"
RoleAdmin UserRole = "admin"
RoleManager UserRole = "manager"
RoleCashier UserRole = "cashier"
RoleWaiter UserRole = "waiter"
RoleOwner UserRole = "owner"
RolePurchasing UserRole = "purchasing"
)
func GetAllUserRoles() []UserRole {
@@ -17,6 +18,7 @@ func GetAllUserRoles() []UserRole {
RoleCashier,
RoleWaiter,
RoleOwner,
RolePurchasing,
}
}
+189 -19
View File
@@ -18,6 +18,7 @@ type PaymentMethodAnalyticsRequest struct {
type PaymentMethodAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"`
GroupBy string `json:"group_by"`
@@ -54,6 +55,7 @@ type SalesAnalyticsRequest struct {
type SalesAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"`
GroupBy string `json:"group_by"`
@@ -88,7 +90,7 @@ type PurchasingAnalyticsRequest struct {
OutletID *string `form:"outlet_id,omitempty"`
DateFrom string `form:"date_from" validate:"required"`
DateTo string `form:"date_to" validate:"required"`
GroupBy string `form:"group_by,default=day" validate:"omitempty,oneof=day hour week month"`
GroupBy string `form:"group_by,default=day" validate:"omitempty,oneof=day hour week month outlet_id"`
}
type PurchasingAnalyticsResponse struct {
@@ -100,13 +102,18 @@ type PurchasingAnalyticsResponse struct {
GroupBy string `json:"group_by"`
Summary PurchasingSummary `json:"summary"`
Data []PurchasingAnalyticsData `json:"data"`
OutletData []PurchasingOutletData `json:"outlet_data,omitempty"`
IngredientData []PurchasingIngredientData `json:"ingredient_data"`
VendorData []PurchasingVendorData `json:"vendor_data"`
}
type PurchasingSummary struct {
TotalPurchases float64 `json:"total_purchases"`
RawMaterialPurchases float64 `json:"raw_material_purchases"`
ExpensePurchases float64 `json:"expense_purchases"`
TotalPurchaseOrders int64 `json:"total_purchase_orders"`
RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"`
ExpenseCount int64 `json:"expense_count"`
TotalQuantity float64 `json:"total_quantity"`
AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"`
TotalIngredients int64 `json:"total_ingredients"`
@@ -114,12 +121,30 @@ type PurchasingSummary struct {
}
type PurchasingAnalyticsData struct {
Date time.Time `json:"date"`
Purchases float64 `json:"purchases"`
PurchaseOrders int64 `json:"purchase_orders"`
Quantity float64 `json:"quantity"`
Ingredients int64 `json:"ingredients"`
Vendors int64 `json:"vendors"`
Date time.Time `json:"date"`
Purchases float64 `json:"purchases"`
RawMaterialPurchases float64 `json:"raw_material_purchases"`
ExpensePurchases float64 `json:"expense_purchases"`
PurchaseOrders int64 `json:"purchase_orders"`
RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"`
ExpenseCount int64 `json:"expense_count"`
Quantity float64 `json:"quantity"`
Ingredients int64 `json:"ingredients"`
Vendors int64 `json:"vendors"`
}
type PurchasingOutletData struct {
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName string `json:"outlet_name"`
Purchases float64 `json:"purchases"`
RawMaterialPurchases float64 `json:"raw_material_purchases"`
ExpensePurchases float64 `json:"expense_purchases"`
PurchaseOrders int64 `json:"purchase_orders"`
RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"`
ExpenseCount int64 `json:"expense_count"`
Quantity float64 `json:"quantity"`
Ingredients int64 `json:"ingredients"`
Vendors int64 `json:"vendors"`
}
type PurchasingIngredientData struct {
@@ -132,12 +157,12 @@ type PurchasingIngredientData struct {
}
type PurchasingVendorData struct {
VendorID uuid.UUID `json:"vendor_id"`
VendorName string `json:"vendor_name"`
TotalCost float64 `json:"total_cost"`
PurchaseOrderCount int64 `json:"purchase_order_count"`
IngredientCount int64 `json:"ingredient_count"`
Quantity float64 `json:"quantity"`
VendorID *uuid.UUID `json:"vendor_id"`
VendorName string `json:"vendor_name"`
TotalCost float64 `json:"total_cost"`
PurchaseOrderCount int64 `json:"purchase_order_count"`
IngredientCount int64 `json:"ingredient_count"`
Quantity float64 `json:"quantity"`
}
// ProductAnalyticsRequest represents the request for product analytics
@@ -153,6 +178,7 @@ type ProductAnalyticsRequest struct {
type ProductAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"`
Data []ProductAnalyticsData `json:"data"`
@@ -190,6 +216,7 @@ type ProductAnalyticsPerCategoryRequest struct {
type ProductAnalyticsPerCategoryResponse struct {
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"`
Data []ProductAnalyticsPerCategoryData `json:"data"`
@@ -219,6 +246,7 @@ type DashboardAnalyticsRequest struct {
type DashboardAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"`
Overview DashboardOverview `json:"overview"`
@@ -229,12 +257,15 @@ type DashboardAnalyticsResponse struct {
// DashboardOverview represents the overview data for dashboard
type DashboardOverview struct {
TotalSales float64 `json:"total_sales"`
TotalOrders int64 `json:"total_orders"`
AverageOrderValue float64 `json:"average_order_value"`
TotalCustomers int64 `json:"total_customers"`
VoidedOrders int64 `json:"voided_orders"`
RefundedOrders int64 `json:"refunded_orders"`
TotalSales float64 `json:"total_sales"`
TotalOrders int64 `json:"total_orders"`
AverageOrderValue float64 `json:"average_order_value"`
TotalCustomers int64 `json:"total_customers"`
VoidedOrders int64 `json:"voided_orders"`
RefundedOrders int64 `json:"refunded_orders"`
TotalItemSold int64 `json:"total_item_sold"`
TotalLowStock int64 `json:"total_low_stock"`
TotalProductActive int64 `json:"total_product_active"`
}
type ProfitLossAnalyticsRequest struct {
@@ -248,6 +279,7 @@ type ProfitLossAnalyticsRequest struct {
type ProfitLossAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"`
GroupBy string `json:"group_by"`
@@ -255,10 +287,28 @@ type ProfitLossAnalyticsResponse struct {
Data []ProfitLossData `json:"data"`
ProductData []ProductProfitData `json:"product_data"`
MainSummary []ProfitLossSummaryRow `json:"main_summary"`
Purchasing ProfitLossPurchasing `json:"purchasing"`
OperationalExpenses []OperationalExpenseItem `json:"operational_expenses"`
OperationalExpensesTotal float64 `json:"operational_expenses_total"`
}
type ProfitLossPurchasing struct {
TodayTotal float64 `json:"today_total"`
MtdTotal float64 `json:"mtd_total"`
TodayRawMaterial float64 `json:"today_raw_material"`
MtdRawMaterial float64 `json:"mtd_raw_material"`
TodayExpense float64 `json:"today_expense"`
MtdExpense float64 `json:"mtd_expense"`
Items []ProfitLossPurchasingItem `json:"items"`
}
type ProfitLossPurchasingItem struct {
Date time.Time `json:"date"`
Item string `json:"item"`
Quantity float64 `json:"quantity"`
Nominal float64 `json:"nominal"`
}
type ProfitLossSummary struct {
TotalRevenue float64 `json:"total_revenue"`
TotalCost float64 `json:"total_cost"`
@@ -316,3 +366,123 @@ 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 ExclusiveSummaryMTDRequest struct {
OrganizationID uuid.UUID
OutletID *string `form:"outlet_id,omitempty"`
DateTo string `form:"date_to" validate:"required"`
ExcludeGajiStaffFromReimburse bool `form:"exclude_gaji_staff_from_reimburse"`
}
type ExclusiveSummaryPeriodResponse struct {
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,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"`
OutletName *string `json:"outlet_name,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"`
}
+42 -26
View File
@@ -19,10 +19,11 @@ type CreateExpenseRequest struct {
}
type CreateExpenseItemRequest struct {
ChartOfAccountID string `json:"chart_of_account_id" validate:"required"`
Item string `json:"item" validate:"required"`
Description *string `json:"description,omitempty"`
Amount float64 `json:"amount" validate:"required"`
ChartOfAccountID string `json:"chart_of_account_id" validate:"required"`
PurchaseCategoryID string `json:"purchase_category_id" validate:"required"`
Item string `json:"item" validate:"required"`
Description *string `json:"description,omitempty"`
Amount float64 `json:"amount" validate:"required"`
}
type UpdateExpenseRequest struct {
@@ -39,10 +40,11 @@ type UpdateExpenseRequest struct {
}
type UpdateExpenseItemRequest struct {
ChartOfAccountID *string `json:"chart_of_account_id,omitempty"`
Item *string `json:"item,omitempty"`
Description *string `json:"description,omitempty"`
Amount *float64 `json:"amount,omitempty"`
ChartOfAccountID *string `json:"chart_of_account_id,omitempty"`
PurchaseCategoryID *string `json:"purchase_category_id,omitempty"`
Item *string `json:"item,omitempty"`
Description *string `json:"description,omitempty"`
Amount *float64 `json:"amount,omitempty"`
}
type ExpenseResponse struct {
@@ -63,15 +65,19 @@ type ExpenseResponse struct {
}
type ExpenseItemResponse struct {
ID uuid.UUID `json:"id"`
ExpenseID uuid.UUID `json:"expense_id"`
ChartOfAccountID uuid.UUID `json:"chart_of_account_id"`
ChartOfAccountName string `json:"chart_of_account_name,omitempty"`
Item string `json:"item"`
Description *string `json:"description"`
Amount float64 `json:"amount"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uuid.UUID `json:"id"`
ExpenseID uuid.UUID `json:"expense_id"`
ChartOfAccountID uuid.UUID `json:"chart_of_account_id"`
ChartOfAccountName string `json:"chart_of_account_name,omitempty"`
PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
PurchaseCategoryName string `json:"purchase_category_name,omitempty"`
PurchaseCategoryType string `json:"purchase_category_type,omitempty"`
PurchaseCategory *PurchaseCategoryResponse `json:"purchase_category,omitempty"`
Item string `json:"item"`
Description *string `json:"description"`
Amount float64 `json:"amount"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ListExpenseRequest struct {
@@ -100,15 +106,16 @@ type ExpenseAnalyticsRequest struct {
}
type ExpenseAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"`
GroupBy string `json:"group_by"`
Summary ExpenseAnalyticsSummary `json:"summary"`
Data []ExpenseAnalyticsData `json:"data"`
CategoryData []ExpenseAnalyticsCategoryData `json:"category_data"`
ItemData []ExpenseAnalyticsItemData `json:"item_data"`
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"`
GroupBy string `json:"group_by"`
Summary ExpenseAnalyticsSummary `json:"summary"`
Data []ExpenseAnalyticsData `json:"data"`
CategoryData []ExpenseAnalyticsCategoryData `json:"category_data"`
ChartOfAccountData []ExpenseAnalyticsChartOfAccountData `json:"chart_of_account_data"`
ItemData []ExpenseAnalyticsItemData `json:"item_data"`
}
type ExpenseAnalyticsSummary struct {
@@ -130,6 +137,15 @@ type ExpenseAnalyticsData struct {
}
type ExpenseAnalyticsCategoryData struct {
PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
PurchaseCategoryName string `json:"purchase_category_name"`
PurchaseCategoryType string `json:"purchase_category_type"`
TotalAmount float64 `json:"total_amount"`
ExpenseCount int64 `json:"expense_count"`
ItemCount int64 `json:"item_count"`
}
type ExpenseAnalyticsChartOfAccountData struct {
ChartOfAccountID uuid.UUID `json:"chart_of_account_id"`
ChartOfAccountName string `json:"chart_of_account_name"`
TotalAmount float64 `json:"total_amount"`
@@ -10,7 +10,7 @@ type CreatePurchaseCategoryRequest struct {
ParentID *uuid.UUID `json:"parent_id,omitempty"`
Code *string `json:"code,omitempty"`
Name string `json:"name" validate:"required,min=1,max=255"`
Type string `json:"type" validate:"required,oneof=raw_material non_inventory"`
Type string `json:"type" validate:"required,oneof=raw_material expense"`
SortOrder *int `json:"sort_order,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
}
@@ -19,14 +19,14 @@ type UpdatePurchaseCategoryRequest struct {
ParentID *uuid.UUID `json:"parent_id,omitempty"`
Code *string `json:"code,omitempty"`
Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"`
Type *string `json:"type,omitempty" validate:"omitempty,oneof=raw_material non_inventory"`
Type *string `json:"type,omitempty" validate:"omitempty,oneof=raw_material expense"`
SortOrder *int `json:"sort_order,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
}
type ListPurchaseCategoriesRequest struct {
ParentID *uuid.UUID `json:"parent_id,omitempty"`
Type string `json:"type,omitempty" validate:"omitempty,oneof=raw_material non_inventory"`
Type string `json:"type,omitempty" validate:"omitempty,oneof=raw_material expense"`
Search string `json:"search,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
Page int `json:"page" validate:"required,min=1"`
+29 -24
View File
@@ -7,7 +7,7 @@ import (
)
type CreatePurchaseOrderRequest struct {
VendorID uuid.UUID `json:"vendor_id" validate:"required"`
VendorID *uuid.UUID `json:"vendor_id,omitempty" validate:"omitempty"`
PONumber string `json:"po_number" validate:"required,min=1,max=50"`
TransactionDate string `json:"transaction_date" validate:"required"` // Format: YYYY-MM-DD
DueDate *string `json:"due_date,omitempty" validate:"omitempty"` // Format: YYYY-MM-DD
@@ -19,11 +19,12 @@ type CreatePurchaseOrderRequest struct {
}
type CreatePurchaseOrderItemRequest struct {
IngredientID uuid.UUID `json:"ingredient_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 {
@@ -39,18 +40,20 @@ type UpdatePurchaseOrderRequest struct {
}
type UpdatePurchaseOrderItemRequest struct {
ID *uuid.UUID `json:"id,omitempty"` // For existing items
IngredientID *uuid.UUID `json:"ingredient_id,omitempty" validate:"omitempty"`
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,omitempty" validate:"omitempty,gte=0"`
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,omitempty" validate:"omitempty,gt=0"`
UnitID *uuid.UUID `json:"unit_id,omitempty" validate:"omitempty"`
Amount *float64 `json:"amount,omitempty" validate:"omitempty,gte=0"`
}
type PurchaseOrderResponse struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
VendorID uuid.UUID `json:"vendor_id"`
OutletID *uuid.UUID `json:"outlet_id"`
VendorID *uuid.UUID `json:"vendor_id"`
PONumber string `json:"po_number"`
TransactionDate time.Time `json:"transaction_date"`
DueDate *time.Time `json:"due_date"`
@@ -66,17 +69,19 @@ type PurchaseOrderResponse struct {
}
type PurchaseOrderItemResponse struct {
ID uuid.UUID `json:"id"`
PurchaseOrderID uuid.UUID `json:"purchase_order_id"`
IngredientID uuid.UUID `json:"ingredient_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"`
Ingredient *IngredientResponse `json:"ingredient,omitempty"`
Unit *UnitResponse `json:"unit,omitempty"`
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"`
Ingredient *IngredientResponse `json:"ingredient,omitempty"`
PurchaseCategory *PurchaseCategoryResponse `json:"purchase_category,omitempty"`
Unit *UnitResponse `json:"unit,omitempty"`
}
type PurchaseOrderAttachmentResponse struct {
+2 -2
View File
@@ -12,14 +12,14 @@ type CreateUserRequest struct {
Name string `json:"name" validate:"required,min=1,max=255"`
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required,min=6"`
Role string `json:"role" validate:"required,oneof=admin manager cashier waiter"`
Role string `json:"role" validate:"required,oneof=admin manager cashier waiter owner purchasing"`
Permissions map[string]interface{} `json:"permissions,omitempty"`
}
type UpdateUserRequest struct {
Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"`
Email *string `json:"email,omitempty" validate:"omitempty,email"`
Role *string `json:"role,omitempty" validate:"omitempty,oneof=admin manager cashier waiter"`
Role *string `json:"role,omitempty" validate:"omitempty,oneof=admin manager cashier waiter owner purchasing"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
Permissions *map[string]interface{} `json:"permissions,omitempty"`
+107 -28
View File
@@ -32,13 +32,18 @@ type PurchasingAnalytics struct {
OutletName *string `json:"outlet_name,omitempty"`
Summary PurchasingSummary `json:"summary"`
Data []PurchasingAnalyticsData `json:"data"`
OutletData []PurchasingOutletData `json:"outlet_data,omitempty"`
IngredientData []PurchasingIngredientData `json:"ingredient_data"`
VendorData []PurchasingVendorData `json:"vendor_data"`
}
type PurchasingSummary struct {
TotalPurchases float64 `json:"total_purchases"`
RawMaterialPurchases float64 `json:"raw_material_purchases"`
ExpensePurchases float64 `json:"expense_purchases"`
TotalPurchaseOrders int64 `json:"total_purchase_orders"`
RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"`
ExpenseCount int64 `json:"expense_count"`
TotalQuantity float64 `json:"total_quantity"`
AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"`
TotalIngredients int64 `json:"total_ingredients"`
@@ -46,12 +51,30 @@ type PurchasingSummary struct {
}
type PurchasingAnalyticsData struct {
Date time.Time `json:"date"`
Purchases float64 `json:"purchases"`
PurchaseOrders int64 `json:"purchase_orders"`
Quantity float64 `json:"quantity"`
Ingredients int64 `json:"ingredients"`
Vendors int64 `json:"vendors"`
Date time.Time `json:"date"`
Purchases float64 `json:"purchases"`
RawMaterialPurchases float64 `json:"raw_material_purchases"`
ExpensePurchases float64 `json:"expense_purchases"`
PurchaseOrders int64 `json:"purchase_orders"`
RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"`
ExpenseCount int64 `json:"expense_count"`
Quantity float64 `json:"quantity"`
Ingredients int64 `json:"ingredients"`
Vendors int64 `json:"vendors"`
}
type PurchasingOutletData struct {
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName string `json:"outlet_name"`
Purchases float64 `json:"purchases"`
RawMaterialPurchases float64 `json:"raw_material_purchases"`
ExpensePurchases float64 `json:"expense_purchases"`
PurchaseOrders int64 `json:"purchase_orders"`
RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"`
ExpenseCount int64 `json:"expense_count"`
Quantity float64 `json:"quantity"`
Ingredients int64 `json:"ingredients"`
Vendors int64 `json:"vendors"`
}
type PurchasingIngredientData struct {
@@ -64,12 +87,12 @@ type PurchasingIngredientData struct {
}
type PurchasingVendorData struct {
VendorID uuid.UUID `json:"vendor_id"`
VendorName string `json:"vendor_name"`
TotalCost float64 `json:"total_cost"`
PurchaseOrderCount int64 `json:"purchase_order_count"`
IngredientCount int64 `json:"ingredient_count"`
Quantity float64 `json:"quantity"`
VendorID *uuid.UUID `json:"vendor_id"`
VendorName string `json:"vendor_name"`
TotalCost float64 `json:"total_cost"`
PurchaseOrderCount int64 `json:"purchase_order_count"`
IngredientCount int64 `json:"ingredient_count"`
Quantity float64 `json:"quantity"`
}
type ProductAnalytics struct {
@@ -106,25 +129,42 @@ type ProductAnalyticsPerCategory struct {
// DashboardOverview represents dashboard overview data
type DashboardOverview struct {
TotalSales float64 `json:"total_sales"`
TotalOrders int64 `json:"total_orders"`
AverageOrderValue float64 `json:"average_order_value"`
TotalCustomers int64 `json:"total_customers"`
VoidedOrders int64 `json:"voided_orders"`
RefundedOrders int64 `json:"refunded_orders"`
TotalSales float64 `json:"total_sales"`
TotalOrders int64 `json:"total_orders"`
AverageOrderValue float64 `json:"average_order_value"`
TotalCustomers int64 `json:"total_customers"`
VoidedOrders int64 `json:"voided_orders"`
RefundedOrders int64 `json:"refunded_orders"`
TotalItemSold int64 `json:"total_item_sold"`
TotalLowStock int64 `json:"total_low_stock"`
TotalProductActive int64 `json:"total_product_active"`
}
type ProfitLossAnalytics struct {
Summary ProfitLossSummary
Data []ProfitLossData
ProductData []ProductProfitData
TodayRevenue float64
TodayCost float64
MtdRevenue float64
MtdCost float64
TodayExpenseByCategory []ExpenseCategoryTotal
MtdExpenseByCategory []ExpenseCategoryTotal
OperationalExpenseItems []OperationalExpenseItem
Summary ProfitLossSummary
Data []ProfitLossData
ProductData []ProductProfitData
TodayRevenue float64
TodayCost float64
MtdRevenue float64
MtdCost float64
TodayPurchasing float64
MtdPurchasing float64
TodayPurchasingRawMaterial float64
MtdPurchasingRawMaterial float64
TodayPurchasingExpense float64
MtdPurchasingExpense float64
PurchasingItems []PurchasingItemDetail
TodayExpenseByCategory []ExpenseCategoryTotal
MtdExpenseByCategory []ExpenseCategoryTotal
OperationalExpenseItems []OperationalExpenseItem
}
type PurchasingItemDetail struct {
Date time.Time
Item string
Quantity float64
Amount float64
}
type ProfitLossSummary struct {
@@ -178,3 +218,42 @@ 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
}
type ExclusiveSummaryBankBalance struct {
Bank string
OpeningBalance *float64
IncomingMutation *float64
OutgoingMutation *float64
ClosingBalance *float64
Notes *string
}
+14 -4
View File
@@ -29,10 +29,11 @@ type Expense struct {
}
type ExpenseAnalytics struct {
Summary ExpenseAnalyticsSummary
Data []ExpenseAnalyticsData
CategoryData []ExpenseAnalyticsCategoryData
ItemData []ExpenseAnalyticsItemData
Summary ExpenseAnalyticsSummary
Data []ExpenseAnalyticsData
CategoryData []ExpenseAnalyticsCategoryData
ChartOfAccountData []ExpenseAnalyticsChartOfAccountData
ItemData []ExpenseAnalyticsItemData
}
type ExpenseAnalyticsSummary struct {
@@ -54,6 +55,15 @@ type ExpenseAnalyticsData struct {
}
type ExpenseAnalyticsCategoryData struct {
PurchaseCategoryID uuid.UUID
PurchaseCategoryName string
PurchaseCategoryType string
TotalAmount float64
ExpenseCount int64
ItemCount int64
}
type ExpenseAnalyticsChartOfAccountData struct {
ChartOfAccountID uuid.UUID
ChartOfAccountName string
TotalAmount float64
+12 -10
View File
@@ -9,17 +9,19 @@ import (
)
type ExpenseItem struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
ExpenseID uuid.UUID `gorm:"type:uuid;not null;index" json:"expense_id"`
ChartOfAccountID uuid.UUID `gorm:"type:uuid;not null;index" json:"chart_of_account_id"`
Item string `gorm:"not null;size:255" json:"item"`
Description *string `gorm:"type:text" json:"description"`
Amount float64 `gorm:"type:decimal(15,2);not null;default:0" json:"amount"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
ExpenseID uuid.UUID `gorm:"type:uuid;not null;index" json:"expense_id"`
ChartOfAccountID uuid.UUID `gorm:"type:uuid;not null;index" json:"chart_of_account_id"`
PurchaseCategoryID uuid.UUID `gorm:"type:uuid;not null;index" json:"purchase_category_id"`
Item string `gorm:"not null;size:255" json:"item"`
Description *string `gorm:"type:text" json:"description"`
Amount float64 `gorm:"type:decimal(15,2);not null;default:0" json:"amount"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
Expense *Expense `gorm:"foreignKey:ExpenseID" json:"expense,omitempty"`
ChartOfAccount *ChartOfAccount `gorm:"foreignKey:ChartOfAccountID" json:"chart_of_account,omitempty"`
Expense *Expense `gorm:"foreignKey:ExpenseID" json:"expense,omitempty"`
ChartOfAccount *ChartOfAccount `gorm:"foreignKey:ChartOfAccountID" json:"chart_of_account,omitempty"`
PurchaseCategory *PurchaseCategory `gorm:"foreignKey:PurchaseCategoryID" json:"purchase_category,omitempty"`
}
func (e *ExpenseItem) BeforeCreate(tx *gorm.DB) error {
+29 -27
View File
@@ -36,34 +36,36 @@ const (
)
type InventoryMovement struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id" validate:"required"`
OutletID uuid.UUID `gorm:"type:uuid;not null;index" json:"outlet_id" validate:"required"`
ItemID uuid.UUID `gorm:"type:uuid;not null;index" json:"item_id" validate:"required"`
ItemType string `gorm:"not null;size:20" json:"item_type" validate:"required"` // "PRODUCT" or "INGREDIENT"
MovementType InventoryMovementType `gorm:"not null;size:50" json:"movement_type" validate:"required"`
Quantity float64 `gorm:"type:decimal(12,3);not null" json:"quantity" validate:"required"`
PreviousQuantity float64 `gorm:"type:decimal(12,3)" json:"previous_quantity"`
NewQuantity float64 `gorm:"type:decimal(12,3)" json:"new_quantity"`
UnitCost float64 `gorm:"type:decimal(12,2);default:0.00" json:"unit_cost"`
TotalCost float64 `gorm:"type:decimal(12,2);default:0.00" json:"total_cost"`
ReferenceType *InventoryMovementReferenceType `gorm:"size:50" json:"reference_type"`
ReferenceID *uuid.UUID `gorm:"type:uuid;index" json:"reference_id"`
OrderID *uuid.UUID `gorm:"type:uuid;index" json:"order_id"`
PaymentID *uuid.UUID `gorm:"type:uuid;index" json:"payment_id"`
UserID uuid.UUID `gorm:"type:uuid;not null;index" json:"user_id" validate:"required"`
Reason *string `gorm:"size:255" json:"reason"`
Notes *string `gorm:"type:text" json:"notes"`
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id" validate:"required"`
OutletID uuid.UUID `gorm:"type:uuid;not null;index" json:"outlet_id" validate:"required"`
ItemID uuid.UUID `gorm:"type:uuid;not null;index" json:"item_id" validate:"required"`
ItemType string `gorm:"not null;size:20" json:"item_type" validate:"required"` // "PRODUCT" or "INGREDIENT"
MovementType InventoryMovementType `gorm:"not null;size:50" json:"movement_type" validate:"required"`
Quantity float64 `gorm:"type:decimal(12,3);not null" json:"quantity" validate:"required"`
PreviousQuantity float64 `gorm:"type:decimal(12,3)" json:"previous_quantity"`
NewQuantity float64 `gorm:"type:decimal(12,3)" json:"new_quantity"`
UnitCost float64 `gorm:"type:decimal(12,2);default:0.00" json:"unit_cost"`
TotalCost float64 `gorm:"type:decimal(12,2);default:0.00" json:"total_cost"`
ReferenceType *InventoryMovementReferenceType `gorm:"size:50" json:"reference_type"`
ReferenceID *uuid.UUID `gorm:"type:uuid;index" json:"reference_id"`
PurchaseOrderItemID *uuid.UUID `gorm:"type:uuid;index" json:"purchase_order_item_id"`
OrderID *uuid.UUID `gorm:"type:uuid;index" json:"order_id"`
PaymentID *uuid.UUID `gorm:"type:uuid;index" json:"payment_id"`
UserID uuid.UUID `gorm:"type:uuid;not null;index" json:"user_id" validate:"required"`
Reason *string `gorm:"size:255" json:"reason"`
Notes *string `gorm:"type:text" json:"notes"`
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
Organization Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"`
Outlet Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"`
Product *Product `gorm:"foreignKey:ItemID" json:"product,omitempty"`
Ingredient *Ingredient `gorm:"foreignKey:ItemID" json:"ingredient,omitempty"`
Order *Order `gorm:"foreignKey:OrderID" json:"order,omitempty"`
Payment *Payment `gorm:"foreignKey:PaymentID" json:"payment,omitempty"`
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
Organization Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"`
Outlet Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"`
Product *Product `gorm:"foreignKey:ItemID" json:"product,omitempty"`
Ingredient *Ingredient `gorm:"foreignKey:ItemID" json:"ingredient,omitempty"`
PurchaseOrderItem *PurchaseOrderItem `gorm:"foreignKey:PurchaseOrderItemID" json:"purchase_order_item,omitempty"`
Order *Order `gorm:"foreignKey:OrderID" json:"order,omitempty"`
Payment *Payment `gorm:"foreignKey:PaymentID" json:"payment,omitempty"`
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
}
func (im *InventoryMovement) BeforeCreate(tx *gorm.DB) error {
+2 -2
View File
@@ -10,8 +10,8 @@ import (
type PurchaseCategoryType string
const (
PurchaseCategoryTypeRawMaterial PurchaseCategoryType = "raw_material"
PurchaseCategoryTypeNonInventory PurchaseCategoryType = "non_inventory"
PurchaseCategoryTypeRawMaterial PurchaseCategoryType = "raw_material"
PurchaseCategoryTypeExpense PurchaseCategoryType = "expense"
)
type PurchaseCategoryPreset struct {
+17 -13
View File
@@ -11,7 +11,8 @@ import (
type PurchaseOrder struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id" validate:"required"`
VendorID uuid.UUID `gorm:"type:uuid;not null" json:"vendor_id" validate:"required"`
OutletID *uuid.UUID `gorm:"type:uuid;index" json:"outlet_id" validate:"omitempty"`
VendorID *uuid.UUID `gorm:"type:uuid" json:"vendor_id" validate:"omitempty"`
PONumber string `gorm:"not null;size:50" json:"po_number" validate:"required,min=1,max=50"`
TransactionDate time.Time `gorm:"type:date;not null" json:"transaction_date" validate:"required"`
DueDate *time.Time `gorm:"type:date" json:"due_date" validate:"omitempty"`
@@ -23,6 +24,7 @@ type PurchaseOrder struct {
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
Organization *Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"`
Outlet *Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"`
Vendor *Vendor `gorm:"foreignKey:VendorID" json:"vendor,omitempty"`
Items []PurchaseOrderItem `gorm:"foreignKey:PurchaseOrderID" json:"items,omitempty"`
Attachments []PurchaseOrderAttachment `gorm:"foreignKey:PurchaseOrderID" json:"attachments,omitempty"`
@@ -41,19 +43,21 @@ 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"`
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"`
Unit *Unit `gorm:"foreignKey:UnitID" json:"unit,omitempty"`
PurchaseOrder *PurchaseOrder `gorm:"foreignKey:PurchaseOrderID" json:"purchase_order,omitempty"`
Ingredient *Ingredient `gorm:"foreignKey:IngredientID" json:"ingredient,omitempty"`
PurchaseCategory *PurchaseCategory `gorm:"foreignKey:PurchaseCategoryID" json:"purchase_category,omitempty"`
Unit *Unit `gorm:"foreignKey:UnitID" json:"unit,omitempty"`
}
func (poi *PurchaseOrderItem) BeforeCreate(tx *gorm.DB) error {
+7 -5
View File
@@ -13,10 +13,12 @@ import (
type UserRole string
const (
RoleAdmin UserRole = "admin"
RoleManager UserRole = "manager"
RoleCashier UserRole = "cashier"
RoleWaiter UserRole = "waiter"
RoleAdmin UserRole = "admin"
RoleManager UserRole = "manager"
RoleCashier UserRole = "cashier"
RoleWaiter UserRole = "waiter"
RoleOwner UserRole = "owner"
RolePurchasing UserRole = "purchasing"
)
type Permissions map[string]interface{}
@@ -46,7 +48,7 @@ type User struct {
Name string `gorm:"not null;size:255" json:"name" validate:"required,min=1,max=255"`
Email string `gorm:"uniqueIndex;not null;size:255" json:"email" validate:"required,email"`
PasswordHash string `gorm:"not null;size:255" json:"-"`
Role UserRole `gorm:"not null;size:50" json:"role" validate:"required,oneof=admin manager cashier waiter"`
Role UserRole `gorm:"not null;size:50" json:"role" validate:"required,oneof=admin manager cashier waiter owner purchasing"`
Permissions Permissions `gorm:"type:jsonb;default:'{}'" json:"permissions"`
IsActive bool `gorm:"default:true" json:"is_active"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
+84
View File
@@ -210,3 +210,87 @@ 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")
}
func (h *AnalyticsHandler) GetExclusiveSummaryMTD(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
var req contract.ExclusiveSummaryMTDRequest
if err := c.ShouldBindQuery(&req); err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetExclusiveSummaryMTD", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryMTD")
return
}
req.OrganizationID = contextInfo.OrganizationID
req.OutletID = h.resolveOutletID(c, contextInfo.OutletID)
modelReq, err := transformer.ExclusiveSummaryMTDContractToModel(&req)
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetExclusiveSummaryMTD", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryMTD")
return
}
response, err := h.analyticsService.GetExclusiveSummaryMTD(ctx, modelReq)
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "AnalyticsHandler::GetExclusiveSummaryMTD", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryMTD")
return
}
contractResp := transformer.ExclusiveSummaryPeriodModelToContract(response)
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetExclusiveSummaryMTD")
}
+32
View File
@@ -66,3 +66,35 @@ func (h *ReportHandler) GetDailyTransactionReportPDF(c *gin.Context) {
"file_name": fileName,
}), "ReportHandler::GetDailyTransactionReportPDF")
}
func (h *ReportHandler) GetProfitLossReportPDF(c *gin.Context) {
ctx := c.Request.Context()
ci := appcontext.FromGinContext(ctx)
outletID := h.resolveOutletID(c, ci.OutletID)
var dayPtr *time.Time
if d := c.Query("date"); d != "" {
if t, err := time.Parse("2006-01-02", d); err == nil {
dayPtr = &t
}
}
user, err := h.userService.GetUserByID(ctx, ci.UserID)
var genBy string
if err != nil {
genBy = ci.UserID.String()
} else {
genBy = user.Name
}
publicURL, fileName, err := h.reportService.GenerateProfitLossPDF(ctx, ci.OrganizationID.String(), outletID, dayPtr, genBy)
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "ReportHandler::GetProfitLossReportPDF", err.Error())}), "ReportHandler::GetProfitLossReportPDF")
return
}
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(map[string]string{
"url": publicURL,
"file_name": fileName,
}), "ReportHandler::GetProfitLossReportPDF")
}
+15 -8
View File
@@ -95,20 +95,27 @@ func ExpenseItemEntityToResponse(entity *entities.ExpenseItem) *models.ExpenseIt
}
response := &models.ExpenseItemResponse{
ID: entity.ID,
ExpenseID: entity.ExpenseID,
ChartOfAccountID: entity.ChartOfAccountID,
Item: entity.Item,
Description: entity.Description,
Amount: entity.Amount,
CreatedAt: entity.CreatedAt,
UpdatedAt: entity.UpdatedAt,
ID: entity.ID,
ExpenseID: entity.ExpenseID,
ChartOfAccountID: entity.ChartOfAccountID,
PurchaseCategoryID: entity.PurchaseCategoryID,
Item: entity.Item,
Description: entity.Description,
Amount: entity.Amount,
CreatedAt: entity.CreatedAt,
UpdatedAt: entity.UpdatedAt,
}
if entity.ChartOfAccount != nil {
response.ChartOfAccountName = entity.ChartOfAccount.Name
}
if entity.PurchaseCategory != nil {
response.PurchaseCategoryName = entity.PurchaseCategory.Name
response.PurchaseCategoryType = string(entity.PurchaseCategory.Type)
response.PurchaseCategory = PurchaseCategoryEntityToResponse(entity.PurchaseCategory)
}
return response
}
+37 -27
View File
@@ -13,6 +13,7 @@ func PurchaseOrderEntityToModel(entity *entities.PurchaseOrder) *models.Purchase
return &models.PurchaseOrder{
ID: entity.ID,
OrganizationID: entity.OrganizationID,
OutletID: entity.OutletID,
VendorID: entity.VendorID,
PONumber: entity.PONumber,
TransactionDate: entity.TransactionDate,
@@ -34,6 +35,7 @@ func PurchaseOrderModelToEntity(model *models.PurchaseOrder) *entities.PurchaseO
return &entities.PurchaseOrder{
ID: model.ID,
OrganizationID: model.OrganizationID,
OutletID: model.OutletID,
VendorID: model.VendorID,
PONumber: model.PONumber,
TransactionDate: model.TransactionDate,
@@ -55,6 +57,7 @@ func PurchaseOrderEntityToResponse(entity *entities.PurchaseOrder) *models.Purch
response := &models.PurchaseOrderResponse{
ID: entity.ID,
OrganizationID: entity.OrganizationID,
OutletID: entity.OutletID,
VendorID: entity.VendorID,
PONumber: entity.PONumber,
TransactionDate: entity.TransactionDate,
@@ -91,15 +94,16 @@ func PurchaseOrderItemEntityToModel(entity *entities.PurchaseOrderItem) *models.
}
return &models.PurchaseOrderItem{
ID: entity.ID,
PurchaseOrderID: entity.PurchaseOrderID,
IngredientID: entity.IngredientID,
Description: entity.Description,
Quantity: entity.Quantity,
UnitID: entity.UnitID,
Amount: entity.Amount,
CreatedAt: entity.CreatedAt,
UpdatedAt: entity.UpdatedAt,
ID: entity.ID,
PurchaseOrderID: entity.PurchaseOrderID,
IngredientID: entity.IngredientID,
PurchaseCategoryID: entity.PurchaseCategoryID,
Description: entity.Description,
Quantity: entity.Quantity,
UnitID: entity.UnitID,
Amount: entity.Amount,
CreatedAt: entity.CreatedAt,
UpdatedAt: entity.UpdatedAt,
}
}
@@ -109,15 +113,16 @@ func PurchaseOrderItemModelToEntity(model *models.PurchaseOrderItem) *entities.P
}
return &entities.PurchaseOrderItem{
ID: model.ID,
PurchaseOrderID: model.PurchaseOrderID,
IngredientID: model.IngredientID,
Description: model.Description,
Quantity: model.Quantity,
UnitID: model.UnitID,
Amount: model.Amount,
CreatedAt: model.CreatedAt,
UpdatedAt: model.UpdatedAt,
ID: model.ID,
PurchaseOrderID: model.PurchaseOrderID,
IngredientID: model.IngredientID,
PurchaseCategoryID: model.PurchaseCategoryID,
Description: model.Description,
Quantity: model.Quantity,
UnitID: model.UnitID,
Amount: model.Amount,
CreatedAt: model.CreatedAt,
UpdatedAt: model.UpdatedAt,
}
}
@@ -127,15 +132,16 @@ func PurchaseOrderItemEntityToResponse(entity *entities.PurchaseOrderItem) *mode
}
response := &models.PurchaseOrderItemResponse{
ID: entity.ID,
PurchaseOrderID: entity.PurchaseOrderID,
IngredientID: entity.IngredientID,
Description: entity.Description,
Quantity: entity.Quantity,
UnitID: entity.UnitID,
Amount: entity.Amount,
CreatedAt: entity.CreatedAt,
UpdatedAt: entity.UpdatedAt,
ID: entity.ID,
PurchaseOrderID: entity.PurchaseOrderID,
IngredientID: entity.IngredientID,
PurchaseCategoryID: entity.PurchaseCategoryID,
Description: entity.Description,
Quantity: entity.Quantity,
UnitID: entity.UnitID,
Amount: entity.Amount,
CreatedAt: entity.CreatedAt,
UpdatedAt: entity.UpdatedAt,
}
// Map ingredient if present
@@ -146,6 +152,10 @@ func PurchaseOrderItemEntityToResponse(entity *entities.PurchaseOrderItem) *mode
}
}
if entity.PurchaseCategory != nil {
response.PurchaseCategory = PurchaseCategoryEntityToResponse(entity.PurchaseCategory)
}
// Map unit if present
if entity.Unit != nil {
response.Unit = &models.UnitResponse{
+5 -1
View File
@@ -82,7 +82,11 @@ func (m *AuthMiddleware) RequireRole(allowedRoles ...string) gin.HandlerFunc {
}
func (m *AuthMiddleware) RequireAdminOrManager() gin.HandlerFunc {
return m.RequireRole("superadmin", "admin", "manager")
return m.RequireRole("superadmin", "admin", "manager", "owner", "purchasing")
}
func (m *AuthMiddleware) RequireAdminOrManagerOrPurchasing() gin.HandlerFunc {
return m.RequireRole("superadmin", "admin", "manager", "owner", "purchasing")
}
func (m *AuthMiddleware) RequireAdmin() gin.HandlerFunc {
+189 -19
View File
@@ -19,6 +19,7 @@ type PaymentMethodAnalyticsRequest struct {
type PaymentMethodAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"`
GroupBy string `json:"group_by"`
@@ -58,6 +59,7 @@ type SalesAnalyticsRequest struct {
type SalesAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"`
GroupBy string `json:"group_by"`
@@ -93,7 +95,7 @@ type PurchasingAnalyticsRequest struct {
OutletID *uuid.UUID `validate:"omitempty"`
DateFrom time.Time `validate:"required"`
DateTo time.Time `validate:"required"`
GroupBy string `validate:"omitempty,oneof=day hour week month"`
GroupBy string `validate:"omitempty,oneof=day hour week month outlet_id"`
}
// PurchasingAnalyticsResponse represents the response for purchasing analytics
@@ -106,6 +108,7 @@ type PurchasingAnalyticsResponse struct {
GroupBy string `json:"group_by"`
Summary PurchasingSummary `json:"summary"`
Data []PurchasingAnalyticsData `json:"data"`
OutletData []PurchasingOutletData `json:"outlet_data,omitempty"`
IngredientData []PurchasingIngredientData `json:"ingredient_data"`
VendorData []PurchasingVendorData `json:"vendor_data"`
}
@@ -113,7 +116,11 @@ type PurchasingAnalyticsResponse struct {
// PurchasingSummary represents the summary of purchasing analytics
type PurchasingSummary struct {
TotalPurchases float64 `json:"total_purchases"`
RawMaterialPurchases float64 `json:"raw_material_purchases"`
ExpensePurchases float64 `json:"expense_purchases"`
TotalPurchaseOrders int64 `json:"total_purchase_orders"`
RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"`
ExpenseCount int64 `json:"expense_count"`
TotalQuantity float64 `json:"total_quantity"`
AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"`
TotalIngredients int64 `json:"total_ingredients"`
@@ -122,12 +129,30 @@ type PurchasingSummary struct {
// PurchasingAnalyticsData represents purchasing analytics by time period
type PurchasingAnalyticsData struct {
Date time.Time `json:"date"`
Purchases float64 `json:"purchases"`
PurchaseOrders int64 `json:"purchase_orders"`
Quantity float64 `json:"quantity"`
Ingredients int64 `json:"ingredients"`
Vendors int64 `json:"vendors"`
Date time.Time `json:"date"`
Purchases float64 `json:"purchases"`
RawMaterialPurchases float64 `json:"raw_material_purchases"`
ExpensePurchases float64 `json:"expense_purchases"`
PurchaseOrders int64 `json:"purchase_orders"`
RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"`
ExpenseCount int64 `json:"expense_count"`
Quantity float64 `json:"quantity"`
Ingredients int64 `json:"ingredients"`
Vendors int64 `json:"vendors"`
}
type PurchasingOutletData struct {
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName string `json:"outlet_name"`
Purchases float64 `json:"purchases"`
RawMaterialPurchases float64 `json:"raw_material_purchases"`
ExpensePurchases float64 `json:"expense_purchases"`
PurchaseOrders int64 `json:"purchase_orders"`
RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"`
ExpenseCount int64 `json:"expense_count"`
Quantity float64 `json:"quantity"`
Ingredients int64 `json:"ingredients"`
Vendors int64 `json:"vendors"`
}
// PurchasingIngredientData represents purchasing analytics for an ingredient
@@ -142,12 +167,12 @@ type PurchasingIngredientData struct {
// PurchasingVendorData represents purchasing analytics for a vendor
type PurchasingVendorData struct {
VendorID uuid.UUID `json:"vendor_id"`
VendorName string `json:"vendor_name"`
TotalCost float64 `json:"total_cost"`
PurchaseOrderCount int64 `json:"purchase_order_count"`
IngredientCount int64 `json:"ingredient_count"`
Quantity float64 `json:"quantity"`
VendorID *uuid.UUID `json:"vendor_id"`
VendorName string `json:"vendor_name"`
TotalCost float64 `json:"total_cost"`
PurchaseOrderCount int64 `json:"purchase_order_count"`
IngredientCount int64 `json:"ingredient_count"`
Quantity float64 `json:"quantity"`
}
// ProductAnalyticsRequest represents the request for product analytics
@@ -163,6 +188,7 @@ type ProductAnalyticsRequest struct {
type ProductAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"`
Data []ProductAnalyticsData `json:"data"`
@@ -200,6 +226,7 @@ type ProductAnalyticsPerCategoryRequest struct {
type ProductAnalyticsPerCategoryResponse struct {
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"`
Data []ProductAnalyticsPerCategoryData `json:"data"`
@@ -229,6 +256,7 @@ type DashboardAnalyticsRequest struct {
type DashboardAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"`
Overview DashboardOverview `json:"overview"`
@@ -239,12 +267,15 @@ type DashboardAnalyticsResponse struct {
// DashboardOverview represents the overview data for dashboard
type DashboardOverview struct {
TotalSales float64 `json:"total_sales"`
TotalOrders int64 `json:"total_orders"`
AverageOrderValue float64 `json:"average_order_value"`
TotalCustomers int64 `json:"total_customers"`
VoidedOrders int64 `json:"voided_orders"`
RefundedOrders int64 `json:"refunded_orders"`
TotalSales float64 `json:"total_sales"`
TotalOrders int64 `json:"total_orders"`
AverageOrderValue float64 `json:"average_order_value"`
TotalCustomers int64 `json:"total_customers"`
VoidedOrders int64 `json:"voided_orders"`
RefundedOrders int64 `json:"refunded_orders"`
TotalItemSold int64 `json:"total_item_sold"`
TotalLowStock int64 `json:"total_low_stock"`
TotalProductActive int64 `json:"total_product_active"`
}
type ProfitLossAnalyticsRequest struct {
@@ -258,6 +289,7 @@ type ProfitLossAnalyticsRequest struct {
type ProfitLossAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"`
GroupBy string `json:"group_by"`
@@ -265,10 +297,28 @@ type ProfitLossAnalyticsResponse struct {
Data []ProfitLossData `json:"data"`
ProductData []ProductProfitData `json:"product_data"`
MainSummary []ProfitLossSummaryRow `json:"main_summary"`
Purchasing ProfitLossPurchasing `json:"purchasing"`
OperationalExpenses []OperationalExpenseItem `json:"operational_expenses"`
OperationalExpensesTotal float64 `json:"operational_expenses_total"`
}
type ProfitLossPurchasing struct {
TodayTotal float64 `json:"today_total"`
MtdTotal float64 `json:"mtd_total"`
TodayRawMaterial float64 `json:"today_raw_material"`
MtdRawMaterial float64 `json:"mtd_raw_material"`
TodayExpense float64 `json:"today_expense"`
MtdExpense float64 `json:"mtd_expense"`
Items []ProfitLossPurchasingItem `json:"items"`
}
type ProfitLossPurchasingItem struct {
Date time.Time `json:"date"`
Item string `json:"item"`
Quantity float64 `json:"quantity"`
Nominal float64 `json:"nominal"`
}
type ProfitLossSummary struct {
TotalRevenue float64 `json:"total_revenue"`
TotalCost float64 `json:"total_cost"`
@@ -326,3 +376,123 @@ 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 ExclusiveSummaryMTDRequest struct {
OrganizationID uuid.UUID `validate:"required"`
OutletID *uuid.UUID `validate:"omitempty"`
DateTo time.Time `validate:"required"`
ExcludeGajiStaffFromReimburse bool `validate:"omitempty"`
}
type ExclusiveSummaryPeriodResponse struct {
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,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"`
OutletName *string `json:"outlet_name,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"`
}
+51 -34
View File
@@ -23,14 +23,15 @@ type Expense struct {
}
type ExpenseItem struct {
ID uuid.UUID `json:"id"`
ExpenseID uuid.UUID `json:"expense_id"`
ChartOfAccountID uuid.UUID `json:"chart_of_account_id"`
Item string `json:"item"`
Description *string `json:"description"`
Amount float64 `json:"amount"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uuid.UUID `json:"id"`
ExpenseID uuid.UUID `json:"expense_id"`
ChartOfAccountID uuid.UUID `json:"chart_of_account_id"`
PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
Item string `json:"item"`
Description *string `json:"description"`
Amount float64 `json:"amount"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ExpenseResponse struct {
@@ -51,15 +52,19 @@ type ExpenseResponse struct {
}
type ExpenseItemResponse struct {
ID uuid.UUID `json:"id"`
ExpenseID uuid.UUID `json:"expense_id"`
ChartOfAccountID uuid.UUID `json:"chart_of_account_id"`
ChartOfAccountName string `json:"chart_of_account_name,omitempty"`
Item string `json:"item"`
Description *string `json:"description"`
Amount float64 `json:"amount"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uuid.UUID `json:"id"`
ExpenseID uuid.UUID `json:"expense_id"`
ChartOfAccountID uuid.UUID `json:"chart_of_account_id"`
ChartOfAccountName string `json:"chart_of_account_name,omitempty"`
PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
PurchaseCategoryName string `json:"purchase_category_name,omitempty"`
PurchaseCategoryType string `json:"purchase_category_type,omitempty"`
PurchaseCategory *PurchaseCategoryResponse `json:"purchase_category,omitempty"`
Item string `json:"item"`
Description *string `json:"description"`
Amount float64 `json:"amount"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type CreateExpenseRequest struct {
@@ -75,10 +80,11 @@ type CreateExpenseRequest struct {
}
type CreateExpenseItemRequest struct {
ChartOfAccountID string `json:"chart_of_account_id"`
Item string `json:"item"`
Description *string `json:"description,omitempty"`
Amount float64 `json:"amount"`
ChartOfAccountID string `json:"chart_of_account_id"`
PurchaseCategoryID string `json:"purchase_category_id"`
Item string `json:"item"`
Description *string `json:"description,omitempty"`
Amount float64 `json:"amount"`
}
type UpdateExpenseRequest struct {
@@ -95,10 +101,11 @@ type UpdateExpenseRequest struct {
}
type UpdateExpenseItemRequest struct {
ChartOfAccountID *string `json:"chart_of_account_id,omitempty"`
Item *string `json:"item,omitempty"`
Description *string `json:"description,omitempty"`
Amount *float64 `json:"amount,omitempty"`
ChartOfAccountID *string `json:"chart_of_account_id,omitempty"`
PurchaseCategoryID *string `json:"purchase_category_id,omitempty"`
Item *string `json:"item,omitempty"`
Description *string `json:"description,omitempty"`
Amount *float64 `json:"amount,omitempty"`
}
type ListExpenseRequest struct {
@@ -128,15 +135,16 @@ type ExpenseAnalyticsRequest struct {
}
type ExpenseAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"`
GroupBy string `json:"group_by"`
Summary ExpenseAnalyticsSummary `json:"summary"`
Data []ExpenseAnalyticsData `json:"data"`
CategoryData []ExpenseAnalyticsCategoryData `json:"category_data"`
ItemData []ExpenseAnalyticsItemData `json:"item_data"`
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"`
GroupBy string `json:"group_by"`
Summary ExpenseAnalyticsSummary `json:"summary"`
Data []ExpenseAnalyticsData `json:"data"`
CategoryData []ExpenseAnalyticsCategoryData `json:"category_data"`
ChartOfAccountData []ExpenseAnalyticsChartOfAccountData `json:"chart_of_account_data"`
ItemData []ExpenseAnalyticsItemData `json:"item_data"`
}
type ExpenseAnalyticsSummary struct {
@@ -158,6 +166,15 @@ type ExpenseAnalyticsData struct {
}
type ExpenseAnalyticsCategoryData struct {
PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
PurchaseCategoryName string `json:"purchase_category_name"`
PurchaseCategoryType string `json:"purchase_category_type"`
TotalAmount float64 `json:"total_amount"`
ExpenseCount int64 `json:"expense_count"`
ItemCount int64 `json:"item_count"`
}
type ExpenseAnalyticsChartOfAccountData struct {
ChartOfAccountID uuid.UUID `json:"chart_of_account_id"`
ChartOfAccountName string `json:"chart_of_account_name"`
TotalAmount float64 `json:"total_amount"`
+42 -34
View File
@@ -9,7 +9,8 @@ import (
type PurchaseOrder struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
VendorID uuid.UUID `json:"vendor_id"`
OutletID *uuid.UUID `json:"outlet_id"`
VendorID *uuid.UUID `json:"vendor_id"`
PONumber string `json:"po_number"`
TransactionDate time.Time `json:"transaction_date"`
DueDate *time.Time `json:"due_date"`
@@ -22,15 +23,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"`
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 {
@@ -43,7 +45,8 @@ type PurchaseOrderAttachment struct {
type PurchaseOrderResponse struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
VendorID uuid.UUID `json:"vendor_id"`
OutletID *uuid.UUID `json:"outlet_id"`
VendorID *uuid.UUID `json:"vendor_id"`
PONumber string `json:"po_number"`
TransactionDate time.Time `json:"transaction_date"`
DueDate *time.Time `json:"due_date"`
@@ -59,17 +62,19 @@ type PurchaseOrderResponse struct {
}
type PurchaseOrderItemResponse struct {
ID uuid.UUID `json:"id"`
PurchaseOrderID uuid.UUID `json:"purchase_order_id"`
IngredientID uuid.UUID `json:"ingredient_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"`
Ingredient *IngredientResponse `json:"ingredient,omitempty"`
Unit *UnitResponse `json:"unit,omitempty"`
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"`
Ingredient *IngredientResponse `json:"ingredient,omitempty"`
PurchaseCategory *PurchaseCategoryResponse `json:"purchase_category,omitempty"`
Unit *UnitResponse `json:"unit,omitempty"`
}
type PurchaseOrderAttachmentResponse struct {
@@ -81,7 +86,8 @@ type PurchaseOrderAttachmentResponse struct {
}
type CreatePurchaseOrderRequest struct {
VendorID uuid.UUID `json:"vendor_id"`
VendorID *uuid.UUID `json:"vendor_id,omitempty"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
PONumber string `json:"po_number"`
TransactionDate time.Time `json:"transaction_date"`
DueDate *time.Time `json:"due_date,omitempty"`
@@ -93,11 +99,12 @@ type CreatePurchaseOrderRequest struct {
}
type CreatePurchaseOrderItemRequest struct {
IngredientID uuid.UUID `json:"ingredient_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 {
@@ -113,12 +120,13 @@ type UpdatePurchaseOrderRequest struct {
}
type UpdatePurchaseOrderItemRequest struct {
ID *uuid.UUID `json:"id,omitempty"` // For existing items
IngredientID *uuid.UUID `json:"ingredient_id,omitempty"`
Description *string `json:"description,omitempty"`
Quantity *float64 `json:"quantity,omitempty"`
UnitID *uuid.UUID `json:"unit_id,omitempty"`
Amount *float64 `json:"amount,omitempty"`
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"`
Quantity *float64 `json:"quantity,omitempty"`
UnitID *uuid.UUID `json:"unit_id,omitempty"`
Amount *float64 `json:"amount,omitempty"`
}
type ListPurchaseOrdersRequest struct {
+6 -4
View File
@@ -63,10 +63,12 @@ type UserResponse struct {
func (u *User) HasPermission(requiredRole constants.UserRole) bool {
roleHierarchy := map[constants.UserRole]int{
constants.RoleWaiter: 1,
constants.RoleCashier: 2,
constants.RoleManager: 3,
constants.RoleAdmin: 4,
constants.RoleWaiter: 1,
constants.RoleCashier: 2,
constants.RolePurchasing: 3,
constants.RoleManager: 4,
constants.RoleAdmin: 5,
constants.RoleOwner: 6,
}
userLevel := roleHierarchy[u.Role]
+364 -15
View File
@@ -6,8 +6,11 @@ import (
"strings"
"time"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
"github.com/google/uuid"
)
type AnalyticsProcessor interface {
@@ -18,6 +21,9 @@ 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)
GetExclusiveSummaryMTD(ctx context.Context, req *models.ExclusiveSummaryMTDRequest) (*models.ExclusiveSummaryPeriodResponse, error)
}
type AnalyticsProcessorImpl struct {
@@ -32,6 +38,18 @@ func NewAnalyticsProcessorImpl(analyticsRepo repository.AnalyticsRepository, exp
}
}
// resolveOutletName fetches the outlet name from the database if outletID is provided
func (p *AnalyticsProcessorImpl) resolveOutletName(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) *string {
if outletID == nil {
return nil
}
name, err := p.analyticsRepo.GetOutletName(ctx, organizationID, *outletID)
if err != nil || name == "" {
return nil
}
return &name
}
func (p *AnalyticsProcessorImpl) GetPaymentMethodAnalytics(ctx context.Context, req *models.PaymentMethodAnalyticsRequest) (*models.PaymentMethodAnalyticsResponse, error) {
if req.DateFrom.After(req.DateTo) {
return nil, fmt.Errorf("date_from cannot be after date_to")
@@ -86,6 +104,7 @@ func (p *AnalyticsProcessorImpl) GetPaymentMethodAnalytics(ctx context.Context,
return &models.PaymentMethodAnalyticsResponse{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
DateFrom: req.DateFrom,
DateTo: req.DateTo,
GroupBy: req.GroupBy,
@@ -160,6 +179,7 @@ func (p *AnalyticsProcessorImpl) GetSalesAnalytics(ctx context.Context, req *mod
return &models.SalesAnalyticsResponse{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
DateFrom: req.DateFrom,
DateTo: req.DateTo,
GroupBy: req.GroupBy,
@@ -185,12 +205,33 @@ func (p *AnalyticsProcessorImpl) GetPurchasingAnalytics(ctx context.Context, req
data := make([]models.PurchasingAnalyticsData, len(result.Data))
for i, item := range result.Data {
data[i] = models.PurchasingAnalyticsData{
Date: item.Date,
Purchases: item.Purchases,
PurchaseOrders: item.PurchaseOrders,
Quantity: item.Quantity,
Ingredients: item.Ingredients,
Vendors: item.Vendors,
Date: item.Date,
Purchases: item.Purchases,
RawMaterialPurchases: item.RawMaterialPurchases,
ExpensePurchases: item.ExpensePurchases,
PurchaseOrders: item.PurchaseOrders,
RawMaterialPurchaseOrders: item.RawMaterialPurchaseOrders,
ExpenseCount: item.ExpenseCount,
Quantity: item.Quantity,
Ingredients: item.Ingredients,
Vendors: item.Vendors,
}
}
outletData := make([]models.PurchasingOutletData, len(result.OutletData))
for i, item := range result.OutletData {
outletData[i] = models.PurchasingOutletData{
OutletID: item.OutletID,
OutletName: item.OutletName,
Purchases: item.Purchases,
RawMaterialPurchases: item.RawMaterialPurchases,
ExpensePurchases: item.ExpensePurchases,
PurchaseOrders: item.PurchaseOrders,
RawMaterialPurchaseOrders: item.RawMaterialPurchaseOrders,
ExpenseCount: item.ExpenseCount,
Quantity: item.Quantity,
Ingredients: item.Ingredients,
Vendors: item.Vendors,
}
}
@@ -227,13 +268,18 @@ func (p *AnalyticsProcessorImpl) GetPurchasingAnalytics(ctx context.Context, req
GroupBy: req.GroupBy,
Summary: models.PurchasingSummary{
TotalPurchases: result.Summary.TotalPurchases,
RawMaterialPurchases: result.Summary.RawMaterialPurchases,
ExpensePurchases: result.Summary.ExpensePurchases,
TotalPurchaseOrders: result.Summary.TotalPurchaseOrders,
RawMaterialPurchaseOrders: result.Summary.RawMaterialPurchaseOrders,
ExpenseCount: result.Summary.ExpenseCount,
TotalQuantity: result.Summary.TotalQuantity,
AveragePurchaseOrderValue: result.Summary.AveragePurchaseOrderValue,
TotalIngredients: result.Summary.TotalIngredients,
TotalVendors: result.Summary.TotalVendors,
},
Data: data,
OutletData: outletData,
IngredientData: ingredientData,
VendorData: vendorData,
}, nil
@@ -283,6 +329,7 @@ func (p *AnalyticsProcessorImpl) GetProductAnalytics(ctx context.Context, req *m
return &models.ProductAnalyticsResponse{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
DateFrom: req.DateFrom,
DateTo: req.DateTo,
Data: resultData,
@@ -320,6 +367,7 @@ func (p *AnalyticsProcessorImpl) GetProductAnalyticsPerCategory(ctx context.Cont
return &models.ProductAnalyticsPerCategoryResponse{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
DateFrom: req.DateFrom,
DateTo: req.DateTo,
Data: resultData,
@@ -381,15 +429,19 @@ func (p *AnalyticsProcessorImpl) GetDashboardAnalytics(ctx context.Context, req
return &models.DashboardAnalyticsResponse{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
DateFrom: req.DateFrom,
DateTo: req.DateTo,
Overview: models.DashboardOverview{
TotalSales: overview.TotalSales,
TotalOrders: overview.TotalOrders,
AverageOrderValue: overview.AverageOrderValue,
TotalCustomers: overview.TotalCustomers,
VoidedOrders: overview.VoidedOrders,
RefundedOrders: overview.RefundedOrders,
TotalSales: overview.TotalSales,
TotalOrders: overview.TotalOrders,
AverageOrderValue: overview.AverageOrderValue,
TotalCustomers: overview.TotalCustomers,
VoidedOrders: overview.VoidedOrders,
RefundedOrders: overview.RefundedOrders,
TotalItemSold: overview.TotalItemSold,
TotalLowStock: overview.TotalLowStock,
TotalProductActive: overview.TotalProductActive,
},
TopProducts: topProducts.Data,
PaymentMethods: paymentMethods.Data,
@@ -592,9 +644,20 @@ func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req
opsTotal += item.Amount
}
purchasingItems := make([]models.ProfitLossPurchasingItem, len(result.PurchasingItems))
for i, item := range result.PurchasingItems {
purchasingItems[i] = models.ProfitLossPurchasingItem{
Date: item.Date,
Item: item.Item,
Quantity: item.Quantity,
Nominal: item.Amount,
}
}
return &models.ProfitLossAnalyticsResponse{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
DateFrom: req.DateFrom,
DateTo: req.DateTo,
GroupBy: req.GroupBy,
@@ -611,9 +674,18 @@ func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req
AverageProfit: result.Summary.AverageProfit,
ProfitabilityRatio: result.Summary.ProfitabilityRatio,
},
Data: data,
ProductData: productData,
MainSummary: mainSummary,
Data: data,
ProductData: productData,
MainSummary: mainSummary,
Purchasing: models.ProfitLossPurchasing{
TodayTotal: result.TodayPurchasing,
MtdTotal: result.MtdPurchasing,
TodayRawMaterial: result.TodayPurchasingRawMaterial,
MtdRawMaterial: result.MtdPurchasingRawMaterial,
TodayExpense: result.TodayPurchasingExpense,
MtdExpense: result.MtdPurchasingExpense,
Items: purchasingItems,
},
OperationalExpenses: opsItems,
OperationalExpensesTotal: opsTotal,
}, nil
@@ -643,3 +715,280 @@ func slugify(s string) string {
}
return string(result)
}
func (p *AnalyticsProcessorImpl) GetExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error) {
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) {
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
}
periods := make([]models.ExclusiveSummaryMonthlyPeriod, 0)
for _, bucket := range buildExclusiveSummaryMonthlyBuckets(monthStart) {
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
}
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: percentage(period.Summary.GrossProfit, period.Summary.Sales),
})
}
bankBalances, err := p.analyticsRepo.GetExclusiveSummaryBankBalances(ctx, req.OrganizationID, req.OutletID)
if err != nil {
return nil, fmt.Errorf("failed to get exclusive summary bank balances: %w", err)
}
bankBalance := make([]models.ExclusiveSummaryBankBalance, len(bankBalances))
for i, item := range bankBalances {
bankBalance[i] = models.ExclusiveSummaryBankBalance{
Bank: item.Bank,
OpeningBalance: item.OpeningBalance,
IncomingMutation: item.IncomingMutation,
OutgoingMutation: item.OutgoingMutation,
ClosingBalance: item.ClosingBalance,
Notes: item.Notes,
}
}
return &models.ExclusiveSummaryMonthlyResponse{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
OutletName: p.resolveOutletName(ctx, req.OrganizationID, 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: bankBalance,
}, nil
}
func (p *AnalyticsProcessorImpl) GetExclusiveSummaryMTD(ctx context.Context, req *models.ExclusiveSummaryMTDRequest) (*models.ExclusiveSummaryPeriodResponse, error) {
mtdStart := time.Date(req.DateTo.Year(), req.DateTo.Month(), 1, 0, 0, 0, 0, req.DateTo.Location())
return p.buildExclusiveSummaryPeriod(ctx, &models.ExclusiveSummaryPeriodRequest{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
DateFrom: mtdStart,
DateTo: req.DateTo,
ExcludeGajiStaffFromReimburse: req.ExcludeGajiStaffFromReimburse,
})
}
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,
OutletName: p.resolveOutletName(ctx, req.OrganizationID, 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 !isExclusiveSummarySalary(transaction.CategoryCode, transaction.CategoryName, transaction.Description) {
continue
}
classification := strings.ToLower(transaction.CategoryCode + " " + transaction.CategoryName + " " + transaction.Description)
switch {
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
}
}
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]
}
+256 -8
View File
@@ -13,9 +13,14 @@ import (
)
type analyticsRepositoryStub struct {
purchasingResult *entities.PurchasingAnalytics
profitLossResult *entities.ProfitLossAnalytics
profitLossGroup string
purchasingResult *entities.PurchasingAnalytics
profitLossResult *entities.ProfitLossAnalytics
exclusiveSummaryResults []*entities.ExclusiveSummaryAnalytics
bankBalances []entities.ExclusiveSummaryBankBalance
profitLossGroup string
exclusiveSummaryCalls int
exclusiveSummaryFrom []time.Time
exclusiveSummaryTo []time.Time
}
func (analyticsRepositoryStub) GetPaymentMethodAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.PaymentMethodAnalytics, error) {
@@ -47,6 +52,26 @@ func (s analyticsRepositoryStub) GetProfitLossAnalytics(_ context.Context, _ uui
return s.profitLossResult, nil
}
func (s *analyticsRepositoryStub) GetExclusiveSummaryAnalytics(_ context.Context, _ uuid.UUID, _ *uuid.UUID, dateFrom, dateTo time.Time) (*entities.ExclusiveSummaryAnalytics, error) {
s.exclusiveSummaryFrom = append(s.exclusiveSummaryFrom, dateFrom)
s.exclusiveSummaryTo = append(s.exclusiveSummaryTo, dateTo)
if s.exclusiveSummaryCalls < len(s.exclusiveSummaryResults) {
result := s.exclusiveSummaryResults[s.exclusiveSummaryCalls]
s.exclusiveSummaryCalls++
return result, nil
}
s.exclusiveSummaryCalls++
return &entities.ExclusiveSummaryAnalytics{}, nil
}
func (s *analyticsRepositoryStub) GetExclusiveSummaryBankBalances(context.Context, uuid.UUID, *uuid.UUID) ([]entities.ExclusiveSummaryBankBalance, error) {
return s.bankBalances, nil
}
func (analyticsRepositoryStub) GetOutletName(context.Context, uuid.UUID, uuid.UUID) (string, error) {
return "", nil
}
type expenseRepositoryStub struct{}
func (expenseRepositoryStub) Create(context.Context, *entities.Expense) error { return nil }
@@ -71,11 +96,27 @@ func TestAnalyticsProcessorGetPurchasingAnalyticsPassesOutletName(t *testing.T)
outletID := uuid.New()
outletName := "Main Outlet"
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
processor := NewAnalyticsProcessorImpl(analyticsRepositoryStub{
processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{
purchasingResult: &entities.PurchasingAnalytics{
OutletName: &outletName,
Summary: entities.PurchasingSummary{
TotalPurchases: 125,
TotalPurchases: 300,
RawMaterialPurchases: 125,
ExpensePurchases: 175,
TotalPurchaseOrders: 3,
RawMaterialPurchaseOrders: 1,
ExpenseCount: 2,
},
Data: []entities.PurchasingAnalyticsData{
{
Date: now,
Purchases: 300,
RawMaterialPurchases: 125,
ExpensePurchases: 175,
PurchaseOrders: 3,
RawMaterialPurchaseOrders: 1,
ExpenseCount: 2,
},
},
},
}, expenseRepositoryStub{})
@@ -92,14 +133,69 @@ func TestAnalyticsProcessorGetPurchasingAnalyticsPassesOutletName(t *testing.T)
require.Equal(t, &outletID, result.OutletID)
require.NotNil(t, result.OutletName)
require.Equal(t, outletName, *result.OutletName)
require.Equal(t, float64(125), result.Summary.TotalPurchases)
require.Equal(t, float64(300), result.Summary.TotalPurchases)
require.Equal(t, float64(125), result.Summary.RawMaterialPurchases)
require.Equal(t, float64(175), result.Summary.ExpensePurchases)
require.Equal(t, int64(3), result.Summary.TotalPurchaseOrders)
require.Equal(t, int64(1), result.Summary.RawMaterialPurchaseOrders)
require.Equal(t, int64(2), result.Summary.ExpenseCount)
require.Len(t, result.Data, 1)
require.Equal(t, float64(300), result.Data[0].Purchases)
require.Equal(t, float64(125), result.Data[0].RawMaterialPurchases)
require.Equal(t, float64(175), result.Data[0].ExpensePurchases)
}
func TestAnalyticsProcessorGetPurchasingAnalyticsMapsOutletData(t *testing.T) {
outletID := uuid.New()
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{
purchasingResult: &entities.PurchasingAnalytics{
Summary: entities.PurchasingSummary{
TotalPurchases: 500,
},
OutletData: []entities.PurchasingOutletData{
{
OutletID: &outletID,
OutletName: "Outlet A",
Purchases: 500,
RawMaterialPurchases: 350,
ExpensePurchases: 150,
PurchaseOrders: 4,
RawMaterialPurchaseOrders: 3,
ExpenseCount: 2,
Quantity: 10,
Ingredients: 5,
Vendors: 2,
},
},
},
}, expenseRepositoryStub{})
result, err := processor.GetPurchasingAnalytics(context.Background(), &models.PurchasingAnalyticsRequest{
OrganizationID: uuid.New(),
DateFrom: now,
DateTo: now,
GroupBy: "outlet_id",
})
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, "outlet_id", result.GroupBy)
require.Empty(t, result.Data)
require.Len(t, result.OutletData, 1)
require.Equal(t, &outletID, result.OutletData[0].OutletID)
require.Equal(t, "Outlet A", result.OutletData[0].OutletName)
require.Equal(t, float64(500), result.OutletData[0].Purchases)
require.Equal(t, float64(350), result.OutletData[0].RawMaterialPurchases)
require.Equal(t, float64(150), result.OutletData[0].ExpensePurchases)
require.Equal(t, int64(4), result.OutletData[0].PurchaseOrders)
}
func TestAnalyticsProcessorGetProfitLossAnalyticsMapsOverviewAndReportFields(t *testing.T) {
productID := uuid.New()
categoryID := uuid.New()
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
processor := NewAnalyticsProcessorImpl(analyticsRepositoryStub{
processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{
profitLossResult: &entities.ProfitLossAnalytics{
Summary: entities.ProfitLossSummary{
TotalRevenue: 1000,
@@ -171,7 +267,7 @@ func TestAnalyticsProcessorGetProfitLossAnalyticsMapsOverviewAndReportFields(t *
func TestAnalyticsProcessorGetProfitLossAnalyticsDynamicExpenseCategories(t *testing.T) {
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
processor := NewAnalyticsProcessorImpl(analyticsRepositoryStub{
processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{
profitLossResult: &entities.ProfitLossAnalytics{
Summary: entities.ProfitLossSummary{
TotalRevenue: 10000,
@@ -248,3 +344,155 @@ func TestAnalyticsProcessorGetProfitLossAnalyticsDynamicExpenseCategories(t *tes
require.Equal(t, float64(7400), result.MainSummary[6].MtdNominal)
require.True(t, result.MainSummary[6].IsBold)
}
func TestAnalyticsProcessorGetExclusiveSummaryPeriodCalculatesTotalsAndReimburse(t *testing.T) {
now := time.Date(2026, 5, 26, 0, 0, 0, 0, time.UTC)
processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{
exclusiveSummaryResults: []*entities.ExclusiveSummaryAnalytics{
{
SalesTotal: 1000,
HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{
{CategoryCode: "RAW", CategoryName: "Raw", Amount: 400},
},
OperationalExpenseBreakdown: []entities.ExclusiveSummaryCategoryTotal{
{CategoryCode: "GAJI", CategoryName: "Gaji", Amount: 250},
{CategoryCode: "OPS", CategoryName: "Operasional", Amount: 100},
},
DailySummary: []entities.ExclusiveSummaryDailySummary{
{Date: now, TransactionCount: 3, TotalCost: 750},
},
DailyTransactions: []entities.ExclusiveSummaryDailyTransaction{
{Date: now, CategoryCode: "RAW", CategoryName: "Raw", Description: "beras", Amount: 400, Source: "purchase_order"},
{Date: now, CategoryCode: "GAJI", CategoryName: "Gaji", Description: "gaji karyawan", Amount: 200, Source: "purchase_order"},
{Date: now, CategoryCode: "GAJI", CategoryName: "Gaji", Description: "DW", Amount: 50, Source: "purchase_order"},
{Date: now, CategoryCode: "OPS", CategoryName: "Operasional", Description: "atk", Amount: 100, Source: "purchase_order"},
},
},
},
}, 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(1000), result.Summary.Sales)
require.Equal(t, float64(400), result.Summary.HPP)
require.Equal(t, float64(600), result.Summary.GrossProfit)
require.Equal(t, float64(350), result.Summary.OperationalExpensesTotal)
require.Equal(t, float64(750), result.Summary.TotalCost)
require.Equal(t, float64(250), result.Summary.NetProfit)
require.Equal(t, float64(250), result.Summary.SalaryTotal)
require.Equal(t, float64(50), result.Summary.SalaryDW)
require.Equal(t, float64(200), result.Summary.SalaryStaff)
require.Equal(t, float64(100), result.Summary.OtherOperationalExpenses)
require.Equal(t, float64(200), result.Reimburse.ExcludedSalaryStaff)
require.Equal(t, float64(550), result.Reimburse.TotalReimburse)
require.Len(t, result.HPPBreakdown, 1)
require.Equal(t, float64(100), result.HPPBreakdown[0].Percentage)
require.Len(t, result.DailySummary, 1)
require.Len(t, result.DailyTransactions, 4)
}
func TestAnalyticsProcessorGetExclusiveSummaryMonthlyBuildsSummaryAndBuckets(t *testing.T) {
location, err := time.LoadLocation("Asia/Jakarta")
require.NoError(t, err)
month := time.Date(2026, 5, 1, 0, 0, 0, 0, location)
openingBalance := 5000000.0
closingBalance := 5000000.0
notes := "Main cash account for daily transactions"
stub := &analyticsRepositoryStub{
exclusiveSummaryResults: []*entities.ExclusiveSummaryAnalytics{
{SalesTotal: 1000, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 400}}, OperationalExpenseBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 100}}},
{SalesTotal: 100, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 40}}},
{SalesTotal: 200, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 80}}},
{SalesTotal: 300, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 120}}},
{SalesTotal: 400, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 160}}},
{SalesTotal: 500, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 200}}},
},
bankBalances: []entities.ExclusiveSummaryBankBalance{
{Bank: "Cash and Bank", OpeningBalance: &openingBalance, ClosingBalance: &closingBalance, Notes: &notes},
},
}
processor := NewAnalyticsProcessorImpl(stub, 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(400), result.Summary.HPP)
require.Equal(t, float64(500), result.Summary.NetProfit)
require.InDelta(t, float64(50), result.Summary.NetProfitMargin, 0.0001)
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, 1)
require.Equal(t, "Cash and Bank", result.BankBalance[0].Bank)
require.NotNil(t, result.BankBalance[0].OpeningBalance)
require.Equal(t, openingBalance, *result.BankBalance[0].OpeningBalance)
require.NotNil(t, result.BankBalance[0].ClosingBalance)
require.Equal(t, closingBalance, *result.BankBalance[0].ClosingBalance)
require.Nil(t, result.BankBalance[0].IncomingMutation)
require.Nil(t, result.BankBalance[0].OutgoingMutation)
require.NotNil(t, result.BankBalance[0].Notes)
require.Equal(t, notes, *result.BankBalance[0].Notes)
require.Equal(t, 6, stub.exclusiveSummaryCalls)
}
func TestAnalyticsProcessorGetExclusiveSummaryMTDBuildsMonthToDateBreakdown(t *testing.T) {
location, err := time.LoadLocation("Asia/Jakarta")
require.NoError(t, err)
dateTo := time.Date(2026, 6, 18, 23, 59, 59, int(time.Second-time.Nanosecond), location)
stub := &analyticsRepositoryStub{
exclusiveSummaryResults: []*entities.ExclusiveSummaryAnalytics{
{
SalesTotal: 1000,
HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{
{CategoryCode: "RAW", CategoryName: "Raw Material", Amount: 400},
},
OperationalExpenseBreakdown: []entities.ExclusiveSummaryCategoryTotal{
{CategoryCode: "OPS", CategoryName: "Operational", Amount: 100},
},
DailySummary: []entities.ExclusiveSummaryDailySummary{
{Date: dateTo, TransactionCount: 2, TotalCost: 500},
},
DailyTransactions: []entities.ExclusiveSummaryDailyTransaction{
{Date: dateTo, CategoryCode: "RAW", CategoryName: "Raw Material", Description: "beras", Amount: 400, Source: "purchase_order"},
{Date: dateTo, CategoryCode: "OPS", CategoryName: "Operational", Description: "atk", Amount: 100, Source: "expense"},
},
},
},
}
processor := NewAnalyticsProcessorImpl(stub, expenseRepositoryStub{})
result, err := processor.GetExclusiveSummaryMTD(context.Background(), &models.ExclusiveSummaryMTDRequest{
OrganizationID: uuid.New(),
DateTo: dateTo,
})
require.NoError(t, err)
require.NotNil(t, result)
require.Len(t, stub.exclusiveSummaryFrom, 1)
require.Equal(t, time.Date(2026, 6, 1, 0, 0, 0, 0, location), stub.exclusiveSummaryFrom[0])
require.Equal(t, dateTo, stub.exclusiveSummaryTo[0])
require.Equal(t, stub.exclusiveSummaryFrom[0], result.Period.DateFrom)
require.Equal(t, dateTo, result.Period.DateTo)
require.Equal(t, float64(1000), result.Summary.Sales)
require.Equal(t, float64(400), result.Summary.HPP)
require.Equal(t, float64(500), result.Summary.TotalCost)
require.Equal(t, float64(500), result.Summary.NetProfit)
require.Len(t, result.HPPBreakdown, 1)
require.Equal(t, float64(100), result.HPPBreakdown[0].Percentage)
require.Len(t, result.OperationalExpenseBreakdown, 1)
require.Len(t, result.DailySummary, 1)
require.Len(t, result.DailyTransactions, 2)
}
+94 -33
View File
@@ -23,12 +23,14 @@ type ExpenseProcessor interface {
}
type ExpenseProcessorImpl struct {
expenseRepo ExpenseRepository
expenseRepo ExpenseRepository
purchaseCategoryRepo PurchaseCategoryRepository
}
func NewExpenseProcessorImpl(expenseRepo ExpenseRepository) *ExpenseProcessorImpl {
func NewExpenseProcessorImpl(expenseRepo ExpenseRepository, purchaseCategoryRepo PurchaseCategoryRepository) *ExpenseProcessorImpl {
return &ExpenseProcessorImpl{
expenseRepo: expenseRepo,
expenseRepo: expenseRepo,
purchaseCategoryRepo: purchaseCategoryRepo,
}
}
@@ -48,6 +50,30 @@ func (p *ExpenseProcessorImpl) CreateExpense(ctx context.Context, organizationID
status = *req.Status
}
items := make([]entities.ExpenseItem, len(req.Items))
for i, itemReq := range req.Items {
chartOfAccountID, err := uuid.Parse(itemReq.ChartOfAccountID)
if err != nil {
return nil, fmt.Errorf("invalid chart_of_account_id for item: %w", err)
}
purchaseCategoryID, err := uuid.Parse(itemReq.PurchaseCategoryID)
if err != nil {
return nil, fmt.Errorf("invalid purchase_category_id for item: %w", err)
}
if err := p.validateExpensePurchaseCategory(ctx, purchaseCategoryID, organizationID); err != nil {
return nil, err
}
items[i] = entities.ExpenseItem{
ChartOfAccountID: chartOfAccountID,
PurchaseCategoryID: purchaseCategoryID,
Item: itemReq.Item,
Description: itemReq.Description,
Amount: itemReq.Amount,
}
}
expenseEntity := &entities.Expense{
OrganizationID: organizationID,
OutletID: outletID,
@@ -65,21 +91,10 @@ func (p *ExpenseProcessorImpl) CreateExpense(ctx context.Context, organizationID
return nil, fmt.Errorf("failed to create expense: %w", err)
}
for _, itemReq := range req.Items {
chartOfAccountID, err := uuid.Parse(itemReq.ChartOfAccountID)
if err != nil {
return nil, fmt.Errorf("invalid chart_of_account_id for item: %w", err)
}
for i := range items {
items[i].ExpenseID = expenseEntity.ID
itemEntity := &entities.ExpenseItem{
ExpenseID: expenseEntity.ID,
ChartOfAccountID: chartOfAccountID,
Item: itemReq.Item,
Description: itemReq.Description,
Amount: itemReq.Amount,
}
err = p.expenseRepo.CreateItem(ctx, itemEntity)
err = p.expenseRepo.CreateItem(ctx, &items[i])
if err != nil {
return nil, fmt.Errorf("failed to create expense item: %w", err)
}
@@ -135,13 +150,10 @@ func (p *ExpenseProcessorImpl) UpdateExpense(ctx context.Context, id, organizati
expenseEntity.Reserved1 = req.Reserved1
}
var items []entities.ExpenseItem
if req.Items != nil {
err = p.expenseRepo.DeleteItemsByExpenseID(ctx, expenseEntity.ID)
if err != nil {
return nil, fmt.Errorf("failed to delete existing items: %w", err)
}
for _, itemReq := range req.Items {
items = make([]entities.ExpenseItem, len(req.Items))
for i, itemReq := range req.Items {
chartOfAccountID := uuid.Nil
if itemReq.ChartOfAccountID != nil {
chartOfAccountID, err = uuid.Parse(*itemReq.ChartOfAccountID)
@@ -150,6 +162,17 @@ func (p *ExpenseProcessorImpl) UpdateExpense(ctx context.Context, id, organizati
}
}
if itemReq.PurchaseCategoryID == nil {
return nil, fmt.Errorf("purchase_category_id is required for item")
}
purchaseCategoryID, err := uuid.Parse(*itemReq.PurchaseCategoryID)
if err != nil {
return nil, fmt.Errorf("invalid purchase_category_id for item: %w", err)
}
if err := p.validateExpensePurchaseCategory(ctx, purchaseCategoryID, organizationID); err != nil {
return nil, err
}
amount := 0.0
if itemReq.Amount != nil {
amount = *itemReq.Amount
@@ -159,15 +182,23 @@ func (p *ExpenseProcessorImpl) UpdateExpense(ctx context.Context, id, organizati
item = *itemReq.Item
}
itemEntity := &entities.ExpenseItem{
ExpenseID: expenseEntity.ID,
ChartOfAccountID: chartOfAccountID,
Item: item,
Description: itemReq.Description,
Amount: amount,
items[i] = entities.ExpenseItem{
ExpenseID: expenseEntity.ID,
ChartOfAccountID: chartOfAccountID,
PurchaseCategoryID: purchaseCategoryID,
Item: item,
Description: itemReq.Description,
Amount: amount,
}
}
err = p.expenseRepo.CreateItem(ctx, itemEntity)
err = p.expenseRepo.DeleteItemsByExpenseID(ctx, expenseEntity.ID)
if err != nil {
return nil, fmt.Errorf("failed to delete existing items: %w", err)
}
for i := range items {
err = p.expenseRepo.CreateItem(ctx, &items[i])
if err != nil {
return nil, fmt.Errorf("failed to create expense item: %w", err)
}
@@ -252,6 +283,18 @@ func (p *ExpenseProcessorImpl) GetExpenseAnalytics(ctx context.Context, req *mod
categoryData := make([]models.ExpenseAnalyticsCategoryData, len(result.CategoryData))
for i, item := range result.CategoryData {
categoryData[i] = models.ExpenseAnalyticsCategoryData{
PurchaseCategoryID: item.PurchaseCategoryID,
PurchaseCategoryName: item.PurchaseCategoryName,
PurchaseCategoryType: item.PurchaseCategoryType,
TotalAmount: item.TotalAmount,
ExpenseCount: item.ExpenseCount,
ItemCount: item.ItemCount,
}
}
chartOfAccountData := make([]models.ExpenseAnalyticsChartOfAccountData, len(result.ChartOfAccountData))
for i, item := range result.ChartOfAccountData {
chartOfAccountData[i] = models.ExpenseAnalyticsChartOfAccountData{
ChartOfAccountID: item.ChartOfAccountID,
ChartOfAccountName: item.ChartOfAccountName,
TotalAmount: item.TotalAmount,
@@ -284,8 +327,26 @@ func (p *ExpenseProcessorImpl) GetExpenseAnalytics(ctx context.Context, req *mod
TotalCategories: result.Summary.TotalCategories,
TotalItems: result.Summary.TotalItems,
},
Data: data,
CategoryData: categoryData,
ItemData: itemData,
Data: data,
CategoryData: categoryData,
ChartOfAccountData: chartOfAccountData,
ItemData: itemData,
}, nil
}
func (p *ExpenseProcessorImpl) validateExpensePurchaseCategory(ctx context.Context, categoryID, organizationID uuid.UUID) error {
category, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, categoryID, organizationID)
if err != nil {
return fmt.Errorf("purchase category not found: %w", err)
}
if !category.IsActive {
return fmt.Errorf("purchase category is inactive")
}
if category.Type != entities.PurchaseCategoryTypeExpense {
return fmt.Errorf("purchase category must be expense")
}
return nil
}
+99 -14
View File
@@ -18,6 +18,45 @@ type expenseRepositoryCaptureStub struct {
analytics *entities.ExpenseAnalytics
}
type expensePurchaseCategoryRepositoryStub struct {
category *entities.PurchaseCategory
}
func (*expensePurchaseCategoryRepositoryStub) Create(context.Context, *entities.PurchaseCategory) error {
return nil
}
func (s *expensePurchaseCategoryRepositoryStub) GetByIDAndOrganizationID(context.Context, uuid.UUID, uuid.UUID) (*entities.PurchaseCategory, error) {
return s.category, nil
}
func (*expensePurchaseCategoryRepositoryStub) Update(context.Context, *entities.PurchaseCategory) error {
return nil
}
func (*expensePurchaseCategoryRepositoryStub) SoftDelete(context.Context, uuid.UUID, uuid.UUID) error {
return nil
}
func (*expensePurchaseCategoryRepositoryStub) List(context.Context, uuid.UUID, map[string]interface{}, int, int) ([]*entities.PurchaseCategory, int64, error) {
return nil, 0, nil
}
func (*expensePurchaseCategoryRepositoryStub) ExistsByCode(context.Context, uuid.UUID, string, *uuid.UUID) (bool, error) {
return false, nil
}
func newExpensePurchaseCategoryRepo(categoryID uuid.UUID, categoryType entities.PurchaseCategoryType) *expensePurchaseCategoryRepositoryStub {
return &expensePurchaseCategoryRepositoryStub{
category: &entities.PurchaseCategory{
ID: categoryID,
Name: "Operational",
Type: categoryType,
IsActive: true,
},
}
}
func (s *expenseRepositoryCaptureStub) Create(_ context.Context, expense *entities.Expense) error {
if expense.ID == uuid.Nil {
expense.ID = uuid.New()
@@ -62,7 +101,8 @@ func (*expenseRepositoryCaptureStub) DeleteItemsByExpenseID(context.Context, uui
func TestExpenseProcessorCreatePersistsItemName(t *testing.T) {
repo := &expenseRepositoryCaptureStub{}
p := NewExpenseProcessorImpl(repo)
purchaseCategoryID := uuid.New()
p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense))
chartOfAccountID := uuid.New()
resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{
@@ -73,9 +113,10 @@ func TestExpenseProcessorCreatePersistsItemName(t *testing.T) {
Total: 10000,
Items: []models.CreateExpenseItemRequest{
{
ChartOfAccountID: chartOfAccountID.String(),
Item: "Cleaning supplies",
Amount: 10000,
ChartOfAccountID: chartOfAccountID.String(),
PurchaseCategoryID: purchaseCategoryID.String(),
Item: "Cleaning supplies",
Amount: 10000,
},
},
})
@@ -84,13 +125,15 @@ func TestExpenseProcessorCreatePersistsItemName(t *testing.T) {
require.NotNil(t, resp)
require.Len(t, repo.createdItems, 1)
require.Equal(t, "Cleaning supplies", repo.createdItems[0].Item)
require.Equal(t, purchaseCategoryID, repo.createdItems[0].PurchaseCategoryID)
require.Len(t, resp.Items, 1)
require.Equal(t, "Cleaning supplies", resp.Items[0].Item)
}
func TestExpenseProcessorCreateDefaultsStatusToDraft(t *testing.T) {
repo := &expenseRepositoryCaptureStub{}
p := NewExpenseProcessorImpl(repo)
purchaseCategoryID := uuid.New()
p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense))
resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{
Receiver: "Cashier",
@@ -100,9 +143,10 @@ func TestExpenseProcessorCreateDefaultsStatusToDraft(t *testing.T) {
Total: 10000,
Items: []models.CreateExpenseItemRequest{
{
ChartOfAccountID: uuid.NewString(),
Item: "Cleaning supplies",
Amount: 10000,
ChartOfAccountID: uuid.NewString(),
PurchaseCategoryID: purchaseCategoryID.String(),
Item: "Cleaning supplies",
Amount: 10000,
},
},
})
@@ -115,7 +159,8 @@ func TestExpenseProcessorCreateDefaultsStatusToDraft(t *testing.T) {
func TestExpenseProcessorCreatePersistsProvidedStatus(t *testing.T) {
repo := &expenseRepositoryCaptureStub{}
p := NewExpenseProcessorImpl(repo)
purchaseCategoryID := uuid.New()
p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense))
status := "approved"
resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{
@@ -127,9 +172,10 @@ func TestExpenseProcessorCreatePersistsProvidedStatus(t *testing.T) {
Total: 10000,
Items: []models.CreateExpenseItemRequest{
{
ChartOfAccountID: uuid.NewString(),
Item: "Cleaning supplies",
Amount: 10000,
ChartOfAccountID: uuid.NewString(),
PurchaseCategoryID: purchaseCategoryID.String(),
Item: "Cleaning supplies",
Amount: 10000,
},
},
})
@@ -140,8 +186,35 @@ func TestExpenseProcessorCreatePersistsProvidedStatus(t *testing.T) {
require.Equal(t, "approved", resp.Status)
}
func TestExpenseProcessorCreateRejectsRawMaterialPurchaseCategory(t *testing.T) {
repo := &expenseRepositoryCaptureStub{}
purchaseCategoryID := uuid.New()
p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeRawMaterial))
resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{
Receiver: "Cashier",
TransactionDate: "2026-05-29",
CodeNumber: "EXP-001",
OutletID: uuid.NewString(),
Total: 10000,
Items: []models.CreateExpenseItemRequest{
{
ChartOfAccountID: uuid.NewString(),
PurchaseCategoryID: purchaseCategoryID.String(),
Item: "Cleaning supplies",
Amount: 10000,
},
},
})
require.Error(t, err)
require.Nil(t, resp)
require.Contains(t, err.Error(), "expense")
}
func TestExpenseProcessorGetExpenseAnalyticsDefaultsGroupByAndMapsResponse(t *testing.T) {
coaID := uuid.New()
purchaseCategoryID := uuid.New()
outletID := uuid.New()
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
repo := &expenseRepositoryCaptureStub{
@@ -165,6 +238,16 @@ func TestExpenseProcessorGetExpenseAnalyticsDefaultsGroupByAndMapsResponse(t *te
},
},
CategoryData: []entities.ExpenseAnalyticsCategoryData{
{
PurchaseCategoryID: purchaseCategoryID,
PurchaseCategoryName: "Operational Supplies",
PurchaseCategoryType: "expense",
TotalAmount: 100000,
ExpenseCount: 2,
ItemCount: 2,
},
},
ChartOfAccountData: []entities.ExpenseAnalyticsChartOfAccountData{
{
ChartOfAccountID: coaID,
ChartOfAccountName: "Operational",
@@ -183,7 +266,7 @@ func TestExpenseProcessorGetExpenseAnalyticsDefaultsGroupByAndMapsResponse(t *te
},
},
}
p := NewExpenseProcessorImpl(repo)
p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense))
resp, err := p.GetExpenseAnalytics(context.Background(), &models.ExpenseAnalyticsRequest{
OrganizationID: uuid.New(),
@@ -200,7 +283,9 @@ func TestExpenseProcessorGetExpenseAnalyticsDefaultsGroupByAndMapsResponse(t *te
require.Len(t, resp.Data, 1)
require.Equal(t, int64(2), resp.Data[0].ExpenseCount)
require.Len(t, resp.CategoryData, 1)
require.Equal(t, coaID, resp.CategoryData[0].ChartOfAccountID)
require.Equal(t, purchaseCategoryID, resp.CategoryData[0].PurchaseCategoryID)
require.Len(t, resp.ChartOfAccountData, 1)
require.Equal(t, coaID, resp.ChartOfAccountData[0].ChartOfAccountID)
require.Len(t, resp.ItemData, 1)
require.Equal(t, "Cleaning supplies", resp.ItemData[0].Item)
}
+1 -1
View File
@@ -86,7 +86,7 @@ type CustomerRepository interface {
}
type InventoryMovementService interface {
CreateIngredientMovement(ctx context.Context, ingredientID, organizationID, outletID, userID uuid.UUID, movementType entities.InventoryMovementType, quantity float64, unitCost float64, reason string, referenceType *entities.InventoryMovementReferenceType, referenceID *uuid.UUID) error
CreateIngredientMovement(ctx context.Context, ingredientID, organizationID, outletID, userID uuid.UUID, movementType entities.InventoryMovementType, quantity float64, unitCost float64, reason string, referenceType *entities.InventoryMovementReferenceType, referenceID *uuid.UUID, purchaseOrderItemID *uuid.UUID) error
CreateProductMovement(ctx context.Context, productID, organizationID, outletID, userID uuid.UUID, movementType entities.InventoryMovementType, quantity float64, unitCost float64, reason string, referenceType *entities.InventoryMovementReferenceType, referenceID *uuid.UUID) error
}
+170 -85
View File
@@ -11,8 +11,8 @@ import (
)
type PurchaseOrderProcessor interface {
CreatePurchaseOrder(ctx context.Context, organizationID uuid.UUID, req *models.CreatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error)
UpdatePurchaseOrder(ctx context.Context, id, organizationID uuid.UUID, req *models.UpdatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error)
CreatePurchaseOrder(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, req *models.CreatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error)
UpdatePurchaseOrder(ctx context.Context, id, organizationID uuid.UUID, outletID *uuid.UUID, req *models.UpdatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error)
DeletePurchaseOrder(ctx context.Context, id, organizationID uuid.UUID) error
GetPurchaseOrderByID(ctx context.Context, id, organizationID uuid.UUID) (*models.PurchaseOrderResponse, error)
ListPurchaseOrders(ctx context.Context, organizationID uuid.UUID, filters map[string]interface{}, page, limit int) ([]*models.PurchaseOrderResponse, int, error)
@@ -25,6 +25,7 @@ type PurchaseOrderProcessorImpl struct {
purchaseOrderRepo PurchaseOrderRepository
vendorRepo VendorRepository
ingredientRepo IngredientRepository
purchaseCategoryRepo PurchaseCategoryRepository
unitRepo UnitRepository
fileRepo FileRepository
inventoryMovementService InventoryMovementService
@@ -35,6 +36,7 @@ func NewPurchaseOrderProcessorImpl(
purchaseOrderRepo PurchaseOrderRepository,
vendorRepo VendorRepository,
ingredientRepo IngredientRepository,
purchaseCategoryRepo PurchaseCategoryRepository,
unitRepo UnitRepository,
fileRepo FileRepository,
inventoryMovementService InventoryMovementService,
@@ -44,6 +46,7 @@ func NewPurchaseOrderProcessorImpl(
purchaseOrderRepo: purchaseOrderRepo,
vendorRepo: vendorRepo,
ingredientRepo: ingredientRepo,
purchaseCategoryRepo: purchaseCategoryRepo,
unitRepo: unitRepo,
fileRepo: fileRepo,
inventoryMovementService: inventoryMovementService,
@@ -51,11 +54,13 @@ func NewPurchaseOrderProcessorImpl(
}
}
func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, organizationID uuid.UUID, req *models.CreatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error) {
// Check if vendor exists and belongs to organization
_, err := p.vendorRepo.GetByIDAndOrganizationID(ctx, req.VendorID, organizationID)
if err != nil {
return nil, fmt.Errorf("vendor not found: %w", err)
func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, req *models.CreatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error) {
// Check if vendor exists and belongs to organization when provided.
if req.VendorID != nil {
_, err := p.vendorRepo.GetByIDAndOrganizationID(ctx, *req.VendorID, organizationID)
if err != nil {
return nil, fmt.Errorf("vendor not found: %w", err)
}
}
// Check if PO number already exists in organization
@@ -64,28 +69,53 @@ 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)
}
// Validate ingredients and units exist
// Validate categories and inventory fields per item type.
for i, item := range req.Items {
_, err := p.ingredientRepo.GetByID(ctx, item.IngredientID, organizationID)
category, err := p.validatePurchaseCategory(ctx, item.PurchaseCategoryID, organizationID, i)
if err != nil {
return nil, fmt.Errorf("ingredient not found for item %d: %w", i, err)
return nil, 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)
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)
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
totalAmount := 0.0
for _, item := range req.Items {
totalAmount += item.Amount
totalAmount += calculatePurchaseOrderItemTotal(item.Quantity, item.Amount)
}
// Create purchase order entity
poEntity := &entities.PurchaseOrder{
OrganizationID: organizationID,
OutletID: outletID,
VendorID: req.VendorID,
PONumber: req.PONumber,
TransactionDate: req.TransactionDate,
@@ -109,12 +139,13 @@ func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, or
// Create purchase order items
for _, itemReq := range req.Items {
itemEntity := &entities.PurchaseOrderItem{
PurchaseOrderID: poEntity.ID,
IngredientID: itemReq.IngredientID,
Description: itemReq.Description,
Quantity: itemReq.Quantity,
UnitID: itemReq.UnitID,
Amount: itemReq.Amount,
PurchaseOrderID: poEntity.ID,
IngredientID: itemReq.IngredientID,
PurchaseCategoryID: itemReq.PurchaseCategoryID,
Description: itemReq.Description,
Quantity: itemReq.Quantity,
UnitID: itemReq.UnitID,
Amount: itemReq.Amount,
}
err = p.purchaseOrderRepo.CreateItem(ctx, itemEntity)
@@ -145,12 +176,15 @@ func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, or
return mappers.PurchaseOrderEntityToResponse(createdPO), nil
}
func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id, organizationID uuid.UUID, req *models.UpdatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error) {
func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id, organizationID uuid.UUID, outletID *uuid.UUID, req *models.UpdatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error) {
// Get existing purchase order
poEntity, err := p.purchaseOrderRepo.GetByIDAndOrganizationID(ctx, id, organizationID)
if err != nil {
return nil, fmt.Errorf("purchase order not found: %w", err)
}
if poEntity.OutletID == nil && outletID != nil {
poEntity.OutletID = outletID
}
// Check if vendor exists and belongs to organization (if vendor is being updated)
if req.VendorID != nil {
@@ -158,7 +192,7 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id
if err != nil {
return nil, fmt.Errorf("vendor not found: %w", err)
}
poEntity.VendorID = *req.VendorID
poEntity.VendorID = req.VendorID
}
// Check if PO number already exists (if PO number is being updated)
@@ -189,68 +223,80 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id
// Update items if provided
if req.Items != nil {
// Delete existing items
totalAmount := 0.0
items := make([]*entities.PurchaseOrderItem, len(req.Items))
for i, itemReq := range req.Items {
if itemReq.PurchaseCategoryID == nil {
return nil, fmt.Errorf("purchase_category_id is required for item %d", i)
}
ingredientID := itemReq.IngredientID
purchaseCategoryID := *itemReq.PurchaseCategoryID
unitID := itemReq.UnitID
quantity := itemReq.Quantity
amount := 0.0
if itemReq.Amount != nil {
amount = *itemReq.Amount
}
description := itemReq.Description
category, err := p.validatePurchaseCategory(ctx, purchaseCategoryID, organizationID, i)
if err != nil {
return nil, 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.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{
PurchaseOrderID: poEntity.ID,
IngredientID: ingredientID,
PurchaseCategoryID: purchaseCategoryID,
Description: description,
Quantity: quantity,
UnitID: unitID,
Amount: amount,
}
totalAmount += calculatePurchaseOrderItemTotal(quantity, amount)
}
// Delete and recreate only after all replacement items are valid.
err = p.purchaseOrderRepo.DeleteItemsByPurchaseOrderID(ctx, poEntity.ID)
if err != nil {
return nil, fmt.Errorf("failed to delete existing items: %w", err)
}
// Create new items
totalAmount := 0.0
for _, itemReq := range req.Items {
// Validate ingredients and units exist
if itemReq.IngredientID != nil {
_, err := p.ingredientRepo.GetByID(ctx, *itemReq.IngredientID, organizationID)
if err != nil {
return nil, fmt.Errorf("ingredient not found: %w", err)
}
}
if itemReq.UnitID != nil {
_, err := p.unitRepo.GetByID(ctx, *itemReq.UnitID, organizationID)
if err != nil {
return nil, fmt.Errorf("unit not found: %w", err)
}
}
// Use existing values if not provided
ingredientID := poEntity.Items[0].IngredientID // This is a simplified approach
unitID := poEntity.Items[0].UnitID
quantity := poEntity.Items[0].Quantity
amount := poEntity.Items[0].Amount
description := poEntity.Items[0].Description
if itemReq.IngredientID != nil {
ingredientID = *itemReq.IngredientID
}
if itemReq.UnitID != nil {
unitID = *itemReq.UnitID
}
if itemReq.Quantity != nil {
quantity = *itemReq.Quantity
}
if itemReq.Amount != nil {
amount = *itemReq.Amount
}
if itemReq.Description != nil {
description = itemReq.Description
}
itemEntity := &entities.PurchaseOrderItem{
PurchaseOrderID: poEntity.ID,
IngredientID: ingredientID,
Description: description,
Quantity: quantity,
UnitID: unitID,
Amount: amount,
}
for _, itemEntity := range items {
err = p.purchaseOrderRepo.CreateItem(ctx, itemEntity)
if err != nil {
return nil, fmt.Errorf("failed to create purchase order item: %w", err)
}
totalAmount += amount
}
poEntity.TotalAmount = totalAmount
@@ -379,19 +425,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
}
@@ -399,7 +453,7 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte
// Calculate unit cost in ingredient's base unit
unitCost := 0.0
if quantityToAdd > 0 {
unitCost = item.Amount / quantityToAdd
unitCost = calculatePurchaseOrderItemTotal(item.Quantity, item.Amount) / quantityToAdd
}
// Create inventory movement for ingredient purchase
@@ -409,7 +463,7 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte
err = p.inventoryMovementService.CreateIngredientMovement(
ctx,
item.IngredientID,
*item.IngredientID,
organizationID,
outletID,
userID,
@@ -419,15 +473,21 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte
reason,
&referenceType,
referenceID,
&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)
}
}
}
// Update the purchase order status
err = p.purchaseOrderRepo.UpdateStatus(ctx, id, status)
statusOutletID := po.OutletID
if statusOutletID == nil && outletID != uuid.Nil {
statusOutletID = &outletID
}
err = p.purchaseOrderRepo.UpdateStatusAndOutlet(ctx, id, status, statusOutletID)
if err != nil {
return nil, fmt.Errorf("failed to update purchase order status: %w", err)
}
@@ -440,3 +500,28 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte
return mappers.PurchaseOrderEntityToResponse(updatedPO), nil
}
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 nil, fmt.Errorf("purchase category not found for item %d: %w", itemIndex, err)
}
if !category.IsActive {
return nil, fmt.Errorf("purchase category for item %d is inactive", 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 category, nil
}
func calculatePurchaseOrderItemTotal(quantity *float64, amount float64) float64 {
if quantity == nil {
return amount
}
return *quantity * amount
}
@@ -19,6 +19,7 @@ type PurchaseOrderRepository interface {
GetByStatus(ctx context.Context, organizationID uuid.UUID, status string) ([]*entities.PurchaseOrder, error)
GetOverdue(ctx context.Context, organizationID uuid.UUID) ([]*entities.PurchaseOrder, error)
UpdateStatus(ctx context.Context, id uuid.UUID, status string) error
UpdateStatusAndOutlet(ctx context.Context, id uuid.UUID, status string, outletID *uuid.UUID) error
UpdateTotalAmount(ctx context.Context, id uuid.UUID, totalAmount float64) error
CreateItem(ctx context.Context, item *entities.PurchaseOrderItem) error
UpdateItem(ctx context.Context, item *entities.PurchaseOrderItem) error
+738 -84
View File
@@ -2,6 +2,7 @@ package repository
import (
"context"
"sort"
"time"
"apskel-pos-be/internal/entities"
@@ -18,6 +19,9 @@ 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)
GetExclusiveSummaryBankBalances(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) ([]entities.ExclusiveSummaryBankBalance, error)
GetOutletName(ctx context.Context, organizationID uuid.UUID, outletID uuid.UUID) (string, error)
}
type AnalyticsRepositoryImpl struct {
@@ -37,6 +41,34 @@ func (r *AnalyticsRepositoryImpl) resolveOutletID(query *gorm.DB, outletID *uuid
return query
}
func (r *AnalyticsRepositoryImpl) GetOutletName(ctx context.Context, organizationID uuid.UUID, outletID uuid.UUID) (string, error) {
var outlet struct {
Name string
}
result := r.db.WithContext(ctx).
Table("outlets").
Select("name").
Where("id = ? AND organization_id = ?", outletID, organizationID).
Limit(1).
Scan(&outlet)
if result.Error != nil {
return "", result.Error
}
return outlet.Name, nil
}
func purchaseOrderItemTotalAmountSQL() string {
return "CASE WHEN pc.type = '" + string(entities.PurchaseCategoryTypeRawMaterial) + "' THEN COALESCE(poi.quantity, 0) * poi.amount ELSE poi.amount END"
}
func purchaseOrderRawMaterialAmountSQL() string {
return "CASE WHEN pc.type = '" + string(entities.PurchaseCategoryTypeRawMaterial) + "' THEN " + purchaseOrderItemTotalAmountSQL() + " ELSE 0 END"
}
func purchaseOrderExpenseAmountSQL() string {
return "CASE WHEN pc.type = '" + string(entities.PurchaseCategoryTypeExpense) + "' THEN " + purchaseOrderItemTotalAmountSQL() + " ELSE 0 END"
}
func (r *AnalyticsRepositoryImpl) GetPaymentMethodAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]*entities.PaymentMethodAnalytics, error) {
var results []*entities.PaymentMethodAnalytics
@@ -124,7 +156,6 @@ func (r *AnalyticsRepositoryImpl) GetSalesAnalytics(ctx context.Context, organiz
}
func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) (*entities.PurchasingAnalytics, error) {
var summary entities.PurchasingSummary
var outletName *string
if outletID != nil {
@@ -144,29 +175,37 @@ func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, or
outletName = &outlet.Name
}
}
return r.getPurchaseOrderPurchasingAnalytics(ctx, organizationID, outletID, outletName, dateFrom, dateTo, groupBy)
}
func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, outletName *string, dateFrom, dateTo time.Time, groupBy string) (*entities.PurchasingAnalytics, error) {
var summary entities.PurchasingSummary
summaryQuery := r.db.WithContext(ctx).
Table("inventory_movements im").
Table("purchase_orders po").
Select(`
COALESCE(SUM(im.total_cost), 0) as total_purchases,
COUNT(DISTINCT im.reference_id) as total_purchase_orders,
COALESCE(SUM(im.quantity), 0) as total_quantity,
COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as total_purchases,
COALESCE(SUM(`+purchaseOrderRawMaterialAmountSQL()+`), 0) as raw_material_purchases,
COALESCE(SUM(`+purchaseOrderExpenseAmountSQL()+`), 0) as expense_purchases,
COUNT(DISTINCT po.id) as total_purchase_orders,
COUNT(DISTINCT CASE WHEN pc.type = '`+string(entities.PurchaseCategoryTypeRawMaterial)+`' THEN po.id END) as raw_material_purchase_orders,
COUNT(CASE WHEN pc.type = '`+string(entities.PurchaseCategoryTypeExpense)+`' THEN poi.id END) as expense_count,
COALESCE(SUM(poi.quantity), 0) as total_quantity,
CASE
WHEN COUNT(DISTINCT im.reference_id) > 0
THEN COALESCE(SUM(im.total_cost), 0) / COUNT(DISTINCT im.reference_id)
WHEN COUNT(DISTINCT po.id) > 0
THEN COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) / COUNT(DISTINCT po.id)
ELSE 0
END as average_purchase_order_value,
COUNT(DISTINCT im.item_id) as total_ingredients,
COUNT(DISTINCT po.vendor_id) as total_vendors
COUNT(DISTINCT i.id) as total_ingredients,
COUNT(DISTINCT COALESCE(po.vendor_id::text, 'no-vendor')) as total_vendors
`).
Joins("LEFT JOIN purchase_orders po ON im.reference_id = po.id").
Where("im.organization_id = ?", organizationID).
Where("im.movement_type = ?", entities.InventoryMovementTypePurchase).
Where("im.item_type = ?", "INGREDIENT").
Where("im.reference_type = ?", entities.InventoryMovementReferenceTypePurchaseOrder).
Where("im.created_at >= ? AND im.created_at <= ?", dateFrom, dateTo)
summaryQuery = r.resolveOutletID(summaryQuery, outletID, "im.outlet_id")
Joins("LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id").
Joins("LEFT JOIN purchase_categories pc ON poi.purchase_category_id = pc.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("po.status != ?", "cancelled").
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo)
summaryQuery = r.applyPurchaseOrderItemOutletFilter(summaryQuery, outletID)
if err := summaryQuery.Scan(&summary).Error; err != nil {
return nil, err
@@ -175,66 +214,105 @@ func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, or
var dateFormat string
switch groupBy {
case "hour":
dateFormat = "DATE_TRUNC('hour', im.created_at)"
dateFormat = "DATE_TRUNC('hour', po.created_at)"
case "week":
dateFormat = "DATE_TRUNC('week', im.created_at)"
dateFormat = "DATE_TRUNC('week', po.transaction_date::timestamp)"
case "month":
dateFormat = "DATE_TRUNC('month', im.created_at)"
dateFormat = "DATE_TRUNC('month', po.transaction_date::timestamp)"
default:
dateFormat = "DATE_TRUNC('day', im.created_at)"
dateFormat = "DATE_TRUNC('day', po.transaction_date::timestamp)"
}
var data []entities.PurchasingAnalyticsData
dataQuery := r.db.WithContext(ctx).
Table("inventory_movements im").
Select(`
`+dateFormat+` as date,
COALESCE(SUM(im.total_cost), 0) as purchases,
COUNT(DISTINCT im.reference_id) as purchase_orders,
COALESCE(SUM(im.quantity), 0) as quantity,
COUNT(DISTINCT im.item_id) as ingredients,
COUNT(DISTINCT po.vendor_id) as vendors
`).
Joins("LEFT JOIN purchase_orders po ON im.reference_id = po.id").
Where("im.organization_id = ?", organizationID).
Where("im.movement_type = ?", entities.InventoryMovementTypePurchase).
Where("im.item_type = ?", "INGREDIENT").
Where("im.reference_type = ?", entities.InventoryMovementReferenceTypePurchaseOrder).
Where("im.created_at >= ? AND im.created_at <= ?", dateFrom, dateTo).
Group(dateFormat).
Order(dateFormat)
var outletData []entities.PurchasingOutletData
if groupBy == "outlet_id" {
outletQuery := r.db.WithContext(ctx).
Table("purchase_orders po").
Select(`
po.outlet_id as outlet_id,
COALESCE(o.name, 'No Outlet') as outlet_name,
COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as purchases,
COALESCE(SUM(`+purchaseOrderRawMaterialAmountSQL()+`), 0) as raw_material_purchases,
COALESCE(SUM(`+purchaseOrderExpenseAmountSQL()+`), 0) as expense_purchases,
COUNT(DISTINCT po.id) as purchase_orders,
COUNT(DISTINCT CASE WHEN pc.type = '`+string(entities.PurchaseCategoryTypeRawMaterial)+`' THEN po.id END) as raw_material_purchase_orders,
COUNT(CASE WHEN pc.type = '`+string(entities.PurchaseCategoryTypeExpense)+`' THEN poi.id END) as expense_count,
COALESCE(SUM(poi.quantity), 0) as quantity,
COUNT(DISTINCT i.id) as ingredients,
COUNT(DISTINCT COALESCE(po.vendor_id::text, 'no-vendor')) as vendors
`).
Joins("LEFT JOIN outlets o ON po.outlet_id = o.id").
Joins("LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id").
Joins("LEFT JOIN purchase_categories pc ON poi.purchase_category_id = pc.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("po.status != ?", "cancelled").
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo).
Group("po.outlet_id, COALESCE(o.name, 'No Outlet')").
Order("outlet_name ASC")
outletQuery = r.applyPurchaseOrderItemOutletFilter(outletQuery, outletID)
dataQuery = r.resolveOutletID(dataQuery, outletID, "im.outlet_id")
if err := outletQuery.Scan(&outletData).Error; err != nil {
return nil, err
}
} else {
dataQuery := r.db.WithContext(ctx).
Table("purchase_orders po").
Select(`
`+dateFormat+` as date,
COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as purchases,
COALESCE(SUM(`+purchaseOrderRawMaterialAmountSQL()+`), 0) as raw_material_purchases,
COALESCE(SUM(`+purchaseOrderExpenseAmountSQL()+`), 0) as expense_purchases,
COUNT(DISTINCT po.id) as purchase_orders,
COUNT(DISTINCT CASE WHEN pc.type = '`+string(entities.PurchaseCategoryTypeRawMaterial)+`' THEN po.id END) as raw_material_purchase_orders,
COUNT(CASE WHEN pc.type = '`+string(entities.PurchaseCategoryTypeExpense)+`' THEN poi.id END) as expense_count,
COALESCE(SUM(poi.quantity), 0) as quantity,
COUNT(DISTINCT i.id) as ingredients,
COUNT(DISTINCT COALESCE(po.vendor_id::text, 'no-vendor')) as vendors
`).
Joins("LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id").
Joins("LEFT JOIN purchase_categories pc ON poi.purchase_category_id = pc.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("po.status != ?", "cancelled").
Where("pc.type = ?", entities.PurchaseCategoryTypeRawMaterial).
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo).
Group(dateFormat).
Order(dateFormat)
dataQuery = r.applyPurchaseOrderItemOutletFilter(dataQuery, outletID)
if err := dataQuery.Scan(&data).Error; err != nil {
return nil, err
if err := dataQuery.Scan(&data).Error; err != nil {
return nil, err
}
}
var ingredientData []entities.PurchasingIngredientData
ingredientQuery := r.db.WithContext(ctx).
Table("inventory_movements im").
Table("purchase_order_items poi").
Select(`
i.id as ingredient_id,
i.name as ingredient_name,
COALESCE(SUM(im.quantity), 0) as quantity,
COALESCE(SUM(im.total_cost), 0) as total_cost,
COALESCE(SUM(poi.quantity), 0) as quantity,
COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as total_cost,
CASE
WHEN SUM(im.quantity) > 0
THEN COALESCE(SUM(im.total_cost), 0) / SUM(im.quantity)
WHEN SUM(poi.quantity) > 0
THEN COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) / SUM(poi.quantity)
ELSE 0
END as average_unit_cost,
COUNT(DISTINCT im.reference_id) as purchase_order_count
COUNT(DISTINCT po.id) as purchase_order_count
`).
Joins("JOIN ingredients i ON im.item_id = i.id").
Where("im.organization_id = ?", organizationID).
Where("im.movement_type = ?", entities.InventoryMovementTypePurchase).
Where("im.item_type = ?", "INGREDIENT").
Where("im.reference_type = ?", entities.InventoryMovementReferenceTypePurchaseOrder).
Where("im.created_at >= ? AND im.created_at <= ?", dateFrom, dateTo).
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("po.status != ?", "cancelled").
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo).
Group("i.id, i.name").
Order("total_cost DESC")
ingredientQuery = r.resolveOutletID(ingredientQuery, outletID, "im.outlet_id")
ingredientQuery = r.applyPurchaseOrderItemOutletFilter(ingredientQuery, outletID)
if err := ingredientQuery.Scan(&ingredientData).Error; err != nil {
return nil, err
@@ -242,26 +320,26 @@ func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, or
var vendorData []entities.PurchasingVendorData
vendorQuery := r.db.WithContext(ctx).
Table("inventory_movements im").
Table("purchase_orders po").
Select(`
v.id as vendor_id,
v.name as vendor_name,
COALESCE(SUM(im.total_cost), 0) as total_cost,
COUNT(DISTINCT im.reference_id) as purchase_order_count,
COUNT(DISTINCT im.item_id) as ingredient_count,
COALESCE(SUM(im.quantity), 0) as quantity
COALESCE(v.name, 'No Vendor') as vendor_name,
COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as total_cost,
COUNT(DISTINCT po.id) as purchase_order_count,
COUNT(DISTINCT i.id) as ingredient_count,
COALESCE(SUM(poi.quantity), 0) as quantity
`).
Joins("JOIN purchase_orders po ON im.reference_id = po.id").
Joins("JOIN vendors v ON po.vendor_id = v.id").
Where("im.organization_id = ?", organizationID).
Where("im.movement_type = ?", entities.InventoryMovementTypePurchase).
Where("im.item_type = ?", "INGREDIENT").
Where("im.reference_type = ?", entities.InventoryMovementReferenceTypePurchaseOrder).
Where("im.created_at >= ? AND im.created_at <= ?", dateFrom, dateTo).
Group("v.id, v.name").
Joins("LEFT JOIN vendors v ON po.vendor_id = v.id").
Joins("LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id").
Joins("LEFT JOIN purchase_categories pc ON poi.purchase_category_id = pc.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("po.status != ?", "cancelled").
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo).
Group("v.id, COALESCE(v.name, 'No Vendor')").
Order("total_cost DESC")
vendorQuery = r.resolveOutletID(vendorQuery, outletID, "im.outlet_id")
vendorQuery = r.applyPurchaseOrderItemOutletFilter(vendorQuery, outletID)
if err := vendorQuery.Scan(&vendorData).Error; err != nil {
return nil, err
@@ -271,11 +349,19 @@ func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, or
OutletName: outletName,
Summary: summary,
Data: data,
OutletData: outletData,
IngredientData: ingredientData,
VendorData: vendorData,
}, nil
}
func (r *AnalyticsRepositoryImpl) applyPurchaseOrderItemOutletFilter(query *gorm.DB, outletID *uuid.UUID) *gorm.DB {
if outletID == nil {
return query
}
return query.Where("po.outlet_id = ?", *outletID)
}
func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, limit int) ([]*entities.ProductAnalytics, error) {
var results []*entities.ProductAnalytics
@@ -284,7 +370,13 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organ
Select(`
p.id as product_id,
p.name as product_name,
p.price as product_price,
p.sku as product_sku,
COALESCE(
NULLIF(pop.price, 0),
(SELECT price FROM product_outlet_prices WHERE product_id = p.id ORDER BY updated_at DESC LIMIT 1),
NULLIF(p.price, 0),
0
) as product_price,
c.id as category_id,
c.name as category_name,
c.order as category_order,
@@ -318,6 +410,7 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organ
Joins("JOIN products p ON oi.product_id = p.id").
Joins("JOIN categories c ON p.category_id = c.id").
Joins("JOIN orders o ON oi.order_id = o.id").
Joins("LEFT JOIN product_outlet_prices pop ON pop.product_id = p.id AND pop.outlet_id = o.outlet_id").
Joins("LEFT JOIN (?) mahpp ON mahpp.product_id = p.id",
r.db.Table("product_recipes pr2").
Select("pr2.product_id, SUM(pr2.quantity * (1 + COALESCE(pr2.waste_percentage, 0)/100.0) * COALESCE(ma.moving_avg_cost, ing.cost)) as hpp_per_unit").
@@ -343,7 +436,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.sku, p.price, p.cost, pop.price, c.id, c.name, c.order, mahpp.hpp_per_unit").
Order("revenue DESC").
Limit(limit).
Scan(&results).Error
@@ -430,6 +523,41 @@ func (r *AnalyticsRepositoryImpl) GetDashboardOverview(ctx context.Context, orga
return nil, err
}
// Total item sold (sum of order_items quantity for completed orders in date range)
var totalItemSold int64
itemQuery := r.db.WithContext(ctx).
Table("order_items oi").
Select("COALESCE(SUM(oi.quantity), 0)").
Joins("JOIN orders o ON o.id = oi.order_id").
Where("o.organization_id = ?", organizationID).
Where("o.is_void = false AND o.is_refund = false AND o.payment_status = 'completed'").
Where("o.created_at >= ? AND o.created_at <= ?", dateFrom, dateTo)
itemQuery = r.resolveOutletID(itemQuery, outletID, "o.outlet_id")
itemQuery.Scan(&totalItemSold)
result.TotalItemSold = totalItemSold
// Total low stock (inventory where quantity <= reorder_level)
var totalLowStock int64
lowStockQuery := r.db.WithContext(ctx).
Table("inventory i").
Select("COUNT(i.id)").
Joins("JOIN products p ON p.id = i.product_id").
Where("p.organization_id = ?", organizationID).
Where("i.quantity <= i.reorder_level")
lowStockQuery = r.resolveOutletID(lowStockQuery, outletID, "i.outlet_id")
lowStockQuery.Scan(&totalLowStock)
result.TotalLowStock = totalLowStock
// Total active products
var totalProductActive int64
productQuery := r.db.WithContext(ctx).
Table("products p").
Select("COUNT(p.id)").
Where("p.organization_id = ?", organizationID).
Where("p.is_active = true")
productQuery.Scan(&totalProductActive)
result.TotalProductActive = totalProductActive
return &result, nil
}
@@ -479,6 +607,11 @@ func (r *AnalyticsRepositoryImpl) GetProfitLossAnalytics(ctx context.Context, or
if err := summaryQuery.Scan(&summary).Error; err != nil {
return nil, err
}
periodHPP, err := r.getPurchaseOrderRawMaterialTotal(ctx, organizationID, outletID, dateFrom, dateTo)
if err != nil {
return nil, err
}
applyProfitLossSummaryCost(&summary, periodHPP)
var timeFormat string
switch groupBy {
@@ -526,6 +659,11 @@ func (r *AnalyticsRepositoryImpl) GetProfitLossAnalytics(ctx context.Context, or
if err := dataQuery.Scan(&data).Error; err != nil {
return nil, err
}
poCostData, err := r.getPurchaseOrderRawMaterialCostByPeriod(ctx, organizationID, outletID, dateFrom, dateTo, groupBy)
if err != nil {
return nil, err
}
data = mergeProfitLossDataWithPurchaseOrderCost(data, poCostData)
var productData []entities.ProductProfitData
productQuery := r.db.WithContext(ctx).
@@ -586,6 +724,11 @@ func (r *AnalyticsRepositoryImpl) GetProfitLossAnalytics(ctx context.Context, or
if err := todayQuery.Scan(&todayRC).Error; err != nil {
return nil, err
}
todayHPP, err := r.getPurchaseOrderRawMaterialTotal(ctx, organizationID, outletID, todayStart, todayEnd)
if err != nil {
return nil, err
}
todayRC.Cost = todayHPP
var mtdRC revenueCostResult
mtdQuery := r.db.WithContext(ctx).
@@ -603,36 +746,333 @@ func (r *AnalyticsRepositoryImpl) GetProfitLossAnalytics(ctx context.Context, or
if err := mtdQuery.Scan(&mtdRC).Error; err != nil {
return nil, err
}
mtdHPP, err := r.getPurchaseOrderRawMaterialTotal(ctx, organizationID, outletID, mtdStart, todayEnd)
if err != nil {
return nil, err
}
mtdRC.Cost = mtdHPP
todayExpenseByCategory, err := r.getExpenseByCategory(ctx, organizationID, outletID, todayStart, todayEnd)
if err != nil {
return nil, err
}
todayPOExpenseByCategory, err := r.getPurchaseOrderExpenseByCategory(ctx, organizationID, outletID, todayStart, todayEnd)
if err != nil {
return nil, err
}
todayExpenseByCategory = mergeExpenseCategoryTotals(todayExpenseByCategory, todayPOExpenseByCategory)
mtdExpenseByCategory, err := r.getExpenseByCategory(ctx, organizationID, outletID, mtdStart, todayEnd)
if err != nil {
return nil, err
}
mtdPOExpenseByCategory, err := r.getPurchaseOrderExpenseByCategory(ctx, organizationID, outletID, mtdStart, todayEnd)
if err != nil {
return nil, err
}
mtdExpenseByCategory = mergeExpenseCategoryTotals(mtdExpenseByCategory, mtdPOExpenseByCategory)
opsItems, err := r.getOperationalExpenseItems(ctx, organizationID, outletID, mtdStart, todayEnd)
if err != nil {
return nil, err
}
poOpsItems, err := r.getPurchaseOrderExpenseItems(ctx, organizationID, outletID, mtdStart, todayEnd)
if err != nil {
return nil, err
}
opsItems = mergeOperationalExpenseItems(opsItems, poOpsItems)
todayPurchasing, err := r.getPurchaseOrderTotals(ctx, organizationID, todayStart, todayEnd)
if err != nil {
return nil, err
}
mtdPurchasing, err := r.getPurchaseOrderTotals(ctx, organizationID, mtdStart, todayEnd)
if err != nil {
return nil, err
}
purchasingItems, err := r.getPurchasingItemDetails(ctx, organizationID, dateFrom, dateTo)
if err != nil {
return nil, err
}
return &entities.ProfitLossAnalytics{
Summary: summary,
Data: data,
ProductData: productData,
TodayRevenue: todayRC.Revenue,
TodayCost: todayRC.Cost,
MtdRevenue: mtdRC.Revenue,
MtdCost: mtdRC.Cost,
TodayExpenseByCategory: todayExpenseByCategory,
MtdExpenseByCategory: mtdExpenseByCategory,
OperationalExpenseItems: opsItems,
Summary: summary,
Data: data,
ProductData: productData,
TodayRevenue: todayRC.Revenue,
TodayCost: todayRC.Cost,
MtdRevenue: mtdRC.Revenue,
MtdCost: mtdRC.Cost,
TodayPurchasing: todayPurchasing.Total,
MtdPurchasing: mtdPurchasing.Total,
TodayPurchasingRawMaterial: todayPurchasing.RawMaterial,
MtdPurchasingRawMaterial: mtdPurchasing.RawMaterial,
TodayPurchasingExpense: todayPurchasing.Expense,
MtdPurchasingExpense: mtdPurchasing.Expense,
PurchasingItems: purchasingItems,
TodayExpenseByCategory: todayExpenseByCategory,
MtdExpenseByCategory: mtdExpenseByCategory,
OperationalExpenseItems: opsItems,
}, nil
}
func (r *AnalyticsRepositoryImpl) getPurchaseOrderRawMaterialTotal(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (float64, error) {
type totalResult struct {
Total float64
}
var result totalResult
query := r.db.WithContext(ctx).
Table("purchase_order_items poi").
Select(`COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as total`).
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("po.status = ?", "received").
Where("pc.type = ?", entities.PurchaseCategoryTypeRawMaterial).
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo)
query = r.applyPurchaseOrderItemOutletFilter(query, outletID)
if err := query.Scan(&result).Error; err != nil {
return 0, err
}
return result.Total, nil
}
type purchasingTotals struct {
Total float64
RawMaterial float64
Expense float64
}
func (r *AnalyticsRepositoryImpl) getPurchaseOrderTotals(ctx context.Context, organizationID uuid.UUID, dateFrom, dateTo time.Time) (purchasingTotals, error) {
type result struct {
Total float64
RawMaterial float64
Expense float64
}
var res result
query := r.db.WithContext(ctx).
Table("purchase_order_items poi").
Select(`
COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as total,
COALESCE(SUM(`+purchaseOrderRawMaterialAmountSQL()+`), 0) as raw_material,
COALESCE(SUM(`+purchaseOrderExpenseAmountSQL()+`), 0) as expense
`).
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("po.status = ?", "received").
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo)
if err := query.Scan(&res).Error; err != nil {
return purchasingTotals{}, err
}
return purchasingTotals{
Total: res.Total,
RawMaterial: res.RawMaterial,
Expense: res.Expense,
}, nil
}
func (r *AnalyticsRepositoryImpl) getPurchasingItemDetails(ctx context.Context, organizationID uuid.UUID, dateFrom, dateTo time.Time) ([]entities.PurchasingItemDetail, error) {
var results []entities.PurchasingItemDetail
query := r.db.WithContext(ctx).
Table("purchase_order_items poi").
Select(`
po.transaction_date as date,
COALESCE(NULLIF(poi.description, ''), i.name, pc.name) as item,
COALESCE(poi.quantity, 0) as quantity,
CASE WHEN pc.type = '`+string(entities.PurchaseCategoryTypeRawMaterial)+`' THEN COALESCE(poi.quantity, 0) * poi.amount ELSE poi.amount END as amount
`).
Joins("JOIN purchase_orders po ON poi.purchase_order_id = po.id").
Joins("LEFT JOIN purchase_categories pc ON poi.purchase_category_id = pc.id").
Joins("LEFT JOIN ingredients i ON poi.ingredient_id = i.id").
Where("po.organization_id = ?", organizationID).
Where("po.status = ?", "received").
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo).
Order("po.transaction_date DESC, poi.created_at DESC")
if err := query.Scan(&results).Error; err != nil {
return nil, err
}
return results, nil
}
func (r *AnalyticsRepositoryImpl) getPurchaseOrderRawMaterialCostByPeriod(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) ([]entities.ProfitLossData, error) {
var dateFormat string
switch groupBy {
case "hour":
dateFormat = "DATE_TRUNC('hour', po.transaction_date::timestamp)"
case "week":
dateFormat = "DATE_TRUNC('week', po.transaction_date::timestamp)"
case "month":
dateFormat = "DATE_TRUNC('month', po.transaction_date::timestamp)"
default:
dateFormat = "DATE_TRUNC('day', po.transaction_date::timestamp)"
}
var results []entities.ProfitLossData
query := r.db.WithContext(ctx).
Table("purchase_order_items poi").
Select(`
`+dateFormat+` as date,
COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as cost
`).
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("po.status = ?", "received").
Where("pc.type = ?", entities.PurchaseCategoryTypeRawMaterial).
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo).
Group(dateFormat).
Order(dateFormat)
query = r.applyPurchaseOrderItemOutletFilter(query, outletID)
if err := query.Scan(&results).Error; err != nil {
return nil, err
}
return results, nil
}
func applyProfitLossSummaryCost(summary *entities.ProfitLossSummary, cost float64) {
summary.TotalCost = cost
summary.GrossProfit = summary.TotalRevenue - cost
summary.GrossProfitMargin = ratio(summary.GrossProfit, summary.TotalRevenue)
summary.NetProfit = summary.TotalRevenue - cost - summary.TotalDiscount
summary.NetProfitMargin = ratio(summary.NetProfit, summary.TotalRevenue)
if summary.TotalOrders > 0 {
summary.AverageProfit = summary.NetProfit / float64(summary.TotalOrders)
} else {
summary.AverageProfit = 0
}
summary.ProfitabilityRatio = ratio(summary.GrossProfit, cost)
}
func mergeProfitLossDataWithPurchaseOrderCost(data, costs []entities.ProfitLossData) []entities.ProfitLossData {
indexByDate := make(map[time.Time]int, len(data))
for i, item := range data {
data[i].Cost = 0
indexByDate[item.Date] = i
}
for _, cost := range costs {
if i, ok := indexByDate[cost.Date]; ok {
data[i].Cost = cost.Cost
continue
}
indexByDate[cost.Date] = len(data)
data = append(data, entities.ProfitLossData{Date: cost.Date, Cost: cost.Cost})
}
for i := range data {
data[i].GrossProfit = data[i].Revenue - data[i].Cost
data[i].GrossProfitMargin = ratio(data[i].GrossProfit, data[i].Revenue)
data[i].NetProfit = data[i].Revenue - data[i].Cost - data[i].Discount
data[i].NetProfitMargin = ratio(data[i].NetProfit, data[i].Revenue)
}
sort.Slice(data, func(i, j int) bool {
return data[i].Date.Before(data[j].Date)
})
return data
}
func ratio(numerator, denominator float64) float64 {
if denominator == 0 {
return 0
}
return (numerator / denominator) * 100
}
func (r *AnalyticsRepositoryImpl) getPurchaseOrderExpenseByCategory(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]entities.ExpenseCategoryTotal, error) {
var results []entities.ExpenseCategoryTotal
query := r.db.WithContext(ctx).
Table("purchase_order_items poi").
Select(`
pc.name as category_name,
COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 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("po.status = ?", "received").
Where("pc.type = ?", entities.PurchaseCategoryTypeExpense).
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo).
Group("pc.id, pc.name, pc.sort_order").
Order("pc.sort_order ASC, pc.name ASC")
query = r.applyPurchaseOrderItemOutletFilter(query, outletID)
if err := query.Scan(&results).Error; err != nil {
return nil, err
}
return results, nil
}
func (r *AnalyticsRepositoryImpl) getPurchaseOrderExpenseItems(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]entities.OperationalExpenseItem, error) {
var results []entities.OperationalExpenseItem
query := r.db.WithContext(ctx).
Table("purchase_order_items poi").
Select(`
COALESCE(NULLIF(poi.description, ''), pc.name) as item,
COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 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("po.status = ?", "received").
Where("pc.type = ?", entities.PurchaseCategoryTypeExpense).
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo).
Group("COALESCE(NULLIF(poi.description, ''), pc.name)").
Order("amount DESC")
query = r.applyPurchaseOrderItemOutletFilter(query, outletID)
if err := query.Scan(&results).Error; err != nil {
return nil, err
}
return results, nil
}
func mergeExpenseCategoryTotals(base, extra []entities.ExpenseCategoryTotal) []entities.ExpenseCategoryTotal {
indexByName := make(map[string]int, len(base))
for i, item := range base {
indexByName[item.CategoryName] = i
}
for _, item := range extra {
if i, ok := indexByName[item.CategoryName]; ok {
base[i].Amount += item.Amount
continue
}
indexByName[item.CategoryName] = len(base)
base = append(base, item)
}
return base
}
func mergeOperationalExpenseItems(base, extra []entities.OperationalExpenseItem) []entities.OperationalExpenseItem {
indexByName := make(map[string]int, len(base))
for i, item := range base {
indexByName[item.Item] = i
}
for _, item := range extra {
if i, ok := indexByName[item.Item]; ok {
base[i].Amount += item.Amount
continue
}
indexByName[item.Item] = len(base)
base = append(base, item)
}
return base
}
func (r *AnalyticsRepositoryImpl) getExpenseByCategory(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]entities.ExpenseCategoryTotal, error) {
var results []entities.ExpenseCategoryTotal
@@ -681,3 +1121,217 @@ func (r *AnalyticsRepositoryImpl) getOperationalExpenseItems(ctx context.Context
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(`+purchaseOrderItemTotalAmountSQL()+`), 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("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("purchase_order_items poi").
Select(`
pc.code as category_code,
pc.name as category_name,
COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 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.PurchaseCategoryTypeExpense).
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) getExclusiveSummaryDailySummary(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]entities.ExclusiveSummaryDailySummary, error) {
var results []entities.ExclusiveSummaryDailySummary
rawQuery, args := r.exclusiveSummaryPurchaseOrderItemQuery(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.exclusiveSummaryPurchaseOrderItemQuery(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, category_name ASC, description ASC
`, args...).Scan(&results).Error
return results, err
}
func (r *AnalyticsRepositoryImpl) exclusiveSummaryPurchaseOrderItemQuery(organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (string, []interface{}) {
outletFilter := ""
args := []interface{}{
organizationID,
"received",
dateFrom,
dateTo,
}
if outletID != nil {
outletFilter = "AND po.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,
` + purchaseOrderItemTotalAmountSQL() + ` 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
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 po.status = ?
AND po.transaction_date >= ? AND po.transaction_date <= ?
` + outletFilter + `
`
return query, args
}
func (r *AnalyticsRepositoryImpl) GetExclusiveSummaryBankBalances(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) ([]entities.ExclusiveSummaryBankBalance, error) {
type accountBalance struct {
Name string
OpeningBalance float64
CurrentBalance float64
Description *string
}
var accounts []accountBalance
query := r.db.WithContext(ctx).
Table("accounts").
Select("name, opening_balance, current_balance, description").
Where("organization_id = ?", organizationID).
Where("account_type IN ?", []entities.AccountType{entities.AccountTypeCash, entities.AccountTypeWallet, entities.AccountTypeBank}).
Where("is_active = ?", true)
if outletID != nil {
query = query.Where("outlet_id = ? OR outlet_id IS NULL", *outletID)
} else {
query = query.Where("outlet_id IS NULL")
}
err := query.
Order("number ASC, name ASC").
Scan(&accounts).Error
if err != nil {
return nil, err
}
balances := make([]entities.ExclusiveSummaryBankBalance, len(accounts))
for i, account := range accounts {
openingBalance := account.OpeningBalance
closingBalance := account.CurrentBalance
balances[i] = entities.ExclusiveSummaryBankBalance{
Bank: account.Name,
OpeningBalance: &openingBalance,
ClosingBalance: &closingBalance,
Notes: account.Description,
}
}
return balances, nil
}
+38 -8
View File
@@ -30,6 +30,7 @@ func (r *ExpenseRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) (*ent
var expense entities.Expense
err := r.db.WithContext(ctx).
Preload("Items.ChartOfAccount").
Preload("Items.PurchaseCategory").
First(&expense, "id = ?", id).Error
if err != nil {
return nil, err
@@ -41,6 +42,7 @@ func (r *ExpenseRepositoryImpl) GetByIDAndOrganizationID(ctx context.Context, id
var expense entities.Expense
err := r.db.WithContext(ctx).
Preload("Items.ChartOfAccount").
Preload("Items.PurchaseCategory").
Where("id = ? AND organization_id = ?", id, organizationID).
First(&expense).Error
if err != nil {
@@ -107,6 +109,7 @@ func (r *ExpenseRepositoryImpl) List(ctx context.Context, organizationID uuid.UU
err := query.
Preload("Items.ChartOfAccount").
Preload("Items.PurchaseCategory").
Order("created_at DESC").
Limit(limit).
Offset(offset).
@@ -139,7 +142,7 @@ func (r *ExpenseRepositoryImpl) GetAnalytics(ctx context.Context, organizationID
Table("expense_items ei").
Select(`
COUNT(ei.id) as total_items,
COUNT(DISTINCT ei.chart_of_account_id) as total_categories
COUNT(DISTINCT ei.purchase_category_id) as total_categories
`).
Joins("JOIN expenses e ON ei.expense_id = e.id").
Where("e.organization_id = ?", organizationID).
@@ -174,7 +177,7 @@ func (r *ExpenseRepositoryImpl) GetAnalytics(ctx context.Context, organizationID
COALESCE(SUM(item_counts.categories), 0) as categories
`).
Joins(`LEFT JOIN (
SELECT expense_id, COUNT(id) as items, COUNT(DISTINCT chart_of_account_id) as categories
SELECT expense_id, COUNT(id) as items, COUNT(DISTINCT purchase_category_id) as categories
FROM expense_items
GROUP BY expense_id
) item_counts ON item_counts.expense_id = e.id`).
@@ -192,6 +195,32 @@ func (r *ExpenseRepositoryImpl) GetAnalytics(ctx context.Context, organizationID
var categoryData []entities.ExpenseAnalyticsCategoryData
categoryQuery := r.db.WithContext(ctx).
Table("expense_items ei").
Select(`
pc.id as purchase_category_id,
pc.name as purchase_category_name,
pc.type as purchase_category_type,
COALESCE(SUM(ei.amount), 0) as total_amount,
COUNT(DISTINCT e.id) as expense_count,
COUNT(ei.id) as item_count
`).
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).
Group("pc.id, pc.name, pc.type").
Order("total_amount DESC")
if outletID != nil {
categoryQuery = categoryQuery.Where("e.outlet_id = ?", *outletID)
}
if err := categoryQuery.Scan(&categoryData).Error; err != nil {
return nil, err
}
var chartOfAccountData []entities.ExpenseAnalyticsChartOfAccountData
chartOfAccountQuery := r.db.WithContext(ctx).
Table("expense_items ei").
Select(`
COALESCE(parent_coa.id, coa.id) as chart_of_account_id,
@@ -209,9 +238,9 @@ func (r *ExpenseRepositoryImpl) GetAnalytics(ctx context.Context, organizationID
Group("COALESCE(parent_coa.id, coa.id), COALESCE(parent_coa.name, coa.name, 'Lain-lain')").
Order("total_amount DESC")
if outletID != nil {
categoryQuery = categoryQuery.Where("e.outlet_id = ?", *outletID)
chartOfAccountQuery = chartOfAccountQuery.Where("e.outlet_id = ?", *outletID)
}
if err := categoryQuery.Scan(&categoryData).Error; err != nil {
if err := chartOfAccountQuery.Scan(&chartOfAccountData).Error; err != nil {
return nil, err
}
@@ -239,10 +268,11 @@ func (r *ExpenseRepositoryImpl) GetAnalytics(ctx context.Context, organizationID
}
return &entities.ExpenseAnalytics{
Summary: summary,
Data: data,
CategoryData: categoryData,
ItemData: itemData,
Summary: summary,
Data: data,
CategoryData: categoryData,
ChartOfAccountData: chartOfAccountData,
ItemData: itemData,
}, nil
}
@@ -31,6 +31,7 @@ func (r *PurchaseOrderRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID)
err := r.db.WithContext(ctx).
Preload("Vendor").
Preload("Items.Ingredient").
Preload("Items.PurchaseCategory").
Preload("Items.Unit").
Preload("Attachments.File").
First(&po, "id = ?", id).Error
@@ -45,6 +46,7 @@ func (r *PurchaseOrderRepositoryImpl) GetByIDAndOrganizationID(ctx context.Conte
err := r.db.WithContext(ctx).
Preload("Vendor").
Preload("Items.Ingredient").
Preload("Items.PurchaseCategory").
Preload("Items.Unit").
Preload("Attachments.File").
Where("id = ? AND organization_id = ?", id, organizationID).
@@ -105,6 +107,7 @@ func (r *PurchaseOrderRepositoryImpl) List(ctx context.Context, organizationID u
err := query.
Preload("Vendor").
Preload("Items.Ingredient").
Preload("Items.PurchaseCategory").
Preload("Items.Unit").
Preload("Attachments.File").
Order("created_at DESC").
@@ -168,6 +171,7 @@ func (r *PurchaseOrderRepositoryImpl) GetByStatus(ctx context.Context, organizat
Where("organization_id = ? AND status = ?", organizationID, status).
Preload("Vendor").
Preload("Items.Ingredient").
Preload("Items.PurchaseCategory").
Preload("Items.Unit").
Find(&pos).Error
return pos, err
@@ -179,6 +183,7 @@ func (r *PurchaseOrderRepositoryImpl) GetOverdue(ctx context.Context, organizati
Where("organization_id = ? AND due_date < ? AND status IN (?)", organizationID, time.Now(), []string{"draft", "sent", "approved"}).
Preload("Vendor").
Preload("Items.Ingredient").
Preload("Items.PurchaseCategory").
Preload("Items.Unit").
Find(&pos).Error
return pos, err
@@ -191,6 +196,18 @@ func (r *PurchaseOrderRepositoryImpl) UpdateStatus(ctx context.Context, id uuid.
Update("status", status).Error
}
func (r *PurchaseOrderRepositoryImpl) UpdateStatusAndOutlet(ctx context.Context, id uuid.UUID, status string, outletID *uuid.UUID) error {
updates := map[string]interface{}{"status": status}
if outletID != nil {
updates["outlet_id"] = *outletID
}
return r.db.WithContext(ctx).
Model(&entities.PurchaseOrder{}).
Where("id = ?", id).
Updates(updates).Error
}
func (r *PurchaseOrderRepositoryImpl) UpdateTotalAmount(ctx context.Context, id uuid.UUID, totalAmount float64) error {
return r.db.WithContext(ctx).
Model(&entities.PurchaseOrder{}).
@@ -219,6 +236,7 @@ func (r *PurchaseOrderRepositoryImpl) GetItemsByPurchaseOrderID(ctx context.Cont
var items []*entities.PurchaseOrderItem
err := r.db.WithContext(ctx).
Preload("Ingredient").
Preload("PurchaseCategory").
Preload("Unit").
Where("purchase_order_id = ?", purchaseOrderID).
Find(&items).Error
+10 -6
View File
@@ -337,6 +337,9 @@ 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)
analytics.GET("/exclusive-summary/mtd", r.analyticsHandler.GetExclusiveSummaryMTD)
}
tables := protected.Group("/tables")
@@ -353,7 +356,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
}
ingredients := protected.Group("/ingredients")
ingredients.Use(r.authMiddleware.RequireAdminOrManager())
ingredients.Use(r.authMiddleware.RequireAdminOrManagerOrPurchasing())
{
ingredients.POST("", r.ingredientHandler.Create)
ingredients.GET("", r.ingredientHandler.GetAll)
@@ -366,7 +369,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
}
vendors := protected.Group("/vendors")
vendors.Use(r.authMiddleware.RequireAdminOrManager())
vendors.Use(r.authMiddleware.RequireAdminOrManagerOrPurchasing())
{
vendors.POST("", r.vendorHandler.CreateVendor)
vendors.GET("", r.vendorHandler.ListVendors)
@@ -377,7 +380,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
}
purchaseOrders := protected.Group("/purchase-orders")
purchaseOrders.Use(r.authMiddleware.RequireAdminOrManager())
purchaseOrders.Use(r.authMiddleware.RequireAdminOrManagerOrPurchasing())
{
purchaseOrders.POST("", r.purchaseOrderHandler.CreatePurchaseOrder)
purchaseOrders.GET("", r.purchaseOrderHandler.ListPurchaseOrders)
@@ -390,7 +393,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
}
purchaseCategories := protected.Group("/purchase-categories")
purchaseCategories.Use(r.authMiddleware.RequireAdminOrManager())
purchaseCategories.Use(r.authMiddleware.RequireAdminOrManagerOrPurchasing())
{
purchaseCategories.POST("", r.purchaseCategoryHandler.CreatePurchaseCategory)
purchaseCategories.GET("", r.purchaseCategoryHandler.ListPurchaseCategories)
@@ -400,7 +403,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
}
unitConverters := protected.Group("/unit-converters")
unitConverters.Use(r.authMiddleware.RequireAdminOrManager())
unitConverters.Use(r.authMiddleware.RequireAdminOrManagerOrPurchasing())
{
unitConverters.POST("", r.unitConverterHandler.CreateIngredientUnitConverter)
unitConverters.GET("", r.unitConverterHandler.ListIngredientUnitConverters)
@@ -462,7 +465,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
}
expenses := protected.Group("/expenses")
expenses.Use(r.authMiddleware.RequireAdminOrManager())
expenses.Use(r.authMiddleware.RequireAdminOrManagerOrPurchasing())
{
expenses.POST("", r.expenseHandler.CreateExpense)
expenses.GET("", r.expenseHandler.ListExpenses)
@@ -617,6 +620,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
outlets.GET("/:outlet_id/tables/occupied", r.tableHandler.GetOccupiedTables)
// Reports
outlets.GET("/:outlet_id/reports/daily-transaction.pdf", r.reportHandler.GetDailyTransactionReportPDF)
outlets.GET("/:outlet_id/reports/profit-loss.pdf", r.reportHandler.GetProfitLossReportPDF)
}
// User device routes - accessible by authenticated users for their own devices
+103 -4
View File
@@ -18,6 +18,9 @@ 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)
GetExclusiveSummaryMTD(ctx context.Context, req *models.ExclusiveSummaryMTDRequest) (*models.ExclusiveSummaryPeriodResponse, error)
}
type AnalyticsServiceImpl struct {
@@ -193,10 +196,11 @@ func (s *AnalyticsServiceImpl) validatePurchasingAnalyticsRequest(req *models.Pu
if req.GroupBy != "" {
validGroupBy := map[string]bool{
"day": true,
"hour": true,
"week": true,
"month": true,
"day": true,
"hour": true,
"week": true,
"month": true,
"outlet_id": true,
}
if !validGroupBy[req.GroupBy] {
return fmt.Errorf("invalid group_by value: %s", req.GroupBy)
@@ -320,3 +324,98 @@ 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) GetExclusiveSummaryMTD(ctx context.Context, req *models.ExclusiveSummaryMTDRequest) (*models.ExclusiveSummaryPeriodResponse, error) {
if err := s.validateExclusiveSummaryMTDRequest(req); err != nil {
return nil, fmt.Errorf("validation error: %w", err)
}
response, err := s.analyticsProcessor.GetExclusiveSummaryMTD(ctx, req)
if err != nil {
return nil, fmt.Errorf("failed to get exclusive summary mtd: %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) validateExclusiveSummaryMTDRequest(req *models.ExclusiveSummaryMTDRequest) 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.DateTo.IsZero() {
return fmt.Errorf("date_to is required")
}
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
}
+124
View File
@@ -41,6 +41,18 @@ 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 (analyticsProcessorStub) GetExclusiveSummaryMTD(context.Context, *models.ExclusiveSummaryMTDRequest) (*models.ExclusiveSummaryPeriodResponse, error) {
return &models.ExclusiveSummaryPeriodResponse{}, nil
}
func TestAnalyticsServiceGetPurchasingAnalyticsValidation(t *testing.T) {
service := NewAnalyticsServiceImpl(analyticsProcessorStub{})
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
@@ -120,6 +132,21 @@ func TestAnalyticsServiceGetPurchasingAnalyticsAllowsEmptyGroupBy(t *testing.T)
require.NotNil(t, resp)
}
func TestAnalyticsServiceGetPurchasingAnalyticsAllowsOutletGroupBy(t *testing.T) {
service := NewAnalyticsServiceImpl(analyticsProcessorStub{})
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
resp, err := service.GetPurchasingAnalytics(context.Background(), &models.PurchasingAnalyticsRequest{
OrganizationID: uuid.New(),
DateFrom: now,
DateTo: now,
GroupBy: "outlet_id",
})
require.NoError(t, err)
require.NotNil(t, resp)
}
func TestAnalyticsServiceGetProfitLossAnalyticsValidation(t *testing.T) {
service := NewAnalyticsServiceImpl(analyticsProcessorStub{})
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
@@ -190,3 +217,100 @@ func TestAnalyticsServiceGetProfitLossAnalyticsAllowsEmptyGroupBy(t *testing.T)
require.NoError(t, err)
require.Nil(t, resp)
}
func TestAnalyticsServiceGetExclusiveSummaryPeriodValidation(t *testing.T) {
service := NewAnalyticsServiceImpl(analyticsProcessorStub{})
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
tests := []struct {
name string
req *models.ExclusiveSummaryPeriodRequest
wantErr string
}{
{
name: "nil request",
req: nil,
wantErr: "request cannot be nil",
},
{
name: "missing organization",
req: &models.ExclusiveSummaryPeriodRequest{
DateFrom: now,
DateTo: now,
},
wantErr: "organization_id is required",
},
{
name: "reversed dates",
req: &models.ExclusiveSummaryPeriodRequest{
OrganizationID: uuid.New(),
DateFrom: now.AddDate(0, 0, 1),
DateTo: now,
},
wantErr: "date_from cannot be after date_to",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resp, err := service.GetExclusiveSummaryPeriod(context.Background(), tt.req)
require.Nil(t, resp)
require.Error(t, err)
require.Contains(t, err.Error(), tt.wantErr)
})
}
}
func TestAnalyticsServiceGetExclusiveSummaryMonthlyValidation(t *testing.T) {
service := NewAnalyticsServiceImpl(analyticsProcessorStub{})
resp, err := service.GetExclusiveSummaryMonthly(context.Background(), &models.ExclusiveSummaryMonthlyRequest{
OrganizationID: uuid.New(),
})
require.Nil(t, resp)
require.Error(t, err)
require.Contains(t, err.Error(), "month is required")
}
func TestAnalyticsServiceGetExclusiveSummaryMTDValidation(t *testing.T) {
service := NewAnalyticsServiceImpl(analyticsProcessorStub{})
now := time.Date(2026, 6, 18, 23, 59, 59, 0, time.UTC)
tests := []struct {
name string
req *models.ExclusiveSummaryMTDRequest
wantErr string
}{
{
name: "nil request",
req: nil,
wantErr: "request cannot be nil",
},
{
name: "missing organization",
req: &models.ExclusiveSummaryMTDRequest{
DateTo: now,
},
wantErr: "organization_id is required",
},
{
name: "missing date_to",
req: &models.ExclusiveSummaryMTDRequest{
OrganizationID: uuid.New(),
},
wantErr: "date_to is required",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resp, err := service.GetExclusiveSummaryMTD(context.Background(), tt.req)
require.Nil(t, resp)
require.Error(t, err)
require.Contains(t, err.Error(), tt.wantErr)
})
}
}
+19 -18
View File
@@ -10,7 +10,7 @@ import (
)
type InventoryMovementService interface {
CreateIngredientMovement(ctx context.Context, ingredientID, organizationID, outletID, userID uuid.UUID, movementType entities.InventoryMovementType, quantity float64, unitCost float64, reason string, referenceType *entities.InventoryMovementReferenceType, referenceID *uuid.UUID) error
CreateIngredientMovement(ctx context.Context, ingredientID, organizationID, outletID, userID uuid.UUID, movementType entities.InventoryMovementType, quantity float64, unitCost float64, reason string, referenceType *entities.InventoryMovementReferenceType, referenceID *uuid.UUID, purchaseOrderItemID *uuid.UUID) error
CreateProductMovement(ctx context.Context, productID, organizationID, outletID, userID uuid.UUID, movementType entities.InventoryMovementType, quantity float64, unitCost float64, reason string, referenceType *entities.InventoryMovementReferenceType, referenceID *uuid.UUID) error
}
@@ -26,7 +26,7 @@ func NewInventoryMovementService(inventoryMovementRepo repository.InventoryMovem
}
}
func (s *InventoryMovementServiceImpl) CreateIngredientMovement(ctx context.Context, ingredientID, organizationID, outletID, userID uuid.UUID, movementType entities.InventoryMovementType, quantity float64, unitCost float64, reason string, referenceType *entities.InventoryMovementReferenceType, referenceID *uuid.UUID) error {
func (s *InventoryMovementServiceImpl) CreateIngredientMovement(ctx context.Context, ingredientID, organizationID, outletID, userID uuid.UUID, movementType entities.InventoryMovementType, quantity float64, unitCost float64, reason string, referenceType *entities.InventoryMovementReferenceType, referenceID *uuid.UUID, purchaseOrderItemID *uuid.UUID) error {
ingredient, err := s.ingredientRepo.GetByID(ctx, ingredientID, organizationID)
if err != nil {
return err
@@ -36,22 +36,23 @@ func (s *InventoryMovementServiceImpl) CreateIngredientMovement(ctx context.Cont
newQuantity := previousQuantity + quantity
movement := &entities.InventoryMovement{
ID: uuid.New(),
OrganizationID: organizationID,
OutletID: outletID,
ItemID: ingredientID,
ItemType: "INGREDIENT",
MovementType: movementType,
Quantity: quantity,
PreviousQuantity: previousQuantity,
NewQuantity: newQuantity,
UnitCost: unitCost,
TotalCost: unitCost * quantity,
ReferenceType: referenceType,
ReferenceID: referenceID,
UserID: userID,
Reason: &reason,
CreatedAt: time.Now(),
ID: uuid.New(),
OrganizationID: organizationID,
OutletID: outletID,
ItemID: ingredientID,
ItemType: "INGREDIENT",
MovementType: movementType,
Quantity: quantity,
PreviousQuantity: previousQuantity,
NewQuantity: newQuantity,
UnitCost: unitCost,
TotalCost: unitCost * quantity,
ReferenceType: referenceType,
ReferenceID: referenceID,
PurchaseOrderItemID: purchaseOrderItemID,
UserID: userID,
Reason: &reason,
CreatedAt: time.Now(),
}
err = s.inventoryMovementRepo.Create(ctx, movement)
+12 -2
View File
@@ -40,7 +40,12 @@ func (s *PurchaseOrderServiceImpl) CreatePurchaseOrder(ctx context.Context, apct
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
}
poResponse, err := s.purchaseOrderProcessor.CreatePurchaseOrder(ctx, apctx.OrganizationID, modelReq)
var outletID *uuid.UUID
if apctx.OutletID != uuid.Nil {
outletID = &apctx.OutletID
}
poResponse, err := s.purchaseOrderProcessor.CreatePurchaseOrder(ctx, apctx.OrganizationID, outletID, modelReq)
if err != nil {
errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.PurchaseOrderServiceEntity, err.Error())
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
@@ -57,7 +62,12 @@ func (s *PurchaseOrderServiceImpl) UpdatePurchaseOrder(ctx context.Context, apct
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
}
poResponse, err := s.purchaseOrderProcessor.UpdatePurchaseOrder(ctx, id, apctx.OrganizationID, modelReq)
var outletID *uuid.UUID
if apctx.OutletID != uuid.Nil {
outletID = &apctx.OutletID
}
poResponse, err := s.purchaseOrderProcessor.UpdatePurchaseOrder(ctx, id, apctx.OrganizationID, outletID, modelReq)
if err != nil {
errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.PurchaseOrderServiceEntity, err.Error())
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
+294
View File
@@ -17,6 +17,7 @@ import (
type ReportService interface {
// Returns (publicURL, fileName, error)
GenerateDailyTransactionPDF(ctx context.Context, organizationID string, outletID string, reportDate *time.Time, generatedBy string) (string, string, error)
GenerateProfitLossPDF(ctx context.Context, organizationID string, outletID string, reportDate *time.Time, generatedBy string) (string, string, error)
}
type ReportServiceImpl struct {
@@ -218,3 +219,296 @@ func getPLPctByID(rows []models.ProfitLossSummaryRow, id string) float64 {
}
return 0
}
// profitLossReportData holds data for the profit/loss PDF template
type profitLossReportData struct {
OrganizationName string
MonthName string
ReportDate string
ReportDateUpper string
TotalPenjualan string
TotalBiaya string
LabaRugi string
LabaRugiClass string
LabaRugiValueClass string
LabaRugiMtd string
LabaRugiMtdClass string
LabaRugiMtdValueClass string
MainSummary []profitLossSummaryRowView
PurchasingItems []profitLossPurchasingItem
PurchasingTotal string
GeneratedBy string
PrintTime string
}
type profitLossSummaryRowView struct {
Number string
Label string
TodayNominal string
TodayPct string
MtdNominal string
MtdPct string
RowClass string
SubItems []profitLossSummaryRowView
}
type profitLossPurchasingItem struct {
Name string
Amount string
}
func (s *ReportServiceImpl) GenerateProfitLossPDF(ctx context.Context, organizationID string, outletID string, reportDate *time.Time, generatedBy string) (string, string, error) {
orgID, err := uuid.Parse(organizationID)
if err != nil {
return "", "", fmt.Errorf("invalid organization id: %w", err)
}
var outID *uuid.UUID
if outletID != "" {
parsed, err := uuid.Parse(outletID)
if err != nil {
return "", "", fmt.Errorf("invalid outlet id: %w", err)
}
outID = &parsed
}
org, err := s.organizationRepo.GetByID(ctx, orgID)
if err != nil {
return "", "", fmt.Errorf("organization not found: %w", err)
}
var tzName string
if outID != nil {
outlet, err := s.outletRepo.GetByID(ctx, *outID)
if err == nil && outlet.Timezone != nil && *outlet.Timezone != "" {
tzName = *outlet.Timezone
}
}
if tzName == "" {
tzName = "Asia/Jakarta"
}
loc, locErr := time.LoadLocation(tzName)
if locErr != nil || loc == nil {
loc = time.Local
}
var day time.Time
if reportDate != nil {
t := reportDate.UTC()
day = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc)
} else {
now := time.Now().In(loc)
day = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
}
dayStart := day
dayEnd := day.Add(24*time.Hour - time.Nanosecond)
// MTD: from 1st of month to end of the report day
mtdStart := time.Date(day.Year(), day.Month(), 1, 0, 0, 0, 0, loc)
mtdEnd := dayEnd
// Get profit/loss analytics for the day
plReq := &models.ProfitLossAnalyticsRequest{
OrganizationID: orgID,
OutletID: outID,
DateFrom: dayStart,
DateTo: mtdEnd,
GroupBy: "day",
}
pl, err := s.analyticsService.GetProfitLossAnalytics(ctx, plReq)
if err != nil {
return "", "", fmt.Errorf("get profit/loss analytics: %w", err)
}
// Get purchasing analytics for the day (Rincian Biaya / Catatan)
purchReq := &models.PurchasingAnalyticsRequest{
OrganizationID: orgID,
OutletID: outID,
DateFrom: dayStart,
DateTo: dayEnd,
GroupBy: "day",
}
purch, err := s.analyticsService.GetPurchasingAnalytics(ctx, purchReq)
if err != nil {
return "", "", fmt.Errorf("get purchasing analytics: %w", err)
}
// Build summary values
totalOmset := getPLNominalByID(pl.MainSummary, "total_omset")
hpp := getPLNominalByID(pl.MainSummary, "hpp")
_ = mtdStart // used above
// Total biaya = HPP + operational expenses for the day
totalBiayaToday := hpp + pl.OperationalExpensesTotal
// Laba/Rugi today
labaRugiToday := totalOmset - totalBiayaToday
// MTD values
mtdOmset := getMtdNominalByID(pl.MainSummary, "total_omset")
mtdCost := getMtdNominalByID(pl.MainSummary, "hpp")
mtdOps := getMtdNominalByID(pl.MainSummary, "biaya_ops")
mtdGaji := getMtdNominalByID(pl.MainSummary, "biaya_gaji")
labaRugiMtd := mtdOmset - mtdCost - mtdOps - mtdGaji
// Month name in Indonesian
monthNames := []string{"", "Januari", "Februari", "Maret", "April", "Mei", "Juni", "Juli", "Agustus", "September", "Oktober", "November", "Desember"}
monthName := fmt.Sprintf("%s %d", monthNames[day.Month()], day.Year())
reportDateStr := fmt.Sprintf("%d %s %d", day.Day(), monthNames[day.Month()], day.Year())
reportDateUpper := fmt.Sprintf("%d %s %d", day.Day(), strings.ToUpper(monthNames[day.Month()]), day.Year())
// Build main summary rows
mainSummaryRows := buildProfitLossSummaryRows(pl.MainSummary)
// Build purchasing items from ingredient data
purchItems := make([]profitLossPurchasingItem, 0)
var purchTotal float64
for _, item := range purch.IngredientData {
purchItems = append(purchItems, profitLossPurchasingItem{
Name: item.IngredientName,
Amount: formatCurrency(item.TotalCost),
})
purchTotal += item.TotalCost
}
// Determine highlight classes
labaRugiClass := ""
labaRugiValueClass := ""
if labaRugiToday < 0 {
labaRugiClass = "highlight-red"
labaRugiValueClass = "negative"
} else {
labaRugiClass = "highlight-green"
labaRugiValueClass = "positive"
}
labaRugiMtdClass := ""
labaRugiMtdValueClass := ""
if labaRugiMtd < 0 {
labaRugiMtdClass = "highlight-red"
labaRugiMtdValueClass = "negative"
} else {
labaRugiMtdClass = "highlight-green"
labaRugiMtdValueClass = "positive"
}
data := profitLossReportData{
OrganizationName: org.Name,
MonthName: monthName,
ReportDate: reportDateStr,
ReportDateUpper: reportDateUpper,
TotalPenjualan: formatCurrency(totalOmset),
TotalBiaya: formatCurrency(totalBiayaToday),
LabaRugi: formatCurrencySigned(labaRugiToday),
LabaRugiClass: labaRugiClass,
LabaRugiValueClass: labaRugiValueClass,
LabaRugiMtd: formatCurrencySigned(labaRugiMtd),
LabaRugiMtdClass: labaRugiMtdClass,
LabaRugiMtdValueClass: labaRugiMtdValueClass,
MainSummary: mainSummaryRows,
PurchasingItems: purchItems,
PurchasingTotal: formatCurrency(purchTotal),
GeneratedBy: generatedBy,
PrintTime: time.Now().In(loc).Format("02/01/2006 15:04:05"),
}
templatePath := filepath.Join("templates", "profit_loss_report.html")
pdfBytes, err := renderTemplateToPDF(templatePath, data)
if err != nil {
return "", "", fmt.Errorf("render pdf: %w", err)
}
safeOrg := orgID.String()
safeOutlet := "all"
if outID != nil {
safeOutlet = outID.String()
}
fileName := fmt.Sprintf("laporan-laba-rugi-%s-%s.pdf", day.Format("2006-01-02"), time.Now().Format("20060102-150405"))
objectKey := fmt.Sprintf("/reports/%s/%s/%s", safeOrg, safeOutlet, fileName)
publicURL, err := s.fileClient.UploadFile(ctx, objectKey, pdfBytes)
if err != nil {
return "", "", fmt.Errorf("upload pdf: %w", err)
}
return publicURL, fileName, nil
}
func getMtdNominalByID(rows []models.ProfitLossSummaryRow, id string) float64 {
for _, row := range rows {
if row.ID == id {
return row.MtdNominal
}
}
return 0
}
func buildProfitLossSummaryRows(rows []models.ProfitLossSummaryRow) []profitLossSummaryRowView {
result := make([]profitLossSummaryRowView, 0, len(rows))
for i, row := range rows {
rowClass := ""
if row.IsBold {
rowClass = "highlight-green-row"
}
// Highlight laba kotor row
if row.ID == "laba_kotor" {
rowClass = "highlight-row"
}
number := ""
if row.ID != "" {
number = fmt.Sprintf("%d", i+1)
}
subItems := make([]profitLossSummaryRowView, 0)
for _, sub := range row.SubItems {
subItems = append(subItems, profitLossSummaryRowView{
Label: sub.Label,
TodayNominal: formatCurrencyOrDash(sub.TodayNominal),
TodayPct: formatPct(sub.TodayPct),
MtdNominal: formatCurrencyOrDash(sub.MtdNominal),
MtdPct: formatPct(sub.MtdPct),
RowClass: "",
})
}
result = append(result, profitLossSummaryRowView{
Number: number,
Label: row.Label,
TodayNominal: formatCurrencyOrDash(row.TodayNominal),
TodayPct: formatPct(row.TodayPct),
MtdNominal: formatCurrencyOrDash(row.MtdNominal),
MtdPct: formatPct(row.MtdPct),
RowClass: rowClass,
SubItems: subItems,
})
}
return result
}
func formatCurrencyOrDash(amount float64) string {
if amount == 0 {
return "-"
}
if amount < 0 {
return formatCurrencySigned(amount)
}
return formatCurrency(amount)
}
func formatCurrencySigned(amount float64) string {
if amount < 0 {
return "(Rp " + addThousandsSep(fmt.Sprintf("%.0f", -amount)) + ")"
}
return "Rp " + addThousandsSep(fmt.Sprintf("%.0f", amount))
}
func formatPct(pct float64) string {
if pct == 0 {
return "0%"
}
return fmt.Sprintf("%.0f%%", pct)
}
+334 -15
View File
@@ -66,6 +66,7 @@ func PaymentMethodAnalyticsModelToContract(resp *models.PaymentMethodAnalyticsRe
return &contract.PaymentMethodAnalyticsResponse{
OrganizationID: resp.OrganizationID,
OutletID: resp.OutletID,
OutletName: resp.OutletName,
DateFrom: resp.DateFrom,
DateTo: resp.DateTo,
GroupBy: resp.GroupBy,
@@ -122,6 +123,7 @@ func SalesAnalyticsModelToContract(resp *models.SalesAnalyticsResponse) *contrac
return &contract.SalesAnalyticsResponse{
OrganizationID: resp.OrganizationID,
OutletID: resp.OutletID,
OutletName: resp.OutletName,
DateFrom: resp.DateFrom,
DateTo: resp.DateTo,
GroupBy: resp.GroupBy,
@@ -169,12 +171,33 @@ func PurchasingAnalyticsModelToContract(resp *models.PurchasingAnalyticsResponse
data := make([]contract.PurchasingAnalyticsData, len(resp.Data))
for i, item := range resp.Data {
data[i] = contract.PurchasingAnalyticsData{
Date: item.Date,
Purchases: item.Purchases,
PurchaseOrders: item.PurchaseOrders,
Quantity: item.Quantity,
Ingredients: item.Ingredients,
Vendors: item.Vendors,
Date: item.Date,
Purchases: item.Purchases,
RawMaterialPurchases: item.RawMaterialPurchases,
ExpensePurchases: item.ExpensePurchases,
PurchaseOrders: item.PurchaseOrders,
RawMaterialPurchaseOrders: item.RawMaterialPurchaseOrders,
ExpenseCount: item.ExpenseCount,
Quantity: item.Quantity,
Ingredients: item.Ingredients,
Vendors: item.Vendors,
}
}
outletData := make([]contract.PurchasingOutletData, len(resp.OutletData))
for i, item := range resp.OutletData {
outletData[i] = contract.PurchasingOutletData{
OutletID: item.OutletID,
OutletName: item.OutletName,
Purchases: item.Purchases,
RawMaterialPurchases: item.RawMaterialPurchases,
ExpensePurchases: item.ExpensePurchases,
PurchaseOrders: item.PurchaseOrders,
RawMaterialPurchaseOrders: item.RawMaterialPurchaseOrders,
ExpenseCount: item.ExpenseCount,
Quantity: item.Quantity,
Ingredients: item.Ingredients,
Vendors: item.Vendors,
}
}
@@ -211,13 +234,18 @@ func PurchasingAnalyticsModelToContract(resp *models.PurchasingAnalyticsResponse
GroupBy: resp.GroupBy,
Summary: contract.PurchasingSummary{
TotalPurchases: resp.Summary.TotalPurchases,
RawMaterialPurchases: resp.Summary.RawMaterialPurchases,
ExpensePurchases: resp.Summary.ExpensePurchases,
TotalPurchaseOrders: resp.Summary.TotalPurchaseOrders,
RawMaterialPurchaseOrders: resp.Summary.RawMaterialPurchaseOrders,
ExpenseCount: resp.Summary.ExpenseCount,
TotalQuantity: resp.Summary.TotalQuantity,
AveragePurchaseOrderValue: resp.Summary.AveragePurchaseOrderValue,
TotalIngredients: resp.Summary.TotalIngredients,
TotalVendors: resp.Summary.TotalVendors,
},
Data: data,
OutletData: outletData,
IngredientData: ingredientData,
VendorData: vendorData,
}
@@ -277,6 +305,7 @@ func ProductAnalyticsModelToContract(resp *models.ProductAnalyticsResponse) *con
return &contract.ProductAnalyticsResponse{
OrganizationID: resp.OrganizationID,
OutletID: resp.OutletID,
OutletName: resp.OutletName,
DateFrom: resp.DateFrom,
DateTo: resp.DateTo,
Data: data,
@@ -329,6 +358,7 @@ func ProductAnalyticsPerCategoryModelToContract(resp *models.ProductAnalyticsPer
return &contract.ProductAnalyticsPerCategoryResponse{
OrganizationID: resp.OrganizationID,
OutletID: resp.OutletID,
OutletName: resp.OutletName,
DateFrom: resp.DateFrom,
DateTo: resp.DateTo,
Data: data,
@@ -413,15 +443,19 @@ func DashboardAnalyticsModelToContract(resp *models.DashboardAnalyticsResponse)
return &contract.DashboardAnalyticsResponse{
OrganizationID: resp.OrganizationID,
OutletID: resp.OutletID,
OutletName: resp.OutletName,
DateFrom: resp.DateFrom,
DateTo: resp.DateTo,
Overview: contract.DashboardOverview{
TotalSales: resp.Overview.TotalSales,
TotalOrders: resp.Overview.TotalOrders,
AverageOrderValue: resp.Overview.AverageOrderValue,
TotalCustomers: resp.Overview.TotalCustomers,
VoidedOrders: resp.Overview.VoidedOrders,
RefundedOrders: resp.Overview.RefundedOrders,
TotalSales: resp.Overview.TotalSales,
TotalOrders: resp.Overview.TotalOrders,
AverageOrderValue: resp.Overview.AverageOrderValue,
TotalCustomers: resp.Overview.TotalCustomers,
VoidedOrders: resp.Overview.VoidedOrders,
RefundedOrders: resp.Overview.RefundedOrders,
TotalItemSold: resp.Overview.TotalItemSold,
TotalLowStock: resp.Overview.TotalLowStock,
TotalProductActive: resp.Overview.TotalProductActive,
},
TopProducts: topProducts,
PaymentMethods: paymentMethods,
@@ -508,9 +542,20 @@ func ProfitLossAnalyticsModelToContract(resp *models.ProfitLossAnalyticsResponse
}
}
purchasingItems := make([]contract.ProfitLossPurchasingItem, len(resp.Purchasing.Items))
for i, item := range resp.Purchasing.Items {
purchasingItems[i] = contract.ProfitLossPurchasingItem{
Date: item.Date,
Item: item.Item,
Quantity: item.Quantity,
Nominal: item.Nominal,
}
}
return &contract.ProfitLossAnalyticsResponse{
OrganizationID: resp.OrganizationID,
OutletID: resp.OutletID,
OutletName: resp.OutletName,
DateFrom: resp.DateFrom,
DateTo: resp.DateTo,
GroupBy: resp.GroupBy,
@@ -527,9 +572,18 @@ func ProfitLossAnalyticsModelToContract(resp *models.ProfitLossAnalyticsResponse
AverageProfit: resp.Summary.AverageProfit,
ProfitabilityRatio: resp.Summary.ProfitabilityRatio,
},
Data: data,
ProductData: productData,
MainSummary: mainSummary,
Data: data,
ProductData: productData,
MainSummary: mainSummary,
Purchasing: contract.ProfitLossPurchasing{
TodayTotal: resp.Purchasing.TodayTotal,
MtdTotal: resp.Purchasing.MtdTotal,
TodayRawMaterial: resp.Purchasing.TodayRawMaterial,
MtdRawMaterial: resp.Purchasing.MtdRawMaterial,
TodayExpense: resp.Purchasing.TodayExpense,
MtdExpense: resp.Purchasing.MtdExpense,
Items: purchasingItems,
},
OperationalExpenses: opsItems,
OperationalExpensesTotal: resp.OperationalExpensesTotal,
}
@@ -551,3 +605,268 @@ 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 ExclusiveSummaryMTDContractToModel(req *contract.ExclusiveSummaryMTDRequest) (*models.ExclusiveSummaryMTDRequest, error) {
if req == nil {
return nil, fmt.Errorf("request cannot be nil")
}
dateTo, err := parseFlexibleDateToJakartaTime(req.DateTo, true)
if err != nil {
return nil, fmt.Errorf("invalid date_to: %w", err)
}
if dateTo == nil {
return nil, fmt.Errorf("date_to is required")
}
return &models.ExclusiveSummaryMTDRequest{
OrganizationID: req.OrganizationID,
OutletID: parseOutletID(req.OutletID),
DateTo: *dateTo,
ExcludeGajiStaffFromReimburse: req.ExcludeGajiStaffFromReimburse,
}, 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,
OutletName: resp.OutletName,
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,
OutletName: resp.OutletName,
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 parseFlexibleDateToJakartaTime(dateStr string, endOfDay bool) (*time.Time, error) {
if dateStr == "" {
return nil, nil
}
fromTime, toTime, err := util.ParseDateRangeToJakartaTime(dateStr, dateStr)
if err == nil {
if endOfDay {
return toTime, nil
}
return fromTime, nil
}
return parseISODateToJakartaTime(dateStr, endOfDay)
}
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
}
@@ -52,17 +52,47 @@ func TestPurchasingAnalyticsContractToModelIgnoresInvalidOutlet(t *testing.T) {
func TestPurchasingAnalyticsModelToContractCopiesOutletName(t *testing.T) {
outletID := uuid.New()
outletName := "Main Outlet"
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
result := PurchasingAnalyticsModelToContract(&models.PurchasingAnalyticsResponse{
OrganizationID: uuid.New(),
OutletID: &outletID,
OutletName: &outletName,
Summary: models.PurchasingSummary{
TotalPurchases: 300,
RawMaterialPurchases: 125,
ExpensePurchases: 175,
TotalPurchaseOrders: 3,
RawMaterialPurchaseOrders: 1,
ExpenseCount: 2,
},
Data: []models.PurchasingAnalyticsData{
{
Date: now,
Purchases: 300,
RawMaterialPurchases: 125,
ExpensePurchases: 175,
PurchaseOrders: 3,
RawMaterialPurchaseOrders: 1,
ExpenseCount: 2,
},
},
})
require.NotNil(t, result)
require.Equal(t, &outletID, result.OutletID)
require.NotNil(t, result.OutletName)
require.Equal(t, outletName, *result.OutletName)
require.Equal(t, float64(300), result.Summary.TotalPurchases)
require.Equal(t, float64(125), result.Summary.RawMaterialPurchases)
require.Equal(t, float64(175), result.Summary.ExpensePurchases)
require.Equal(t, int64(3), result.Summary.TotalPurchaseOrders)
require.Equal(t, int64(1), result.Summary.RawMaterialPurchaseOrders)
require.Equal(t, int64(2), result.Summary.ExpenseCount)
require.Len(t, result.Data, 1)
require.Equal(t, float64(300), result.Data[0].Purchases)
require.Equal(t, float64(125), result.Data[0].RawMaterialPurchases)
require.Equal(t, float64(175), result.Data[0].ExpensePurchases)
}
func TestPurchasingAnalyticsModelToContractOmitsNilOutletName(t *testing.T) {
@@ -75,6 +105,40 @@ func TestPurchasingAnalyticsModelToContractOmitsNilOutletName(t *testing.T) {
require.NotContains(t, string(payload), "outlet_name")
}
func TestPurchasingAnalyticsModelToContractCopiesOutletData(t *testing.T) {
outletID := uuid.New()
result := PurchasingAnalyticsModelToContract(&models.PurchasingAnalyticsResponse{
OrganizationID: uuid.New(),
GroupBy: "outlet_id",
OutletData: []models.PurchasingOutletData{
{
OutletID: &outletID,
OutletName: "Outlet A",
Purchases: 500,
RawMaterialPurchases: 350,
ExpensePurchases: 150,
PurchaseOrders: 4,
RawMaterialPurchaseOrders: 3,
ExpenseCount: 2,
Quantity: 10,
Ingredients: 5,
Vendors: 2,
},
},
})
require.NotNil(t, result)
require.Equal(t, "outlet_id", result.GroupBy)
require.Len(t, result.OutletData, 1)
require.Equal(t, &outletID, result.OutletData[0].OutletID)
require.Equal(t, "Outlet A", result.OutletData[0].OutletName)
require.Equal(t, float64(500), result.OutletData[0].Purchases)
require.Equal(t, float64(350), result.OutletData[0].RawMaterialPurchases)
require.Equal(t, float64(150), result.OutletData[0].ExpensePurchases)
require.Equal(t, int64(4), result.OutletData[0].PurchaseOrders)
}
func TestProfitLossAnalyticsContractToModelParsesDateRange(t *testing.T) {
orgID := uuid.New()
outletID := uuid.New().String()
@@ -152,3 +216,43 @@ func TestProfitLossAnalyticsModelToContractCopiesDateRange(t *testing.T) {
require.Len(t, result.MainSummary, 1)
require.Equal(t, "total_omset", result.MainSummary[0].ID)
}
func TestExclusiveSummaryPeriodContractToModelParsesFlexibleDates(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)
}
+39 -20
View File
@@ -27,10 +27,11 @@ func CreateExpenseRequestToModel(req *contract.CreateExpenseRequest) *models.Cre
func CreateExpenseItemRequestToModel(req *contract.CreateExpenseItemRequest) models.CreateExpenseItemRequest {
return models.CreateExpenseItemRequest{
ChartOfAccountID: req.ChartOfAccountID,
Item: req.Item,
Description: req.Description,
Amount: req.Amount,
ChartOfAccountID: req.ChartOfAccountID,
PurchaseCategoryID: req.PurchaseCategoryID,
Item: req.Item,
Description: req.Description,
Amount: req.Amount,
}
}
@@ -60,10 +61,11 @@ func UpdateExpenseRequestToModel(req *contract.UpdateExpenseRequest) *models.Upd
func UpdateExpenseItemRequestToModel(req *contract.UpdateExpenseItemRequest) models.UpdateExpenseItemRequest {
return models.UpdateExpenseItemRequest{
ChartOfAccountID: req.ChartOfAccountID,
Item: req.Item,
Description: req.Description,
Amount: req.Amount,
ChartOfAccountID: req.ChartOfAccountID,
PurchaseCategoryID: req.PurchaseCategoryID,
Item: req.Item,
Description: req.Description,
Amount: req.Amount,
}
}
@@ -109,15 +111,19 @@ func ExpenseModelResponseToResponse(expense *models.ExpenseResponse) *contract.E
func ExpenseItemModelResponseToResponse(item *models.ExpenseItemResponse) contract.ExpenseItemResponse {
return contract.ExpenseItemResponse{
ID: item.ID,
ExpenseID: item.ExpenseID,
ChartOfAccountID: item.ChartOfAccountID,
ChartOfAccountName: item.ChartOfAccountName,
Item: item.Item,
Description: item.Description,
Amount: item.Amount,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
ID: item.ID,
ExpenseID: item.ExpenseID,
ChartOfAccountID: item.ChartOfAccountID,
ChartOfAccountName: item.ChartOfAccountName,
PurchaseCategoryID: item.PurchaseCategoryID,
PurchaseCategoryName: item.PurchaseCategoryName,
PurchaseCategoryType: item.PurchaseCategoryType,
PurchaseCategory: PurchaseCategoryModelResponseToResponse(item.PurchaseCategory),
Item: item.Item,
Description: item.Description,
Amount: item.Amount,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
}
}
@@ -176,6 +182,18 @@ func ExpenseAnalyticsModelToContract(resp *models.ExpenseAnalyticsResponse) *con
categoryData := make([]contract.ExpenseAnalyticsCategoryData, len(resp.CategoryData))
for i, item := range resp.CategoryData {
categoryData[i] = contract.ExpenseAnalyticsCategoryData{
PurchaseCategoryID: item.PurchaseCategoryID,
PurchaseCategoryName: item.PurchaseCategoryName,
PurchaseCategoryType: item.PurchaseCategoryType,
TotalAmount: item.TotalAmount,
ExpenseCount: item.ExpenseCount,
ItemCount: item.ItemCount,
}
}
chartOfAccountData := make([]contract.ExpenseAnalyticsChartOfAccountData, len(resp.ChartOfAccountData))
for i, item := range resp.ChartOfAccountData {
chartOfAccountData[i] = contract.ExpenseAnalyticsChartOfAccountData{
ChartOfAccountID: item.ChartOfAccountID,
ChartOfAccountName: item.ChartOfAccountName,
TotalAmount: item.TotalAmount,
@@ -208,8 +226,9 @@ func ExpenseAnalyticsModelToContract(resp *models.ExpenseAnalyticsResponse) *con
TotalCategories: resp.Summary.TotalCategories,
TotalItems: resp.Summary.TotalItems,
},
Data: data,
CategoryData: categoryData,
ItemData: itemData,
Data: data,
CategoryData: categoryData,
ChartOfAccountData: chartOfAccountData,
ItemData: itemData,
}
}
@@ -11,11 +11,12 @@ func CreatePurchaseOrderRequestToModel(req *contract.CreatePurchaseOrderRequest)
items := make([]models.CreatePurchaseOrderItemRequest, len(req.Items))
for i, item := range req.Items {
items[i] = models.CreatePurchaseOrderItemRequest{
IngredientID: item.IngredientID,
Description: item.Description,
Quantity: item.Quantity,
UnitID: item.UnitID,
Amount: item.Amount,
IngredientID: item.IngredientID,
PurchaseCategoryID: item.PurchaseCategoryID,
Description: item.Description,
Quantity: item.Quantity,
UnitID: item.UnitID,
Amount: item.Amount,
}
}
@@ -54,12 +55,13 @@ func UpdatePurchaseOrderRequestToModel(req *contract.UpdatePurchaseOrderRequest)
items = make([]models.UpdatePurchaseOrderItemRequest, len(req.Items))
for i, item := range req.Items {
items[i] = models.UpdatePurchaseOrderItemRequest{
ID: item.ID,
IngredientID: item.IngredientID,
Description: item.Description,
Quantity: item.Quantity,
UnitID: item.UnitID,
Amount: item.Amount,
ID: item.ID,
IngredientID: item.IngredientID,
PurchaseCategoryID: item.PurchaseCategoryID,
Description: item.Description,
Quantity: item.Quantity,
UnitID: item.UnitID,
Amount: item.Amount,
}
}
}
@@ -118,6 +120,7 @@ func PurchaseOrderModelResponseToResponse(po *models.PurchaseOrderResponse) *con
response := &contract.PurchaseOrderResponse{
ID: po.ID,
OrganizationID: po.OrganizationID,
OutletID: po.OutletID,
VendorID: po.VendorID,
PONumber: po.PONumber,
TransactionDate: po.TransactionDate,
@@ -154,15 +157,16 @@ func PurchaseOrderModelResponseToResponse(po *models.PurchaseOrderResponse) *con
response.Items = make([]contract.PurchaseOrderItemResponse, len(po.Items))
for i, item := range po.Items {
response.Items[i] = contract.PurchaseOrderItemResponse{
ID: item.ID,
PurchaseOrderID: item.PurchaseOrderID,
IngredientID: item.IngredientID,
Description: item.Description,
Quantity: item.Quantity,
UnitID: item.UnitID,
Amount: item.Amount,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
ID: item.ID,
PurchaseOrderID: item.PurchaseOrderID,
IngredientID: item.IngredientID,
PurchaseCategoryID: item.PurchaseCategoryID,
Description: item.Description,
Quantity: item.Quantity,
UnitID: item.UnitID,
Amount: item.Amount,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
}
// Map ingredient if present
@@ -173,6 +177,10 @@ func PurchaseOrderModelResponseToResponse(po *models.PurchaseOrderResponse) *con
}
}
if item.PurchaseCategory != nil {
response.Items[i].PurchaseCategory = PurchaseCategoryModelResponseToResponse(item.PurchaseCategory)
}
// Map unit if present
if item.Unit != nil {
response.Items[i].Unit = &contract.UnitResponse{
@@ -12,15 +12,20 @@ import (
)
func TestCreatePurchaseOrderRequestToModelAllowsMissingDueDate(t *testing.T) {
vendorID := uuid.New()
ingredientID := uuid.New()
quantity := 1.0
unitID := uuid.New()
result, err := CreatePurchaseOrderRequestToModel(&contract.CreatePurchaseOrderRequest{
VendorID: uuid.New(),
VendorID: &vendorID,
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,
},
},
@@ -31,9 +36,10 @@ func TestCreatePurchaseOrderRequestToModelAllowsMissingDueDate(t *testing.T) {
}
func TestPurchaseOrderModelResponseToResponseIncludesNullDueDate(t *testing.T) {
vendorID := uuid.New()
result := PurchaseOrderModelResponseToResponse(&models.PurchaseOrderResponse{
ID: uuid.New(),
VendorID: uuid.New(),
VendorID: &vendorID,
PONumber: "PO-001",
})
@@ -41,3 +47,19 @@ func TestPurchaseOrderModelResponseToResponseIncludesNullDueDate(t *testing.T) {
require.NoError(t, err)
require.Contains(t, string(payload), `"due_date":null`)
}
func TestCreatePurchaseOrderRequestToModelAllowsMissingVendor(t *testing.T) {
result, err := CreatePurchaseOrderRequestToModel(&contract.CreatePurchaseOrderRequest{
PONumber: "PO-001",
TransactionDate: "2026-05-29",
Items: []contract.CreatePurchaseOrderItemRequest{
{
PurchaseCategoryID: uuid.New(),
Amount: 1000,
},
},
})
require.NoError(t, err)
require.Nil(t, result.VendorID)
}
+15
View File
@@ -68,12 +68,18 @@ func (v *ExpenseValidatorImpl) ValidateCreateExpenseRequest(req *contract.Create
if strings.TrimSpace(item.ChartOfAccountID) == "" {
return fmt.Errorf("item %d: chart_of_account_id is required", i), constants.MissingFieldErrorCode
}
if strings.TrimSpace(item.PurchaseCategoryID) == "" {
return fmt.Errorf("item %d: purchase_category_id is required", i), constants.MissingFieldErrorCode
}
if strings.TrimSpace(item.Item) == "" {
return fmt.Errorf("item %d: item is required", i), constants.MissingFieldErrorCode
}
if _, err := uuid.Parse(item.ChartOfAccountID); err != nil {
return fmt.Errorf("item %d: chart_of_account_id must be a valid UUID", i), constants.MalformedFieldErrorCode
}
if _, err := uuid.Parse(item.PurchaseCategoryID); err != nil {
return fmt.Errorf("item %d: purchase_category_id must be a valid UUID", i), constants.MalformedFieldErrorCode
}
if item.Amount <= 0 {
return fmt.Errorf("item %d: amount must be greater than 0", i), constants.MalformedFieldErrorCode
}
@@ -126,6 +132,15 @@ func (v *ExpenseValidatorImpl) ValidateUpdateExpenseRequest(req *contract.Update
return fmt.Errorf("item %d: chart_of_account_id must be a valid UUID", i), constants.MalformedFieldErrorCode
}
}
if item.PurchaseCategoryID == nil {
return fmt.Errorf("item %d: purchase_category_id is required", i), constants.MissingFieldErrorCode
}
if strings.TrimSpace(*item.PurchaseCategoryID) == "" {
return fmt.Errorf("item %d: purchase_category_id cannot be empty", i), constants.MalformedFieldErrorCode
}
if _, err := uuid.Parse(*item.PurchaseCategoryID); err != nil {
return fmt.Errorf("item %d: purchase_category_id must be a valid UUID", i), constants.MalformedFieldErrorCode
}
if item.Item != nil && strings.TrimSpace(*item.Item) == "" {
return fmt.Errorf("item %d: item cannot be empty", i), constants.MalformedFieldErrorCode
}
+17 -12
View File
@@ -21,8 +21,9 @@ func TestExpenseValidatorCreateRequiresItemName(t *testing.T) {
Total: 10000,
Items: []contract.CreateExpenseItemRequest{
{
ChartOfAccountID: uuid.NewString(),
Amount: 10000,
ChartOfAccountID: uuid.NewString(),
PurchaseCategoryID: uuid.NewString(),
Amount: 10000,
},
},
}
@@ -45,9 +46,10 @@ func TestExpenseValidatorCreateDoesNotRequireHeaderExpenseName(t *testing.T) {
Total: 10000,
Items: []contract.CreateExpenseItemRequest{
{
ChartOfAccountID: uuid.NewString(),
Item: "Cleaning supplies",
Amount: 10000,
ChartOfAccountID: uuid.NewString(),
PurchaseCategoryID: uuid.NewString(),
Item: "Cleaning supplies",
Amount: 10000,
},
},
}
@@ -71,9 +73,10 @@ func TestExpenseValidatorCreateAllowsValidOptionalStatus(t *testing.T) {
Total: 10000,
Items: []contract.CreateExpenseItemRequest{
{
ChartOfAccountID: uuid.NewString(),
Item: "Cleaning supplies",
Amount: 10000,
ChartOfAccountID: uuid.NewString(),
PurchaseCategoryID: uuid.NewString(),
Item: "Cleaning supplies",
Amount: 10000,
},
},
}
@@ -97,9 +100,10 @@ func TestExpenseValidatorCreateRejectsInvalidStatus(t *testing.T) {
Total: 10000,
Items: []contract.CreateExpenseItemRequest{
{
ChartOfAccountID: uuid.NewString(),
Item: "Cleaning supplies",
Amount: 10000,
ChartOfAccountID: uuid.NewString(),
PurchaseCategoryID: uuid.NewString(),
Item: "Cleaning supplies",
Amount: 10000,
},
},
}
@@ -114,10 +118,11 @@ func TestExpenseValidatorCreateRejectsInvalidStatus(t *testing.T) {
func TestExpenseValidatorUpdateRejectsEmptyItemNameWhenProvided(t *testing.T) {
v := NewExpenseValidator()
empty := " "
purchaseCategoryID := uuid.NewString()
req := &contract.UpdateExpenseRequest{
Items: []contract.UpdateExpenseItemRequest{
{Item: &empty},
{PurchaseCategoryID: &purchaseCategoryID, Item: &empty},
},
}
@@ -34,7 +34,7 @@ func (v *PurchaseCategoryValidatorImpl) ValidateCreatePurchaseCategoryRequest(re
}
if !isValidPurchaseCategoryType(req.Type) {
return errors.New("type must be raw_material or non_inventory"), constants.MalformedFieldErrorCode
return errors.New("type must be raw_material or expense"), constants.MalformedFieldErrorCode
}
if req.Code != nil && len(strings.TrimSpace(*req.Code)) > 100 {
@@ -63,7 +63,7 @@ func (v *PurchaseCategoryValidatorImpl) ValidateUpdatePurchaseCategoryRequest(re
}
if req.Type != nil && !isValidPurchaseCategoryType(*req.Type) {
return errors.New("type must be raw_material or non_inventory"), constants.MalformedFieldErrorCode
return errors.New("type must be raw_material or expense"), constants.MalformedFieldErrorCode
}
if req.Code != nil && len(strings.TrimSpace(*req.Code)) > 100 {
@@ -87,7 +87,7 @@ func (v *PurchaseCategoryValidatorImpl) ValidateListPurchaseCategoriesRequest(re
}
if req.Type != "" && !isValidPurchaseCategoryType(req.Type) {
return errors.New("type must be raw_material or non_inventory"), constants.MalformedFieldErrorCode
return errors.New("type must be raw_material or expense"), constants.MalformedFieldErrorCode
}
return nil, ""
@@ -95,7 +95,7 @@ func (v *PurchaseCategoryValidatorImpl) ValidateListPurchaseCategoriesRequest(re
func isValidPurchaseCategoryType(categoryType string) bool {
switch categoryType {
case "raw_material", "non_inventory":
case "raw_material", "expense":
return true
default:
return false
+30 -11
View File
@@ -2,11 +2,14 @@ package validator
import (
"errors"
"strconv"
"strings"
"time"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract"
"github.com/google/uuid"
)
type PurchaseOrderValidator interface {
@@ -26,8 +29,8 @@ func (v *PurchaseOrderValidatorImpl) ValidateCreatePurchaseOrderRequest(req *con
return errors.New("request body is required"), constants.MissingFieldErrorCode
}
if req.VendorID.String() == "" {
return errors.New("vendor_id is required"), constants.MissingFieldErrorCode
if req.VendorID != nil && *req.VendorID == uuid.Nil {
return errors.New("vendor_id cannot be empty"), constants.MalformedFieldErrorCode
}
if strings.TrimSpace(req.PONumber) == "" {
@@ -178,32 +181,48 @@ func (v *PurchaseOrderValidatorImpl) ValidateListPurchaseOrdersRequest(req *cont
}
func (v *PurchaseOrderValidatorImpl) validatePurchaseOrderItem(item *contract.CreatePurchaseOrderItemRequest, index int) (error, string) {
if item.IngredientID.String() == "" {
return errors.New("items[" + string(rune(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 {
return errors.New("items[" + string(rune(index)) + "].quantity must be greater than 0"), constants.MalformedFieldErrorCode
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.String() == "" {
return errors.New("items[" + string(rune(index)) + "].unit_id is required"), constants.MissingFieldErrorCode
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 != nil && *item.UnitID == uuid.Nil {
return errors.New("items[" + strconv.Itoa(index) + "].unit_id cannot be empty"), constants.MalformedFieldErrorCode
}
if item.Amount < 0 {
return errors.New("items[" + string(rune(index)) + "].amount must be greater than or equal to 0"), constants.MalformedFieldErrorCode
return errors.New("items[" + strconv.Itoa(index) + "].amount must be greater than or equal to 0"), constants.MalformedFieldErrorCode
}
return nil, ""
}
func (v *PurchaseOrderValidatorImpl) validateUpdatePurchaseOrderItem(item *contract.UpdatePurchaseOrderItemRequest, index int) (error, string) {
if item.PurchaseCategoryID == nil || *item.PurchaseCategoryID == uuid.Nil {
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 cannot be empty"), constants.MalformedFieldErrorCode
}
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 {
return errors.New("items[" + string(rune(index)) + "].quantity must be greater than 0"), constants.MalformedFieldErrorCode
return errors.New("items[" + strconv.Itoa(index) + "].quantity must be greater than 0"), constants.MalformedFieldErrorCode
}
if item.Amount != nil && *item.Amount < 0 {
return errors.New("items[" + string(rune(index)) + "].amount must be greater than or equal to 0"), constants.MalformedFieldErrorCode
return errors.New("items[" + strconv.Itoa(index) + "].amount must be greater than or equal to 0"), constants.MalformedFieldErrorCode
}
return nil, ""
@@ -11,21 +11,51 @@ import (
)
func validCreatePurchaseOrderRequest() *contract.CreatePurchaseOrderRequest {
vendorID := uuid.New()
ingredientID := uuid.New()
quantity := 1.0
unitID := uuid.New()
return &contract.CreatePurchaseOrderRequest{
VendorID: uuid.New(),
VendorID: &vendorID,
PONumber: "PO-001",
TransactionDate: "2026-05-29",
Items: []contract.CreatePurchaseOrderItemRequest{
{
IngredientID: uuid.New(),
Quantity: 1,
UnitID: uuid.New(),
Amount: 1000,
IngredientID: &ingredientID,
PurchaseCategoryID: uuid.New(),
Quantity: &quantity,
UnitID: &unitID,
Amount: 1000,
},
},
}
}
func TestPurchaseOrderValidatorCreateAllowsMissingVendor(t *testing.T) {
validator := NewPurchaseOrderValidator()
req := validCreatePurchaseOrderRequest()
req.VendorID = nil
err, code := validator.ValidateCreatePurchaseOrderRequest(req)
require.NoError(t, err)
require.Empty(t, code)
}
func TestPurchaseOrderValidatorCreateRejectsEmptyVendor(t *testing.T) {
validator := NewPurchaseOrderValidator()
req := validCreatePurchaseOrderRequest()
vendorID := uuid.Nil
req.VendorID = &vendorID
err, code := validator.ValidateCreatePurchaseOrderRequest(req)
require.Error(t, err)
require.Equal(t, constants.MalformedFieldErrorCode, code)
require.Contains(t, err.Error(), "vendor_id cannot be empty")
}
func TestPurchaseOrderValidatorCreateAllowsMissingDueDate(t *testing.T) {
validator := NewPurchaseOrderValidator()
+6 -4
View File
@@ -140,10 +140,12 @@ func (v *UserValidatorImpl) ValidateUserID(userID uuid.UUID) (error, string) {
func isValidUserRole(role string) bool {
validRoles := map[string]bool{
string(constants.RoleAdmin): true,
string(constants.RoleManager): true,
string(constants.RoleCashier): true,
string(constants.RoleWaiter): true,
string(constants.RoleAdmin): true,
string(constants.RoleManager): true,
string(constants.RoleCashier): true,
string(constants.RoleWaiter): true,
string(constants.RoleOwner): true,
string(constants.RolePurchasing): true,
}
return validRoles[role]
}
@@ -3,7 +3,7 @@ CREATE TABLE purchase_category_presets (
parent_id UUID REFERENCES purchase_category_presets(id) ON DELETE SET NULL,
code VARCHAR(100) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL,
type VARCHAR(20) NOT NULL CHECK (type IN ('raw_material', 'non_inventory')),
type VARCHAR(20) NOT NULL CHECK (type IN ('raw_material', 'expense')),
sort_order INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
@@ -17,7 +17,7 @@ CREATE TABLE purchase_categories (
parent_id UUID REFERENCES purchase_categories(id) ON DELETE SET NULL,
code VARCHAR(100) NOT NULL,
name VARCHAR(255) NOT NULL,
type VARCHAR(20) NOT NULL CHECK (type IN ('raw_material', 'non_inventory')),
type VARCHAR(20) NOT NULL CHECK (type IN ('raw_material', 'expense')),
sort_order INTEGER NOT NULL DEFAULT 0,
is_system BOOLEAN NOT NULL DEFAULT false,
is_active BOOLEAN NOT NULL DEFAULT true,
@@ -38,8 +38,8 @@ CREATE INDEX idx_purchase_categories_is_active ON purchase_categories(is_active)
INSERT INTO purchase_category_presets (code, name, type, sort_order)
VALUES
('hpp', 'HPP', 'raw_material', 1),
('biaya_lain_lain', 'Biaya Lain-lain', 'non_inventory', 2)
('bahan_baku', 'Bahan Baku', 'raw_material', 1),
('biaya_lain_lain', 'Biaya Lain-lain', 'expense', 2)
ON CONFLICT (code) DO NOTHING;
INSERT INTO purchase_category_presets (parent_id, code, name, type, sort_order)
@@ -47,18 +47,14 @@ SELECT parent.id, child.code, child.name, child.type, child.sort_order
FROM purchase_category_presets parent
JOIN (
VALUES
('hpp', 'hpp_bakso_mie_ayam', 'Bakso & Mie Ayam', 'raw_material', 1),
('hpp', 'hpp_nusantara', 'Nusantara', 'raw_material', 2),
('hpp', 'hpp_ramen', 'Ramen', 'raw_material', 3),
('hpp', 'hpp_minuman_kopi', 'Minuman/Kopi', 'raw_material', 4),
('biaya_lain_lain', 'biaya_atk_perlengkapan', 'ATK & Perlengkapan', 'non_inventory', 1),
('biaya_lain_lain', 'biaya_makan_karyawan', 'Makan Karyawan', 'non_inventory', 2),
('biaya_lain_lain', 'biaya_bensin_parkir', 'Bensin & Parkir', 'non_inventory', 3),
('biaya_lain_lain', 'biaya_kebersihan_keamanan', 'Kebersihan & Keamanan', 'non_inventory', 4),
('biaya_lain_lain', 'biaya_gaji_dw', 'Gaji DW', 'non_inventory', 5),
('biaya_lain_lain', 'biaya_gaji_staff', 'Gaji Staff', 'non_inventory', 6),
('biaya_lain_lain', 'biaya_internet_server', 'Internet & Server', 'non_inventory', 7),
('biaya_lain_lain', 'biaya_air_listrik', 'Air & Listrik', 'non_inventory', 8),
('biaya_lain_lain', 'biaya_promosi', 'Promosi', 'non_inventory', 9)
('biaya_lain_lain', 'biaya_atk_perlengkapan', 'ATK & Perlengkapan', 'expense', 1),
('biaya_lain_lain', 'biaya_makan_karyawan', 'Makan Karyawan', 'expense', 2),
('biaya_lain_lain', 'biaya_bensin_parkir', 'Bensin & Parkir', 'expense', 3),
('biaya_lain_lain', 'biaya_kebersihan_keamanan', 'Kebersihan & Keamanan', 'expense', 4),
('biaya_lain_lain', 'biaya_gaji_dw', 'Gaji DW', 'expense', 5),
('biaya_lain_lain', 'biaya_gaji_staff', 'Gaji Staff', 'expense', 6),
('biaya_lain_lain', 'biaya_internet_server', 'Internet & Server', 'expense', 7),
('biaya_lain_lain', 'biaya_air_listrik', 'Air & Listrik', 'expense', 8),
('biaya_lain_lain', 'biaya_promosi', 'Promosi', 'expense', 9)
) AS child(parent_code, code, name, type, sort_order) ON parent.code = child.parent_code
ON CONFLICT (code) DO NOTHING;
@@ -0,0 +1,5 @@
DROP INDEX IF EXISTS idx_inventory_movements_purchase_order_item_id;
ALTER TABLE inventory_movements DROP COLUMN IF EXISTS purchase_order_item_id;
DROP INDEX IF EXISTS idx_purchase_order_items_purchase_category_id;
ALTER TABLE purchase_order_items DROP COLUMN IF EXISTS purchase_category_id;
@@ -0,0 +1,11 @@
ALTER TABLE purchase_order_items
ADD COLUMN IF NOT EXISTS purchase_category_id UUID REFERENCES purchase_categories(id) ON DELETE RESTRICT;
CREATE INDEX IF NOT EXISTS idx_purchase_order_items_purchase_category_id
ON purchase_order_items(purchase_category_id);
ALTER TABLE inventory_movements
ADD COLUMN IF NOT EXISTS purchase_order_item_id UUID REFERENCES purchase_order_items(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_inventory_movements_purchase_order_item_id
ON inventory_movements(purchase_order_item_id);
@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS idx_expense_items_purchase_category_id;
ALTER TABLE expense_items DROP COLUMN IF EXISTS purchase_category_id;
@@ -0,0 +1,5 @@
ALTER TABLE expense_items
ADD COLUMN IF NOT EXISTS purchase_category_id UUID REFERENCES purchase_categories(id) ON DELETE RESTRICT;
CREATE INDEX IF NOT EXISTS idx_expense_items_purchase_category_id
ON expense_items(purchase_category_id);
@@ -0,0 +1,32 @@
ALTER TABLE purchase_category_presets DROP CONSTRAINT IF EXISTS purchase_category_presets_type_check;
ALTER TABLE purchase_categories DROP CONSTRAINT IF EXISTS purchase_categories_type_check;
UPDATE purchase_category_presets
SET type = 'non_inventory'
WHERE type = 'expense';
UPDATE purchase_categories
SET type = 'non_inventory'
WHERE type = 'expense';
UPDATE purchase_category_presets
SET code = 'hpp', name = 'HPP'
WHERE code = 'bahan_baku' AND type = 'raw_material';
UPDATE purchase_categories
SET code = 'hpp', name = 'HPP'
WHERE code = 'bahan_baku' AND type = 'raw_material';
UPDATE purchase_category_presets
SET is_active = true
WHERE code LIKE 'hpp\_%' ESCAPE '\' AND type = 'raw_material';
UPDATE purchase_categories
SET is_active = true
WHERE code LIKE 'hpp\_%' ESCAPE '\' AND type = 'raw_material';
ALTER TABLE purchase_category_presets
ADD CONSTRAINT purchase_category_presets_type_check CHECK (type IN ('raw_material', 'non_inventory'));
ALTER TABLE purchase_categories
ADD CONSTRAINT purchase_categories_type_check CHECK (type IN ('raw_material', 'non_inventory'));
@@ -0,0 +1,36 @@
ALTER TABLE purchase_category_presets DROP CONSTRAINT IF EXISTS purchase_category_presets_type_check;
ALTER TABLE purchase_categories DROP CONSTRAINT IF EXISTS purchase_categories_type_check;
UPDATE purchase_category_presets
SET type = 'expense'
WHERE type = 'non_inventory';
UPDATE purchase_categories
SET type = 'expense'
WHERE type = 'non_inventory';
UPDATE purchase_category_presets
SET code = 'bahan_baku', name = 'Bahan Baku'
WHERE code = 'hpp' AND type = 'raw_material';
UPDATE purchase_categories
SET code = 'bahan_baku', name = 'Bahan Baku'
WHERE code = 'hpp' AND type = 'raw_material';
UPDATE purchase_category_presets
SET is_active = false
WHERE code LIKE 'hpp\_%' ESCAPE '\' AND type = 'raw_material';
UPDATE purchase_categories
SET is_active = false
WHERE code LIKE 'hpp\_%' ESCAPE '\' AND type = 'raw_material';
ALTER TABLE purchase_category_presets
ADD CONSTRAINT purchase_category_presets_type_check CHECK (type IN ('raw_material', 'expense'));
ALTER TABLE purchase_categories
ADD CONSTRAINT purchase_categories_type_check CHECK (type IN ('raw_material', 'expense'));
ALTER TABLE purchase_order_items ALTER COLUMN ingredient_id DROP NOT NULL;
ALTER TABLE purchase_order_items ALTER COLUMN quantity DROP NOT NULL;
ALTER TABLE purchase_order_items ALTER COLUMN unit_id DROP NOT NULL;
@@ -0,0 +1,2 @@
ALTER TABLE purchase_orders
ALTER COLUMN vendor_id SET NOT NULL;
@@ -0,0 +1,2 @@
ALTER TABLE purchase_orders
ALTER COLUMN vendor_id DROP NOT NULL;
@@ -0,0 +1,4 @@
DROP INDEX IF EXISTS idx_purchase_orders_outlet_id;
ALTER TABLE purchase_orders
DROP COLUMN IF EXISTS outlet_id;
@@ -0,0 +1,66 @@
ALTER TABLE purchase_orders
ADD COLUMN IF NOT EXISTS outlet_id UUID REFERENCES outlets(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_purchase_orders_outlet_id
ON purchase_orders(outlet_id);
WITH movement_outlets AS (
SELECT
poi.purchase_order_id,
MIN(im.outlet_id::text)::uuid AS outlet_id
FROM inventory_movements im
JOIN purchase_order_items poi ON im.purchase_order_item_id = poi.id
WHERE im.outlet_id IS NOT NULL
AND im.purchase_order_item_id IS NOT NULL
GROUP BY poi.purchase_order_id
HAVING COUNT(DISTINCT im.outlet_id) = 1
)
UPDATE purchase_orders po
SET outlet_id = movement_outlets.outlet_id
FROM movement_outlets
WHERE po.id = movement_outlets.purchase_order_id
AND po.outlet_id IS NULL;
WITH candidate_item_outlets AS (
SELECT
poi.purchase_order_id,
i.outlet_id
FROM purchase_order_items poi
JOIN ingredients i ON poi.ingredient_id = i.id
WHERE i.outlet_id IS NOT NULL
UNION ALL
SELECT
poi.purchase_order_id,
u.outlet_id
FROM purchase_order_items poi
JOIN units u ON poi.unit_id = u.id
WHERE u.outlet_id IS NOT NULL
), item_outlets AS (
SELECT
purchase_order_id,
MIN(outlet_id::text)::uuid AS outlet_id
FROM candidate_item_outlets
GROUP BY purchase_order_id
HAVING COUNT(DISTINCT outlet_id) = 1
)
UPDATE purchase_orders po
SET outlet_id = item_outlets.outlet_id
FROM item_outlets
WHERE po.id = item_outlets.purchase_order_id
AND po.outlet_id IS NULL;
WITH single_outlet_organizations AS (
SELECT
organization_id,
MIN(id::text)::uuid AS outlet_id
FROM outlets
GROUP BY organization_id
HAVING COUNT(*) = 1
)
UPDATE purchase_orders po
SET outlet_id = single_outlet_organizations.outlet_id
FROM single_outlet_organizations
WHERE po.organization_id = single_outlet_organizations.organization_id
AND po.outlet_id IS NULL;
@@ -0,0 +1,4 @@
-- Revert to original roles
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_role_check;
UPDATE users SET role = 'admin' WHERE role NOT IN ('admin', 'manager', 'cashier', 'waiter');
ALTER TABLE users ADD CONSTRAINT users_role_check CHECK (role IN ('admin', 'manager', 'cashier', 'waiter'));
@@ -0,0 +1,4 @@
-- Add 'owner' and 'purchasing' roles to users table
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_role_check;
UPDATE users SET role = 'admin' WHERE role NOT IN ('admin', 'manager', 'cashier', 'waiter', 'owner', 'purchasing');
ALTER TABLE users ADD CONSTRAINT users_role_check CHECK (role IN ('admin', 'manager', 'cashier', 'waiter', 'owner', 'purchasing'));
+394
View File
@@ -0,0 +1,394 @@
<!doctype html>
<html lang="id">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Laporan Penjualan Harian</title>
<style>
@page {
size: A4;
margin: 10mm 12mm;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
background: #ffffff;
color: #2d3748;
line-height: 1.4;
font-size: 11px;
}
.report-container {
max-width: 210mm;
margin: 0 auto;
padding: 0;
}
/* Header */
.report-header {
text-align: center;
margin-bottom: 20px;
}
.header-logo {
display: inline-block;
width: 60px;
height: 60px;
border-radius: 10px;
overflow: hidden;
margin-bottom: 8px;
background: #dc2626;
padding: 10px;
}
.header-logo svg {
width: 40px;
height: 40px;
}
.header-title {
font-size: 22px;
font-weight: 800;
color: #1a1a1a;
margin-bottom: 4px;
}
.header-org {
font-size: 14px;
font-weight: 700;
color: #dc2626;
margin-bottom: 2px;
}
.header-period {
font-size: 11px;
color: #666;
font-style: italic;
}
/* Summary Boxes */
.summary-boxes {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0;
margin-bottom: 20px;
border: 2px solid #1a1a1a;
}
.summary-box {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 14px;
border-bottom: 1px solid #1a1a1a;
}
.summary-box:nth-child(odd) {
border-right: 1px solid #1a1a1a;
}
.summary-box:nth-child(n + 3) {
border-bottom: none;
}
.summary-box.highlight-green {
background: #dcfce7;
}
.summary-box.highlight-red {
background: #fee2e2;
}
.summary-box-label {
font-size: 10px;
font-weight: 700;
color: #1a1a1a;
text-transform: uppercase;
}
.summary-box-value {
font-size: 13px;
font-weight: 800;
color: #1a1a1a;
font-family: "Courier New", monospace;
}
.summary-box-value.negative {
color: #dc2626;
}
.summary-box-value.positive {
color: #16a34a;
}
/* Section Title */
.section-title {
font-size: 14px;
font-weight: 800;
color: #dc2626;
margin-bottom: 10px;
margin-top: 20px;
}
/* Main Summary Table */
.summary-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 20px;
font-size: 11px;
}
.summary-table thead th {
background: #dc2626;
color: #ffffff;
padding: 8px 10px;
text-align: center;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
border: 1px solid #dc2626;
}
.summary-table thead th:first-child {
width: 30px;
}
.summary-table thead th:nth-child(2) {
text-align: left;
width: 35%;
}
.summary-table tbody td {
padding: 7px 10px;
border: 1px solid #e5e7eb;
font-size: 11px;
}
.summary-table tbody td:first-child {
text-align: center;
font-weight: 600;
}
.summary-table tbody td.nominal {
text-align: right;
font-family: "Courier New", monospace;
font-weight: 600;
}
.summary-table tbody td.pct {
text-align: center;
font-weight: 600;
}
.summary-table tbody tr.bold-row td {
font-weight: 800;
background: #f9fafb;
}
.summary-table tbody tr.highlight-row td {
font-weight: 800;
background: #fef3c7;
}
.summary-table tbody tr.highlight-green-row td {
font-weight: 800;
background: #dcfce7;
}
.summary-table tbody tr.sub-item td {
padding-left: 30px;
font-size: 10px;
color: #4b5563;
}
/* Rincian Biaya Table */
.detail-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 20px;
font-size: 11px;
}
.detail-table thead th {
background: #dc2626;
color: #ffffff;
padding: 8px 10px;
text-align: center;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
border: 1px solid #dc2626;
}
.detail-table thead th:first-child {
width: 30px;
}
.detail-table thead th:nth-child(2) {
text-align: left;
}
.detail-table thead th:last-child {
text-align: right;
width: 25%;
}
.detail-table tbody td {
padding: 7px 10px;
border: 1px solid #e5e7eb;
font-size: 11px;
}
.detail-table tbody td:first-child {
text-align: center;
font-weight: 600;
}
.detail-table tbody td:last-child {
text-align: right;
font-family: "Courier New", monospace;
font-weight: 600;
}
.detail-table tbody tr.total-row td {
font-weight: 800;
background: #fef3c7;
border-top: 2px solid #1a1a1a;
}
/* Footer */
.report-footer {
margin-top: 20px;
padding-top: 10px;
border-top: 1px solid #e5e7eb;
font-size: 9px;
color: #9ca3af;
text-align: center;
}
</style>
</head>
<body>
<div class="report-container">
<!-- HEADER -->
<div class="report-header">
<div class="header-logo">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 500 500"
width="40"
height="40"
>
<rect fill="#dc2626" x="0" y="0" width="500" height="500" rx="60" />
<g transform="translate(250,250) scale(3.2)">
<rect
x="-40"
y="-50"
width="80"
height="10"
rx="3"
fill="white"
/>
<rect
x="-30"
y="-35"
width="60"
height="60"
rx="5"
fill="none"
stroke="white"
stroke-width="5"
/>
<circle cx="0" cy="-55" r="8" fill="white" />
<line
x1="-10"
y1="-60"
x2="10"
y2="-50"
stroke="white"
stroke-width="3"
/>
<line
x1="10"
y1="-60"
x2="-10"
y2="-50"
stroke="white"
stroke-width="3"
/>
</g>
</svg>
</div>
<div class="header-title">LAPORAN PENJUALAN HARIAN</div>
<div class="header-org">{{.OrganizationName}}</div>
<div class="header-period">
Bulan: {{.MonthName}} / Tanggal Report: {{.ReportDate}}
</div>
</div>
<!-- SUMMARY BOXES -->
<div class="summary-boxes">
<div class="summary-box">
<span class="summary-box-label"
>TOTAL PENJUALAN {{.ReportDateUpper}}</span
>
<span class="summary-box-value">{{.TotalPenjualan}}</span>
</div>
<div class="summary-box">
<span class="summary-box-label"
>TOTAL BIAYA {{.ReportDateUpper}}</span
>
<span class="summary-box-value">{{.TotalBiaya}}</span>
</div>
<div class="summary-box {{.LabaRugiClass}}">
<span class="summary-box-label">LABA/RUGI {{.ReportDateUpper}}</span>
<span class="summary-box-value {{.LabaRugiValueClass}}"
>{{.LabaRugi}}</span
>
</div>
<div class="summary-box {{.LabaRugiMtdClass}}">
<span class="summary-box-label">LABA/RUGI MTD</span>
<span class="summary-box-value {{.LabaRugiMtdValueClass}}"
>{{.LabaRugiMtd}}</span
>
</div>
</div>
<!-- 1. RINGKASAN LAPORAN -->
<div class="section-title">1. Ringkasan Laporan</div>
<table class="summary-table">
<thead>
<tr>
<th>NO</th>
<th>KETERANGAN</th>
<th>TANGGAL REPORT<br />Nominal</th>
<th>%</th>
<th>MTD<br />Nominal</th>
<th>%</th>
</tr>
</thead>
<tbody>
{{range $i, $row := .MainSummary}}
<tr class="{{$row.RowClass}}">
<td>{{if $row.Number}}{{$row.Number}}{{end}}</td>
<td>{{$row.Label}}</td>
<td class="nominal">{{$row.TodayNominal}}</td>
<td class="pct">{{$row.TodayPct}}</td>
<td class="nominal">{{$row.MtdNominal}}</td>
<td class="pct">{{$row.MtdPct}}</td>
</tr>
{{range $j, $sub := $row.SubItems}}
<tr class="sub-item">
<td></td>
<td>{{$sub.Label}}</td>
<td class="nominal">{{$sub.TodayNominal}}</td>
<td class="pct">{{$sub.TodayPct}}</td>
<td class="nominal">{{$sub.MtdNominal}}</td>
<td class="pct">{{$sub.MtdPct}}</td>
</tr>
{{end}} {{end}}
</tbody>
</table>
<!-- 2. RINCIAN BIAYA / CATATAN -->
<div class="section-title">2. Rincian Biaya / Catatan</div>
<table class="detail-table">
<thead>
<tr>
<th>NO</th>
<th>KETERANGAN</th>
<th>JUMLAH</th>
</tr>
</thead>
<tbody>
{{range $i, $item := .PurchasingItems}}
<tr>
<td>{{add $i 1}}</td>
<td>{{$item.Name}}</td>
<td>{{$item.Amount}}</td>
</tr>
{{end}}
<tr class="total-row">
<td></td>
<td>TOTAL</td>
<td>{{.PurchasingTotal}}</td>
</tr>
</tbody>
</table>
<!-- FOOTER -->
<div class="report-footer">
Dicetak oleh: {{.GeneratedBy}} | {{.PrintTime}} | Powered by APSKEL
</div>
</div>
</body>
</html>