Compare commits

..
Author SHA1 Message Date
ryan 5fa9fc5070 Merge branch 'feature/exclusive-summary-revise' into feature/exclusive-summary 2026-06-17 18:32:11 +07:00
ryan 07e8be0521 Add account balance 2026-06-17 17:53:01 +07:00
ryan 4b7b225f58 Add exclusive summary endpoint 2026-06-17 17:35:37 +07:00
41 changed files with 335 additions and 2273 deletions
-1
View File
@@ -1 +0,0 @@
{}
+1 -2
View File
@@ -351,8 +351,6 @@ 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/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 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM=
github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= 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.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
@@ -382,6 +380,7 @@ 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.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 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= 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.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
-2
View File
@@ -8,7 +8,6 @@ const (
RoleCashier UserRole = "cashier" RoleCashier UserRole = "cashier"
RoleWaiter UserRole = "waiter" RoleWaiter UserRole = "waiter"
RoleOwner UserRole = "owner" RoleOwner UserRole = "owner"
RolePurchasing UserRole = "purchasing"
) )
func GetAllUserRoles() []UserRole { func GetAllUserRoles() []UserRole {
@@ -18,7 +17,6 @@ func GetAllUserRoles() []UserRole {
RoleCashier, RoleCashier,
RoleWaiter, RoleWaiter,
RoleOwner, RoleOwner,
RolePurchasing,
} }
} }
+2 -53
View File
@@ -18,7 +18,6 @@ type PaymentMethodAnalyticsRequest struct {
type PaymentMethodAnalyticsResponse struct { type PaymentMethodAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"` DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"` DateTo time.Time `json:"date_to"`
GroupBy string `json:"group_by"` GroupBy string `json:"group_by"`
@@ -55,7 +54,6 @@ type SalesAnalyticsRequest struct {
type SalesAnalyticsResponse struct { type SalesAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"` DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"` DateTo time.Time `json:"date_to"`
GroupBy string `json:"group_by"` GroupBy string `json:"group_by"`
@@ -90,7 +88,7 @@ type PurchasingAnalyticsRequest struct {
OutletID *string `form:"outlet_id,omitempty"` OutletID *string `form:"outlet_id,omitempty"`
DateFrom string `form:"date_from" validate:"required"` DateFrom string `form:"date_from" validate:"required"`
DateTo string `form:"date_to" validate:"required"` DateTo string `form:"date_to" validate:"required"`
GroupBy string `form:"group_by,default=day" validate:"omitempty,oneof=day hour week month outlet_id"` GroupBy string `form:"group_by,default=day" validate:"omitempty,oneof=day hour week month"`
} }
type PurchasingAnalyticsResponse struct { type PurchasingAnalyticsResponse struct {
@@ -102,7 +100,6 @@ type PurchasingAnalyticsResponse struct {
GroupBy string `json:"group_by"` GroupBy string `json:"group_by"`
Summary PurchasingSummary `json:"summary"` Summary PurchasingSummary `json:"summary"`
Data []PurchasingAnalyticsData `json:"data"` Data []PurchasingAnalyticsData `json:"data"`
OutletData []PurchasingOutletData `json:"outlet_data,omitempty"`
IngredientData []PurchasingIngredientData `json:"ingredient_data"` IngredientData []PurchasingIngredientData `json:"ingredient_data"`
VendorData []PurchasingVendorData `json:"vendor_data"` VendorData []PurchasingVendorData `json:"vendor_data"`
} }
@@ -133,20 +130,6 @@ type PurchasingAnalyticsData struct {
Vendors int64 `json:"vendors"` 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 { type PurchasingIngredientData struct {
IngredientID uuid.UUID `json:"ingredient_id"` IngredientID uuid.UUID `json:"ingredient_id"`
IngredientName string `json:"ingredient_name"` IngredientName string `json:"ingredient_name"`
@@ -157,7 +140,7 @@ type PurchasingIngredientData struct {
} }
type PurchasingVendorData struct { type PurchasingVendorData struct {
VendorID *uuid.UUID `json:"vendor_id"` VendorID uuid.UUID `json:"vendor_id"`
VendorName string `json:"vendor_name"` VendorName string `json:"vendor_name"`
TotalCost float64 `json:"total_cost"` TotalCost float64 `json:"total_cost"`
PurchaseOrderCount int64 `json:"purchase_order_count"` PurchaseOrderCount int64 `json:"purchase_order_count"`
@@ -178,7 +161,6 @@ type ProductAnalyticsRequest struct {
type ProductAnalyticsResponse struct { type ProductAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"` DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"` DateTo time.Time `json:"date_to"`
Data []ProductAnalyticsData `json:"data"` Data []ProductAnalyticsData `json:"data"`
@@ -216,7 +198,6 @@ type ProductAnalyticsPerCategoryRequest struct {
type ProductAnalyticsPerCategoryResponse struct { type ProductAnalyticsPerCategoryResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"` DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"` DateTo time.Time `json:"date_to"`
Data []ProductAnalyticsPerCategoryData `json:"data"` Data []ProductAnalyticsPerCategoryData `json:"data"`
@@ -246,7 +227,6 @@ type DashboardAnalyticsRequest struct {
type DashboardAnalyticsResponse struct { type DashboardAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"` DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"` DateTo time.Time `json:"date_to"`
Overview DashboardOverview `json:"overview"` Overview DashboardOverview `json:"overview"`
@@ -263,9 +243,6 @@ type DashboardOverview struct {
TotalCustomers int64 `json:"total_customers"` TotalCustomers int64 `json:"total_customers"`
VoidedOrders int64 `json:"voided_orders"` VoidedOrders int64 `json:"voided_orders"`
RefundedOrders int64 `json:"refunded_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 { type ProfitLossAnalyticsRequest struct {
@@ -279,7 +256,6 @@ type ProfitLossAnalyticsRequest struct {
type ProfitLossAnalyticsResponse struct { type ProfitLossAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"` DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"` DateTo time.Time `json:"date_to"`
GroupBy string `json:"group_by"` GroupBy string `json:"group_by"`
@@ -287,28 +263,10 @@ type ProfitLossAnalyticsResponse struct {
Data []ProfitLossData `json:"data"` Data []ProfitLossData `json:"data"`
ProductData []ProductProfitData `json:"product_data"` ProductData []ProductProfitData `json:"product_data"`
MainSummary []ProfitLossSummaryRow `json:"main_summary"` MainSummary []ProfitLossSummaryRow `json:"main_summary"`
Purchasing ProfitLossPurchasing `json:"purchasing"`
OperationalExpenses []OperationalExpenseItem `json:"operational_expenses"` OperationalExpenses []OperationalExpenseItem `json:"operational_expenses"`
OperationalExpensesTotal float64 `json:"operational_expenses_total"` 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 { type ProfitLossSummary struct {
TotalRevenue float64 `json:"total_revenue"` TotalRevenue float64 `json:"total_revenue"`
TotalCost float64 `json:"total_cost"` TotalCost float64 `json:"total_cost"`
@@ -381,17 +339,9 @@ type ExclusiveSummaryMonthlyRequest struct {
Month string `form:"month" validate:"required"` 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 { type ExclusiveSummaryPeriodResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
Period ExclusiveSummaryPeriodRange `json:"period"` Period ExclusiveSummaryPeriodRange `json:"period"`
Summary ExclusiveSummaryPeriodSummary `json:"summary"` Summary ExclusiveSummaryPeriodSummary `json:"summary"`
Reimburse ExclusiveSummaryReimburse `json:"reimburse"` Reimburse ExclusiveSummaryReimburse `json:"reimburse"`
@@ -451,7 +401,6 @@ type ExclusiveSummaryDailyTransaction struct {
type ExclusiveSummaryMonthlyResponse struct { type ExclusiveSummaryMonthlyResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
Month string `json:"month"` Month string `json:"month"`
Summary ExclusiveSummaryMonthlySummary `json:"summary"` Summary ExclusiveSummaryMonthlySummary `json:"summary"`
Periods []ExclusiveSummaryMonthlyPeriod `json:"periods"` Periods []ExclusiveSummaryMonthlyPeriod `json:"periods"`
+2 -3
View File
@@ -7,7 +7,7 @@ import (
) )
type CreatePurchaseOrderRequest struct { type CreatePurchaseOrderRequest struct {
VendorID *uuid.UUID `json:"vendor_id,omitempty" validate:"omitempty"` VendorID uuid.UUID `json:"vendor_id" validate:"required"`
PONumber string `json:"po_number" validate:"required,min=1,max=50"` PONumber string `json:"po_number" validate:"required,min=1,max=50"`
TransactionDate string `json:"transaction_date" validate:"required"` // Format: YYYY-MM-DD TransactionDate string `json:"transaction_date" validate:"required"` // Format: YYYY-MM-DD
DueDate *string `json:"due_date,omitempty" validate:"omitempty"` // Format: YYYY-MM-DD DueDate *string `json:"due_date,omitempty" validate:"omitempty"` // Format: YYYY-MM-DD
@@ -52,8 +52,7 @@ type UpdatePurchaseOrderItemRequest struct {
type PurchaseOrderResponse struct { type PurchaseOrderResponse struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id"` VendorID uuid.UUID `json:"vendor_id"`
VendorID *uuid.UUID `json:"vendor_id"`
PONumber string `json:"po_number"` PONumber string `json:"po_number"`
TransactionDate time.Time `json:"transaction_date"` TransactionDate time.Time `json:"transaction_date"`
DueDate *time.Time `json:"due_date"` DueDate *time.Time `json:"due_date"`
+2 -2
View File
@@ -12,14 +12,14 @@ type CreateUserRequest struct {
Name string `json:"name" validate:"required,min=1,max=255"` Name string `json:"name" validate:"required,min=1,max=255"`
Email string `json:"email" validate:"required,email"` Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required,min=6"` Password string `json:"password" validate:"required,min=6"`
Role string `json:"role" validate:"required,oneof=admin manager cashier waiter owner purchasing"` Role string `json:"role" validate:"required,oneof=admin manager cashier waiter"`
Permissions map[string]interface{} `json:"permissions,omitempty"` Permissions map[string]interface{} `json:"permissions,omitempty"`
} }
type UpdateUserRequest struct { type UpdateUserRequest struct {
Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"` Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"`
Email *string `json:"email,omitempty" validate:"omitempty,email"` Email *string `json:"email,omitempty" validate:"omitempty,email"`
Role *string `json:"role,omitempty" validate:"omitempty,oneof=admin manager cashier waiter owner purchasing"` Role *string `json:"role,omitempty" validate:"omitempty,oneof=admin manager cashier waiter"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
IsActive *bool `json:"is_active,omitempty"` IsActive *bool `json:"is_active,omitempty"`
Permissions *map[string]interface{} `json:"permissions,omitempty"` Permissions *map[string]interface{} `json:"permissions,omitempty"`
+5 -38
View File
@@ -32,7 +32,6 @@ type PurchasingAnalytics struct {
OutletName *string `json:"outlet_name,omitempty"` OutletName *string `json:"outlet_name,omitempty"`
Summary PurchasingSummary `json:"summary"` Summary PurchasingSummary `json:"summary"`
Data []PurchasingAnalyticsData `json:"data"` Data []PurchasingAnalyticsData `json:"data"`
OutletData []PurchasingOutletData `json:"outlet_data,omitempty"`
IngredientData []PurchasingIngredientData `json:"ingredient_data"` IngredientData []PurchasingIngredientData `json:"ingredient_data"`
VendorData []PurchasingVendorData `json:"vendor_data"` VendorData []PurchasingVendorData `json:"vendor_data"`
} }
@@ -63,20 +62,6 @@ type PurchasingAnalyticsData struct {
Vendors int64 `json:"vendors"` 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 { type PurchasingIngredientData struct {
IngredientID uuid.UUID `json:"ingredient_id"` IngredientID uuid.UUID `json:"ingredient_id"`
IngredientName string `json:"ingredient_name"` IngredientName string `json:"ingredient_name"`
@@ -87,7 +72,7 @@ type PurchasingIngredientData struct {
} }
type PurchasingVendorData struct { type PurchasingVendorData struct {
VendorID *uuid.UUID `json:"vendor_id"` VendorID uuid.UUID `json:"vendor_id"`
VendorName string `json:"vendor_name"` VendorName string `json:"vendor_name"`
TotalCost float64 `json:"total_cost"` TotalCost float64 `json:"total_cost"`
PurchaseOrderCount int64 `json:"purchase_order_count"` PurchaseOrderCount int64 `json:"purchase_order_count"`
@@ -135,9 +120,6 @@ type DashboardOverview struct {
TotalCustomers int64 `json:"total_customers"` TotalCustomers int64 `json:"total_customers"`
VoidedOrders int64 `json:"voided_orders"` VoidedOrders int64 `json:"voided_orders"`
RefundedOrders int64 `json:"refunded_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 { type ProfitLossAnalytics struct {
@@ -148,25 +130,11 @@ type ProfitLossAnalytics struct {
TodayCost float64 TodayCost float64
MtdRevenue float64 MtdRevenue float64
MtdCost float64 MtdCost float64
TodayPurchasing float64
MtdPurchasing float64
TodayPurchasingRawMaterial float64
MtdPurchasingRawMaterial float64
TodayPurchasingExpense float64
MtdPurchasingExpense float64
PurchasingItems []PurchasingItemDetail
TodayExpenseByCategory []ExpenseCategoryTotal TodayExpenseByCategory []ExpenseCategoryTotal
MtdExpenseByCategory []ExpenseCategoryTotal MtdExpenseByCategory []ExpenseCategoryTotal
OperationalExpenseItems []OperationalExpenseItem OperationalExpenseItems []OperationalExpenseItem
} }
type PurchasingItemDetail struct {
Date time.Time
Item string
Quantity float64
Amount float64
}
type ProfitLossSummary struct { type ProfitLossSummary struct {
TotalRevenue float64 TotalRevenue float64
TotalCost float64 TotalCost float64
@@ -251,9 +219,8 @@ type ExclusiveSummaryDailyTransaction struct {
type ExclusiveSummaryBankBalance struct { type ExclusiveSummaryBankBalance struct {
Bank string Bank string
OpeningBalance *float64 AccountType string
IncomingMutation *float64 OpeningBalance float64
OutgoingMutation *float64 ClosingBalance float64
ClosingBalance *float64 Description *string
Notes *string
} }
+1 -3
View File
@@ -11,8 +11,7 @@ import (
type PurchaseOrder struct { type PurchaseOrder struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` 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"` OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id" validate:"required"`
OutletID *uuid.UUID `gorm:"type:uuid;index" json:"outlet_id" validate:"omitempty"` VendorID uuid.UUID `gorm:"type:uuid;not null" json:"vendor_id" validate:"required"`
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"` 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"` TransactionDate time.Time `gorm:"type:date;not null" json:"transaction_date" validate:"required"`
DueDate *time.Time `gorm:"type:date" json:"due_date" validate:"omitempty"` DueDate *time.Time `gorm:"type:date" json:"due_date" validate:"omitempty"`
@@ -24,7 +23,6 @@ type PurchaseOrder struct {
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
Organization *Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"` Organization *Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"`
Outlet *Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"`
Vendor *Vendor `gorm:"foreignKey:VendorID" json:"vendor,omitempty"` Vendor *Vendor `gorm:"foreignKey:VendorID" json:"vendor,omitempty"`
Items []PurchaseOrderItem `gorm:"foreignKey:PurchaseOrderID" json:"items,omitempty"` Items []PurchaseOrderItem `gorm:"foreignKey:PurchaseOrderID" json:"items,omitempty"`
Attachments []PurchaseOrderAttachment `gorm:"foreignKey:PurchaseOrderID" json:"attachments,omitempty"` Attachments []PurchaseOrderAttachment `gorm:"foreignKey:PurchaseOrderID" json:"attachments,omitempty"`
+1 -3
View File
@@ -17,8 +17,6 @@ const (
RoleManager UserRole = "manager" RoleManager UserRole = "manager"
RoleCashier UserRole = "cashier" RoleCashier UserRole = "cashier"
RoleWaiter UserRole = "waiter" RoleWaiter UserRole = "waiter"
RoleOwner UserRole = "owner"
RolePurchasing UserRole = "purchasing"
) )
type Permissions map[string]interface{} type Permissions map[string]interface{}
@@ -48,7 +46,7 @@ type User struct {
Name string `gorm:"not null;size:255" json:"name" validate:"required,min=1,max=255"` 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"` Email string `gorm:"uniqueIndex;not null;size:255" json:"email" validate:"required,email"`
PasswordHash string `gorm:"not null;size:255" json:"-"` PasswordHash string `gorm:"not null;size:255" json:"-"`
Role UserRole `gorm:"not null;size:50" json:"role" validate:"required,oneof=admin manager cashier waiter owner purchasing"` Role UserRole `gorm:"not null;size:50" json:"role" validate:"required,oneof=admin manager cashier waiter"`
Permissions Permissions `gorm:"type:jsonb;default:'{}'" json:"permissions"` Permissions Permissions `gorm:"type:jsonb;default:'{}'" json:"permissions"`
IsActive bool `gorm:"default:true" json:"is_active"` IsActive bool `gorm:"default:true" json:"is_active"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
-28
View File
@@ -266,31 +266,3 @@ func (h *AnalyticsHandler) GetExclusiveSummaryMonthly(c *gin.Context) {
contractResp := transformer.ExclusiveSummaryMonthlyModelToContract(response) contractResp := transformer.ExclusiveSummaryMonthlyModelToContract(response)
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetExclusiveSummaryMonthly") 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,35 +66,3 @@ func (h *ReportHandler) GetDailyTransactionReportPDF(c *gin.Context) {
"file_name": fileName, "file_name": fileName,
}), "ReportHandler::GetDailyTransactionReportPDF") }), "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")
}
@@ -13,7 +13,6 @@ func PurchaseOrderEntityToModel(entity *entities.PurchaseOrder) *models.Purchase
return &models.PurchaseOrder{ return &models.PurchaseOrder{
ID: entity.ID, ID: entity.ID,
OrganizationID: entity.OrganizationID, OrganizationID: entity.OrganizationID,
OutletID: entity.OutletID,
VendorID: entity.VendorID, VendorID: entity.VendorID,
PONumber: entity.PONumber, PONumber: entity.PONumber,
TransactionDate: entity.TransactionDate, TransactionDate: entity.TransactionDate,
@@ -35,7 +34,6 @@ func PurchaseOrderModelToEntity(model *models.PurchaseOrder) *entities.PurchaseO
return &entities.PurchaseOrder{ return &entities.PurchaseOrder{
ID: model.ID, ID: model.ID,
OrganizationID: model.OrganizationID, OrganizationID: model.OrganizationID,
OutletID: model.OutletID,
VendorID: model.VendorID, VendorID: model.VendorID,
PONumber: model.PONumber, PONumber: model.PONumber,
TransactionDate: model.TransactionDate, TransactionDate: model.TransactionDate,
@@ -57,7 +55,6 @@ func PurchaseOrderEntityToResponse(entity *entities.PurchaseOrder) *models.Purch
response := &models.PurchaseOrderResponse{ response := &models.PurchaseOrderResponse{
ID: entity.ID, ID: entity.ID,
OrganizationID: entity.OrganizationID, OrganizationID: entity.OrganizationID,
OutletID: entity.OutletID,
VendorID: entity.VendorID, VendorID: entity.VendorID,
PONumber: entity.PONumber, PONumber: entity.PONumber,
TransactionDate: entity.TransactionDate, TransactionDate: entity.TransactionDate,
+1 -5
View File
@@ -82,11 +82,7 @@ func (m *AuthMiddleware) RequireRole(allowedRoles ...string) gin.HandlerFunc {
} }
func (m *AuthMiddleware) RequireAdminOrManager() gin.HandlerFunc { func (m *AuthMiddleware) RequireAdminOrManager() gin.HandlerFunc {
return m.RequireRole("superadmin", "admin", "manager", "owner", "purchasing") return m.RequireRole("superadmin", "admin", "manager")
}
func (m *AuthMiddleware) RequireAdminOrManagerOrPurchasing() gin.HandlerFunc {
return m.RequireRole("superadmin", "admin", "manager", "owner", "purchasing")
} }
func (m *AuthMiddleware) RequireAdmin() gin.HandlerFunc { func (m *AuthMiddleware) RequireAdmin() gin.HandlerFunc {
+2 -53
View File
@@ -19,7 +19,6 @@ type PaymentMethodAnalyticsRequest struct {
type PaymentMethodAnalyticsResponse struct { type PaymentMethodAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"` DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"` DateTo time.Time `json:"date_to"`
GroupBy string `json:"group_by"` GroupBy string `json:"group_by"`
@@ -59,7 +58,6 @@ type SalesAnalyticsRequest struct {
type SalesAnalyticsResponse struct { type SalesAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"` DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"` DateTo time.Time `json:"date_to"`
GroupBy string `json:"group_by"` GroupBy string `json:"group_by"`
@@ -95,7 +93,7 @@ type PurchasingAnalyticsRequest struct {
OutletID *uuid.UUID `validate:"omitempty"` OutletID *uuid.UUID `validate:"omitempty"`
DateFrom time.Time `validate:"required"` DateFrom time.Time `validate:"required"`
DateTo time.Time `validate:"required"` DateTo time.Time `validate:"required"`
GroupBy string `validate:"omitempty,oneof=day hour week month outlet_id"` GroupBy string `validate:"omitempty,oneof=day hour week month"`
} }
// PurchasingAnalyticsResponse represents the response for purchasing analytics // PurchasingAnalyticsResponse represents the response for purchasing analytics
@@ -108,7 +106,6 @@ type PurchasingAnalyticsResponse struct {
GroupBy string `json:"group_by"` GroupBy string `json:"group_by"`
Summary PurchasingSummary `json:"summary"` Summary PurchasingSummary `json:"summary"`
Data []PurchasingAnalyticsData `json:"data"` Data []PurchasingAnalyticsData `json:"data"`
OutletData []PurchasingOutletData `json:"outlet_data,omitempty"`
IngredientData []PurchasingIngredientData `json:"ingredient_data"` IngredientData []PurchasingIngredientData `json:"ingredient_data"`
VendorData []PurchasingVendorData `json:"vendor_data"` VendorData []PurchasingVendorData `json:"vendor_data"`
} }
@@ -141,20 +138,6 @@ type PurchasingAnalyticsData struct {
Vendors int64 `json:"vendors"` 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 // PurchasingIngredientData represents purchasing analytics for an ingredient
type PurchasingIngredientData struct { type PurchasingIngredientData struct {
IngredientID uuid.UUID `json:"ingredient_id"` IngredientID uuid.UUID `json:"ingredient_id"`
@@ -167,7 +150,7 @@ type PurchasingIngredientData struct {
// PurchasingVendorData represents purchasing analytics for a vendor // PurchasingVendorData represents purchasing analytics for a vendor
type PurchasingVendorData struct { type PurchasingVendorData struct {
VendorID *uuid.UUID `json:"vendor_id"` VendorID uuid.UUID `json:"vendor_id"`
VendorName string `json:"vendor_name"` VendorName string `json:"vendor_name"`
TotalCost float64 `json:"total_cost"` TotalCost float64 `json:"total_cost"`
PurchaseOrderCount int64 `json:"purchase_order_count"` PurchaseOrderCount int64 `json:"purchase_order_count"`
@@ -188,7 +171,6 @@ type ProductAnalyticsRequest struct {
type ProductAnalyticsResponse struct { type ProductAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"` DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"` DateTo time.Time `json:"date_to"`
Data []ProductAnalyticsData `json:"data"` Data []ProductAnalyticsData `json:"data"`
@@ -226,7 +208,6 @@ type ProductAnalyticsPerCategoryRequest struct {
type ProductAnalyticsPerCategoryResponse struct { type ProductAnalyticsPerCategoryResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"` DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"` DateTo time.Time `json:"date_to"`
Data []ProductAnalyticsPerCategoryData `json:"data"` Data []ProductAnalyticsPerCategoryData `json:"data"`
@@ -256,7 +237,6 @@ type DashboardAnalyticsRequest struct {
type DashboardAnalyticsResponse struct { type DashboardAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"` DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"` DateTo time.Time `json:"date_to"`
Overview DashboardOverview `json:"overview"` Overview DashboardOverview `json:"overview"`
@@ -273,9 +253,6 @@ type DashboardOverview struct {
TotalCustomers int64 `json:"total_customers"` TotalCustomers int64 `json:"total_customers"`
VoidedOrders int64 `json:"voided_orders"` VoidedOrders int64 `json:"voided_orders"`
RefundedOrders int64 `json:"refunded_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 { type ProfitLossAnalyticsRequest struct {
@@ -289,7 +266,6 @@ type ProfitLossAnalyticsRequest struct {
type ProfitLossAnalyticsResponse struct { type ProfitLossAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"` DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"` DateTo time.Time `json:"date_to"`
GroupBy string `json:"group_by"` GroupBy string `json:"group_by"`
@@ -297,28 +273,10 @@ type ProfitLossAnalyticsResponse struct {
Data []ProfitLossData `json:"data"` Data []ProfitLossData `json:"data"`
ProductData []ProductProfitData `json:"product_data"` ProductData []ProductProfitData `json:"product_data"`
MainSummary []ProfitLossSummaryRow `json:"main_summary"` MainSummary []ProfitLossSummaryRow `json:"main_summary"`
Purchasing ProfitLossPurchasing `json:"purchasing"`
OperationalExpenses []OperationalExpenseItem `json:"operational_expenses"` OperationalExpenses []OperationalExpenseItem `json:"operational_expenses"`
OperationalExpensesTotal float64 `json:"operational_expenses_total"` 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 { type ProfitLossSummary struct {
TotalRevenue float64 `json:"total_revenue"` TotalRevenue float64 `json:"total_revenue"`
TotalCost float64 `json:"total_cost"` TotalCost float64 `json:"total_cost"`
@@ -391,17 +349,9 @@ type ExclusiveSummaryMonthlyRequest struct {
Month time.Time `validate:"required"` 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 { type ExclusiveSummaryPeriodResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
Period ExclusiveSummaryPeriodRange `json:"period"` Period ExclusiveSummaryPeriodRange `json:"period"`
Summary ExclusiveSummaryPeriodSummary `json:"summary"` Summary ExclusiveSummaryPeriodSummary `json:"summary"`
Reimburse ExclusiveSummaryReimburse `json:"reimburse"` Reimburse ExclusiveSummaryReimburse `json:"reimburse"`
@@ -461,7 +411,6 @@ type ExclusiveSummaryDailyTransaction struct {
type ExclusiveSummaryMonthlyResponse struct { type ExclusiveSummaryMonthlyResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
Month string `json:"month"` Month string `json:"month"`
Summary ExclusiveSummaryMonthlySummary `json:"summary"` Summary ExclusiveSummaryMonthlySummary `json:"summary"`
Periods []ExclusiveSummaryMonthlyPeriod `json:"periods"` Periods []ExclusiveSummaryMonthlyPeriod `json:"periods"`
+3 -6
View File
@@ -9,8 +9,7 @@ import (
type PurchaseOrder struct { type PurchaseOrder struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id"` VendorID uuid.UUID `json:"vendor_id"`
VendorID *uuid.UUID `json:"vendor_id"`
PONumber string `json:"po_number"` PONumber string `json:"po_number"`
TransactionDate time.Time `json:"transaction_date"` TransactionDate time.Time `json:"transaction_date"`
DueDate *time.Time `json:"due_date"` DueDate *time.Time `json:"due_date"`
@@ -45,8 +44,7 @@ type PurchaseOrderAttachment struct {
type PurchaseOrderResponse struct { type PurchaseOrderResponse struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id"` VendorID uuid.UUID `json:"vendor_id"`
VendorID *uuid.UUID `json:"vendor_id"`
PONumber string `json:"po_number"` PONumber string `json:"po_number"`
TransactionDate time.Time `json:"transaction_date"` TransactionDate time.Time `json:"transaction_date"`
DueDate *time.Time `json:"due_date"` DueDate *time.Time `json:"due_date"`
@@ -86,8 +84,7 @@ type PurchaseOrderAttachmentResponse struct {
} }
type CreatePurchaseOrderRequest struct { type CreatePurchaseOrderRequest struct {
VendorID *uuid.UUID `json:"vendor_id,omitempty"` VendorID uuid.UUID `json:"vendor_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
PONumber string `json:"po_number"` PONumber string `json:"po_number"`
TransactionDate time.Time `json:"transaction_date"` TransactionDate time.Time `json:"transaction_date"`
DueDate *time.Time `json:"due_date,omitempty"` DueDate *time.Time `json:"due_date,omitempty"`
+2 -4
View File
@@ -65,10 +65,8 @@ func (u *User) HasPermission(requiredRole constants.UserRole) bool {
roleHierarchy := map[constants.UserRole]int{ roleHierarchy := map[constants.UserRole]int{
constants.RoleWaiter: 1, constants.RoleWaiter: 1,
constants.RoleCashier: 2, constants.RoleCashier: 2,
constants.RolePurchasing: 3, constants.RoleManager: 3,
constants.RoleManager: 4, constants.RoleAdmin: 4,
constants.RoleAdmin: 5,
constants.RoleOwner: 6,
} }
userLevel := roleHierarchy[u.Role] userLevel := roleHierarchy[u.Role]
+33 -89
View File
@@ -23,7 +23,6 @@ type AnalyticsProcessor interface {
GetProfitLossAnalytics(ctx context.Context, req *models.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsResponse, error) GetProfitLossAnalytics(ctx context.Context, req *models.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsResponse, error)
GetExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error) GetExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error)
GetExclusiveSummaryMonthly(ctx context.Context, req *models.ExclusiveSummaryMonthlyRequest) (*models.ExclusiveSummaryMonthlyResponse, error) GetExclusiveSummaryMonthly(ctx context.Context, req *models.ExclusiveSummaryMonthlyRequest) (*models.ExclusiveSummaryMonthlyResponse, error)
GetExclusiveSummaryMTD(ctx context.Context, req *models.ExclusiveSummaryMTDRequest) (*models.ExclusiveSummaryPeriodResponse, error)
} }
type AnalyticsProcessorImpl struct { type AnalyticsProcessorImpl struct {
@@ -38,18 +37,6 @@ 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) { func (p *AnalyticsProcessorImpl) GetPaymentMethodAnalytics(ctx context.Context, req *models.PaymentMethodAnalyticsRequest) (*models.PaymentMethodAnalyticsResponse, error) {
if req.DateFrom.After(req.DateTo) { if req.DateFrom.After(req.DateTo) {
return nil, fmt.Errorf("date_from cannot be after date_to") return nil, fmt.Errorf("date_from cannot be after date_to")
@@ -104,7 +91,6 @@ func (p *AnalyticsProcessorImpl) GetPaymentMethodAnalytics(ctx context.Context,
return &models.PaymentMethodAnalyticsResponse{ return &models.PaymentMethodAnalyticsResponse{
OrganizationID: req.OrganizationID, OrganizationID: req.OrganizationID,
OutletID: req.OutletID, OutletID: req.OutletID,
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
DateFrom: req.DateFrom, DateFrom: req.DateFrom,
DateTo: req.DateTo, DateTo: req.DateTo,
GroupBy: req.GroupBy, GroupBy: req.GroupBy,
@@ -179,7 +165,6 @@ func (p *AnalyticsProcessorImpl) GetSalesAnalytics(ctx context.Context, req *mod
return &models.SalesAnalyticsResponse{ return &models.SalesAnalyticsResponse{
OrganizationID: req.OrganizationID, OrganizationID: req.OrganizationID,
OutletID: req.OutletID, OutletID: req.OutletID,
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
DateFrom: req.DateFrom, DateFrom: req.DateFrom,
DateTo: req.DateTo, DateTo: req.DateTo,
GroupBy: req.GroupBy, GroupBy: req.GroupBy,
@@ -218,23 +203,6 @@ func (p *AnalyticsProcessorImpl) GetPurchasingAnalytics(ctx context.Context, req
} }
} }
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,
}
}
ingredientData := make([]models.PurchasingIngredientData, len(result.IngredientData)) ingredientData := make([]models.PurchasingIngredientData, len(result.IngredientData))
for i, item := range result.IngredientData { for i, item := range result.IngredientData {
ingredientData[i] = models.PurchasingIngredientData{ ingredientData[i] = models.PurchasingIngredientData{
@@ -279,7 +247,6 @@ func (p *AnalyticsProcessorImpl) GetPurchasingAnalytics(ctx context.Context, req
TotalVendors: result.Summary.TotalVendors, TotalVendors: result.Summary.TotalVendors,
}, },
Data: data, Data: data,
OutletData: outletData,
IngredientData: ingredientData, IngredientData: ingredientData,
VendorData: vendorData, VendorData: vendorData,
}, nil }, nil
@@ -329,7 +296,6 @@ func (p *AnalyticsProcessorImpl) GetProductAnalytics(ctx context.Context, req *m
return &models.ProductAnalyticsResponse{ return &models.ProductAnalyticsResponse{
OrganizationID: req.OrganizationID, OrganizationID: req.OrganizationID,
OutletID: req.OutletID, OutletID: req.OutletID,
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
DateFrom: req.DateFrom, DateFrom: req.DateFrom,
DateTo: req.DateTo, DateTo: req.DateTo,
Data: resultData, Data: resultData,
@@ -367,7 +333,6 @@ func (p *AnalyticsProcessorImpl) GetProductAnalyticsPerCategory(ctx context.Cont
return &models.ProductAnalyticsPerCategoryResponse{ return &models.ProductAnalyticsPerCategoryResponse{
OrganizationID: req.OrganizationID, OrganizationID: req.OrganizationID,
OutletID: req.OutletID, OutletID: req.OutletID,
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
DateFrom: req.DateFrom, DateFrom: req.DateFrom,
DateTo: req.DateTo, DateTo: req.DateTo,
Data: resultData, Data: resultData,
@@ -429,7 +394,6 @@ func (p *AnalyticsProcessorImpl) GetDashboardAnalytics(ctx context.Context, req
return &models.DashboardAnalyticsResponse{ return &models.DashboardAnalyticsResponse{
OrganizationID: req.OrganizationID, OrganizationID: req.OrganizationID,
OutletID: req.OutletID, OutletID: req.OutletID,
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
DateFrom: req.DateFrom, DateFrom: req.DateFrom,
DateTo: req.DateTo, DateTo: req.DateTo,
Overview: models.DashboardOverview{ Overview: models.DashboardOverview{
@@ -439,9 +403,6 @@ func (p *AnalyticsProcessorImpl) GetDashboardAnalytics(ctx context.Context, req
TotalCustomers: overview.TotalCustomers, TotalCustomers: overview.TotalCustomers,
VoidedOrders: overview.VoidedOrders, VoidedOrders: overview.VoidedOrders,
RefundedOrders: overview.RefundedOrders, RefundedOrders: overview.RefundedOrders,
TotalItemSold: overview.TotalItemSold,
TotalLowStock: overview.TotalLowStock,
TotalProductActive: overview.TotalProductActive,
}, },
TopProducts: topProducts.Data, TopProducts: topProducts.Data,
PaymentMethods: paymentMethods.Data, PaymentMethods: paymentMethods.Data,
@@ -644,20 +605,9 @@ func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req
opsTotal += item.Amount 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{ return &models.ProfitLossAnalyticsResponse{
OrganizationID: req.OrganizationID, OrganizationID: req.OrganizationID,
OutletID: req.OutletID, OutletID: req.OutletID,
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
DateFrom: req.DateFrom, DateFrom: req.DateFrom,
DateTo: req.DateTo, DateTo: req.DateTo,
GroupBy: req.GroupBy, GroupBy: req.GroupBy,
@@ -677,15 +627,6 @@ func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req
Data: data, Data: data,
ProductData: productData, ProductData: productData,
MainSummary: mainSummary, 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, OperationalExpenses: opsItems,
OperationalExpensesTotal: opsTotal, OperationalExpensesTotal: opsTotal,
}, nil }, nil
@@ -738,8 +679,14 @@ func (p *AnalyticsProcessorImpl) GetExclusiveSummaryMonthly(ctx context.Context,
return nil, err return nil, err
} }
periods := make([]models.ExclusiveSummaryMonthlyPeriod, 0) bankBalance, err := p.buildExclusiveSummaryBankBalances(ctx, req.OrganizationID, req.OutletID)
for _, bucket := range buildExclusiveSummaryMonthlyBuckets(monthStart) { if err != nil {
return nil, err
}
buckets := buildExclusiveSummaryMonthlyBuckets(monthStart)
periods := make([]models.ExclusiveSummaryMonthlyPeriod, 0, len(buckets))
for _, bucket := range buckets {
period, err := p.buildExclusiveSummaryPeriod(ctx, &models.ExclusiveSummaryPeriodRequest{ period, err := p.buildExclusiveSummaryPeriod(ctx, &models.ExclusiveSummaryPeriodRequest{
OrganizationID: req.OrganizationID, OrganizationID: req.OrganizationID,
OutletID: req.OutletID, OutletID: req.OutletID,
@@ -761,27 +708,9 @@ func (p *AnalyticsProcessorImpl) GetExclusiveSummaryMonthly(ctx context.Context,
}) })
} }
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{ return &models.ExclusiveSummaryMonthlyResponse{
OrganizationID: req.OrganizationID, OrganizationID: req.OrganizationID,
OutletID: req.OutletID, OutletID: req.OutletID,
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
Month: monthStart.Format("2006-01"), Month: monthStart.Format("2006-01"),
Summary: models.ExclusiveSummaryMonthlySummary{ Summary: models.ExclusiveSummaryMonthlySummary{
TotalSales: fullPeriod.Summary.Sales, TotalSales: fullPeriod.Summary.Sales,
@@ -797,16 +726,32 @@ func (p *AnalyticsProcessorImpl) GetExclusiveSummaryMonthly(ctx context.Context,
}, nil }, nil
} }
func (p *AnalyticsProcessorImpl) GetExclusiveSummaryMTD(ctx context.Context, req *models.ExclusiveSummaryMTDRequest) (*models.ExclusiveSummaryPeriodResponse, error) { func (p *AnalyticsProcessorImpl) buildExclusiveSummaryBankBalances(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) ([]models.ExclusiveSummaryBankBalance, error) {
mtdStart := time.Date(req.DateTo.Year(), req.DateTo.Month(), 1, 0, 0, 0, 0, req.DateTo.Location()) balances, err := p.analyticsRepo.GetExclusiveSummaryBankBalances(ctx, organizationID, outletID)
if err != nil {
return nil, fmt.Errorf("failed to get exclusive summary bank balances: %w", err)
}
return p.buildExclusiveSummaryPeriod(ctx, &models.ExclusiveSummaryPeriodRequest{ result := make([]models.ExclusiveSummaryBankBalance, len(balances))
OrganizationID: req.OrganizationID, for i, balance := range balances {
OutletID: req.OutletID, openingBalance := balance.OpeningBalance
DateFrom: mtdStart, closingBalance := balance.ClosingBalance
DateTo: req.DateTo, notes := strings.TrimSpace(balance.AccountType)
ExcludeGajiStaffFromReimburse: req.ExcludeGajiStaffFromReimburse, if balance.Description != nil && strings.TrimSpace(*balance.Description) != "" {
}) notes = strings.TrimSpace(*balance.Description)
}
result[i] = models.ExclusiveSummaryBankBalance{
Bank: balance.Bank,
OpeningBalance: &openingBalance,
ClosingBalance: &closingBalance,
}
if notes != "" {
result[i].Notes = &notes
}
}
return result, nil
} }
func (p *AnalyticsProcessorImpl) buildExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error) { func (p *AnalyticsProcessorImpl) buildExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error) {
@@ -856,7 +801,6 @@ func (p *AnalyticsProcessorImpl) buildExclusiveSummaryPeriod(ctx context.Context
return &models.ExclusiveSummaryPeriodResponse{ return &models.ExclusiveSummaryPeriodResponse{
OrganizationID: req.OrganizationID, OrganizationID: req.OrganizationID,
OutletID: req.OutletID, OutletID: req.OutletID,
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
Period: models.ExclusiveSummaryPeriodRange{ Period: models.ExclusiveSummaryPeriodRange{
DateFrom: req.DateFrom, DateFrom: req.DateFrom,
DateTo: req.DateTo, DateTo: req.DateTo,
+48 -173
View File
@@ -15,12 +15,9 @@ import (
type analyticsRepositoryStub struct { type analyticsRepositoryStub struct {
purchasingResult *entities.PurchasingAnalytics purchasingResult *entities.PurchasingAnalytics
profitLossResult *entities.ProfitLossAnalytics profitLossResult *entities.ProfitLossAnalytics
exclusiveSummaryResults []*entities.ExclusiveSummaryAnalytics exclusiveResult *entities.ExclusiveSummaryAnalytics
bankBalances []entities.ExclusiveSummaryBankBalance bankBalances []entities.ExclusiveSummaryBankBalance
profitLossGroup string 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) { func (analyticsRepositoryStub) GetPaymentMethodAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.PaymentMethodAnalytics, error) {
@@ -52,26 +49,14 @@ func (s analyticsRepositoryStub) GetProfitLossAnalytics(_ context.Context, _ uui
return s.profitLossResult, nil return s.profitLossResult, nil
} }
func (s *analyticsRepositoryStub) GetExclusiveSummaryAnalytics(_ context.Context, _ uuid.UUID, _ *uuid.UUID, dateFrom, dateTo time.Time) (*entities.ExclusiveSummaryAnalytics, error) { func (s analyticsRepositoryStub) GetExclusiveSummaryAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) (*entities.ExclusiveSummaryAnalytics, error) {
s.exclusiveSummaryFrom = append(s.exclusiveSummaryFrom, dateFrom) return s.exclusiveResult, nil
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) { func (s analyticsRepositoryStub) GetExclusiveSummaryBankBalances(context.Context, uuid.UUID, *uuid.UUID) ([]entities.ExclusiveSummaryBankBalance, error) {
return s.bankBalances, nil return s.bankBalances, nil
} }
func (analyticsRepositoryStub) GetOutletName(context.Context, uuid.UUID, uuid.UUID) (string, error) {
return "", nil
}
type expenseRepositoryStub struct{} type expenseRepositoryStub struct{}
func (expenseRepositoryStub) Create(context.Context, *entities.Expense) error { return nil } func (expenseRepositoryStub) Create(context.Context, *entities.Expense) error { return nil }
@@ -96,7 +81,7 @@ func TestAnalyticsProcessorGetPurchasingAnalyticsPassesOutletName(t *testing.T)
outletID := uuid.New() outletID := uuid.New()
outletName := "Main Outlet" outletName := "Main Outlet"
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{ processor := NewAnalyticsProcessorImpl(analyticsRepositoryStub{
purchasingResult: &entities.PurchasingAnalytics{ purchasingResult: &entities.PurchasingAnalytics{
OutletName: &outletName, OutletName: &outletName,
Summary: entities.PurchasingSummary{ Summary: entities.PurchasingSummary{
@@ -145,57 +130,11 @@ func TestAnalyticsProcessorGetPurchasingAnalyticsPassesOutletName(t *testing.T)
require.Equal(t, float64(175), result.Data[0].ExpensePurchases) 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) { func TestAnalyticsProcessorGetProfitLossAnalyticsMapsOverviewAndReportFields(t *testing.T) {
productID := uuid.New() productID := uuid.New()
categoryID := uuid.New() categoryID := uuid.New()
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{ processor := NewAnalyticsProcessorImpl(analyticsRepositoryStub{
profitLossResult: &entities.ProfitLossAnalytics{ profitLossResult: &entities.ProfitLossAnalytics{
Summary: entities.ProfitLossSummary{ Summary: entities.ProfitLossSummary{
TotalRevenue: 1000, TotalRevenue: 1000,
@@ -267,7 +206,7 @@ func TestAnalyticsProcessorGetProfitLossAnalyticsMapsOverviewAndReportFields(t *
func TestAnalyticsProcessorGetProfitLossAnalyticsDynamicExpenseCategories(t *testing.T) { func TestAnalyticsProcessorGetProfitLossAnalyticsDynamicExpenseCategories(t *testing.T) {
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{ processor := NewAnalyticsProcessorImpl(analyticsRepositoryStub{
profitLossResult: &entities.ProfitLossAnalytics{ profitLossResult: &entities.ProfitLossAnalytics{
Summary: entities.ProfitLossSummary{ Summary: entities.ProfitLossSummary{
TotalRevenue: 10000, TotalRevenue: 10000,
@@ -345,28 +284,22 @@ func TestAnalyticsProcessorGetProfitLossAnalyticsDynamicExpenseCategories(t *tes
require.True(t, result.MainSummary[6].IsBold) require.True(t, result.MainSummary[6].IsBold)
} }
func TestAnalyticsProcessorGetExclusiveSummaryPeriodCalculatesTotalsAndReimburse(t *testing.T) { func TestAnalyticsProcessorGetExclusiveSummaryPeriodCalculatesSummaryAndReimburse(t *testing.T) {
now := time.Date(2026, 5, 26, 0, 0, 0, 0, time.UTC) now := time.Date(2026, 5, 26, 0, 0, 0, 0, time.UTC)
processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{ processor := NewAnalyticsProcessorImpl(analyticsRepositoryStub{
exclusiveSummaryResults: []*entities.ExclusiveSummaryAnalytics{ exclusiveResult: &entities.ExclusiveSummaryAnalytics{
{ SalesTotal: 35619000,
SalesTotal: 1000,
HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{ HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{
{CategoryCode: "RAW", CategoryName: "Raw", Amount: 400}, {CategoryCode: "hpp_nusantara", CategoryName: "Nusantara", Amount: 19010552},
}, },
OperationalExpenseBreakdown: []entities.ExclusiveSummaryCategoryTotal{ OperationalExpenseBreakdown: []entities.ExclusiveSummaryCategoryTotal{
{CategoryCode: "GAJI", CategoryName: "Gaji", Amount: 250}, {CategoryCode: "biaya_gaji_staff", CategoryName: "Gaji Staff", Amount: 48203333},
{CategoryCode: "OPS", CategoryName: "Operasional", Amount: 100}, {CategoryCode: "biaya_gaji_dw", CategoryName: "Gaji DW", Amount: 3555000},
}, {CategoryCode: "biaya_lain", CategoryName: "Biaya Lain-lain", Amount: 1608605},
DailySummary: []entities.ExclusiveSummaryDailySummary{
{Date: now, TransactionCount: 3, TotalCost: 750},
}, },
DailyTransactions: []entities.ExclusiveSummaryDailyTransaction{ DailyTransactions: []entities.ExclusiveSummaryDailyTransaction{
{Date: now, CategoryCode: "RAW", CategoryName: "Raw", Description: "beras", Amount: 400, Source: "purchase_order"}, {Date: now, CategoryCode: "biaya_gaji_staff", CategoryName: "Gaji Staff", Description: "gaji kary", Amount: 48203333, Source: "purchase_order"},
{Date: now, CategoryCode: "GAJI", CategoryName: "Gaji", Description: "gaji karyawan", Amount: 200, Source: "purchase_order"}, {Date: now, CategoryCode: "biaya_gaji_dw", CategoryName: "Gaji DW", Description: "DW", Amount: 3555000, 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{}) }, expenseRepositoryStub{})
@@ -380,45 +313,38 @@ func TestAnalyticsProcessorGetExclusiveSummaryPeriodCalculatesTotalsAndReimburse
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, result) require.NotNil(t, result)
require.Equal(t, float64(1000), result.Summary.Sales) require.Equal(t, float64(35619000), result.Summary.Sales)
require.Equal(t, float64(400), result.Summary.HPP) require.Equal(t, float64(19010552), result.Summary.HPP)
require.Equal(t, float64(600), result.Summary.GrossProfit) require.Equal(t, float64(16608448), result.Summary.GrossProfit)
require.Equal(t, float64(350), result.Summary.OperationalExpensesTotal) require.Equal(t, float64(51758333), result.Summary.SalaryTotal)
require.Equal(t, float64(750), result.Summary.TotalCost) require.Equal(t, float64(3555000), result.Summary.SalaryDW)
require.Equal(t, float64(250), result.Summary.NetProfit) require.Equal(t, float64(48203333), result.Summary.SalaryStaff)
require.Equal(t, float64(250), result.Summary.SalaryTotal) require.Equal(t, float64(53366938), result.Summary.OperationalExpensesTotal)
require.Equal(t, float64(50), result.Summary.SalaryDW) require.Equal(t, float64(72377490), result.Summary.TotalCost)
require.Equal(t, float64(200), result.Summary.SalaryStaff) require.Equal(t, float64(-36758490), result.Summary.NetProfit)
require.Equal(t, float64(100), result.Summary.OtherOperationalExpenses) require.Equal(t, float64(48203333), result.Reimburse.ExcludedSalaryStaff)
require.Equal(t, float64(200), result.Reimburse.ExcludedSalaryStaff) require.Equal(t, float64(24174157), result.Reimburse.TotalReimburse)
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) { func TestAnalyticsProcessorGetExclusiveSummaryMonthlyBuildsCalendarBucketsAndBankTemplate(t *testing.T) {
location, err := time.LoadLocation("Asia/Jakarta") location, err := time.LoadLocation("Asia/Jakarta")
require.NoError(t, err) require.NoError(t, err)
month := time.Date(2026, 5, 1, 0, 0, 0, 0, location) month := time.Date(2026, 5, 1, 0, 0, 0, 0, location)
openingBalance := 5000000.0 processor := NewAnalyticsProcessorImpl(analyticsRepositoryStub{
closingBalance := 5000000.0 exclusiveResult: &entities.ExclusiveSummaryAnalytics{
notes := "Main cash account for daily transactions" SalesTotal: 1000,
stub := &analyticsRepositoryStub{ HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{
exclusiveSummaryResults: []*entities.ExclusiveSummaryAnalytics{ {CategoryCode: "hpp", CategoryName: "HPP", Amount: 400},
{SalesTotal: 1000, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 400}}, OperationalExpenseBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 100}}}, },
{SalesTotal: 100, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 40}}}, OperationalExpenseBreakdown: []entities.ExclusiveSummaryCategoryTotal{
{SalesTotal: 200, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 80}}}, {CategoryCode: "ops", CategoryName: "OPS", Amount: 100},
{SalesTotal: 300, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 120}}}, },
{SalesTotal: 400, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 160}}},
{SalesTotal: 500, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 200}}},
}, },
bankBalances: []entities.ExclusiveSummaryBankBalance{ bankBalances: []entities.ExclusiveSummaryBankBalance{
{Bank: "Cash and Bank", OpeningBalance: &openingBalance, ClosingBalance: &closingBalance, Notes: &notes}, {Bank: "Rekening Bank", AccountType: "wallet", OpeningBalance: 1000, ClosingBalance: 2500},
{Bank: "Kas Utama", AccountType: "cash", OpeningBalance: 3000, ClosingBalance: 3500},
}, },
} }, expenseRepositoryStub{})
processor := NewAnalyticsProcessorImpl(stub, expenseRepositoryStub{})
result, err := processor.GetExclusiveSummaryMonthly(context.Background(), &models.ExclusiveSummaryMonthlyRequest{ result, err := processor.GetExclusiveSummaryMonthly(context.Background(), &models.ExclusiveSummaryMonthlyRequest{
OrganizationID: uuid.New(), OrganizationID: uuid.New(),
@@ -429,70 +355,19 @@ func TestAnalyticsProcessorGetExclusiveSummaryMonthlyBuildsSummaryAndBuckets(t *
require.NotNil(t, result) require.NotNil(t, result)
require.Equal(t, "2026-05", result.Month) require.Equal(t, "2026-05", result.Month)
require.Equal(t, float64(1000), result.Summary.TotalSales) 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.Equal(t, float64(500), result.Summary.NetProfit)
require.InDelta(t, float64(50), result.Summary.NetProfitMargin, 0.0001)
require.Len(t, result.Periods, 5) require.Len(t, result.Periods, 5)
require.Equal(t, "1 - 3 Mei", result.Periods[0].Label) require.Equal(t, "1 - 3 Mei", result.Periods[0].Label)
require.Equal(t, "25 - 31 Mei", result.Periods[4].Label) require.Equal(t, "25 - 31 Mei", result.Periods[4].Label)
require.Len(t, result.BankBalance, 1) require.Len(t, result.BankBalance, 2)
require.Equal(t, "Cash and Bank", result.BankBalance[0].Bank) require.Equal(t, "Rekening Bank", result.BankBalance[0].Bank)
require.NotNil(t, result.BankBalance[0].OpeningBalance) require.NotNil(t, result.BankBalance[0].OpeningBalance)
require.Equal(t, openingBalance, *result.BankBalance[0].OpeningBalance) require.Equal(t, float64(1000), *result.BankBalance[0].OpeningBalance)
require.NotNil(t, result.BankBalance[0].ClosingBalance) require.NotNil(t, result.BankBalance[0].ClosingBalance)
require.Equal(t, closingBalance, *result.BankBalance[0].ClosingBalance) require.Equal(t, float64(2500), *result.BankBalance[0].ClosingBalance)
require.NotNil(t, result.BankBalance[0].Notes)
require.Equal(t, "wallet", *result.BankBalance[0].Notes)
require.Nil(t, result.BankBalance[0].IncomingMutation) require.Nil(t, result.BankBalance[0].IncomingMutation)
require.Nil(t, result.BankBalance[0].OutgoingMutation) require.Nil(t, result.BankBalance[0].OutgoingMutation)
require.NotNil(t, result.BankBalance[0].Notes) require.Equal(t, "Kas Utama", result.BankBalance[1].Bank)
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)
} }
+11 -30
View File
@@ -11,8 +11,8 @@ import (
) )
type PurchaseOrderProcessor interface { type PurchaseOrderProcessor interface {
CreatePurchaseOrder(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, req *models.CreatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error) CreatePurchaseOrder(ctx context.Context, organizationID 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) UpdatePurchaseOrder(ctx context.Context, id, organizationID uuid.UUID, req *models.UpdatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error)
DeletePurchaseOrder(ctx context.Context, id, organizationID uuid.UUID) error DeletePurchaseOrder(ctx context.Context, id, organizationID uuid.UUID) error
GetPurchaseOrderByID(ctx context.Context, id, organizationID uuid.UUID) (*models.PurchaseOrderResponse, 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) ListPurchaseOrders(ctx context.Context, organizationID uuid.UUID, filters map[string]interface{}, page, limit int) ([]*models.PurchaseOrderResponse, int, error)
@@ -54,14 +54,12 @@ func NewPurchaseOrderProcessorImpl(
} }
} }
func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, req *models.CreatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error) { func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, organizationID uuid.UUID, req *models.CreatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error) {
// Check if vendor exists and belongs to organization when provided. // Check if vendor exists and belongs to organization
if req.VendorID != nil { _, err := p.vendorRepo.GetByIDAndOrganizationID(ctx, req.VendorID, organizationID)
_, err := p.vendorRepo.GetByIDAndOrganizationID(ctx, *req.VendorID, organizationID)
if err != nil { if err != nil {
return nil, fmt.Errorf("vendor not found: %w", err) return nil, fmt.Errorf("vendor not found: %w", err)
} }
}
// Check if PO number already exists in organization // Check if PO number already exists in organization
existingPO, err := p.purchaseOrderRepo.GetByPONumber(ctx, req.PONumber, organizationID) existingPO, err := p.purchaseOrderRepo.GetByPONumber(ctx, req.PONumber, organizationID)
@@ -109,13 +107,12 @@ func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, or
// Calculate total amount // Calculate total amount
totalAmount := 0.0 totalAmount := 0.0
for _, item := range req.Items { for _, item := range req.Items {
totalAmount += calculatePurchaseOrderItemTotal(item.Quantity, item.Amount) totalAmount += item.Amount
} }
// Create purchase order entity // Create purchase order entity
poEntity := &entities.PurchaseOrder{ poEntity := &entities.PurchaseOrder{
OrganizationID: organizationID, OrganizationID: organizationID,
OutletID: outletID,
VendorID: req.VendorID, VendorID: req.VendorID,
PONumber: req.PONumber, PONumber: req.PONumber,
TransactionDate: req.TransactionDate, TransactionDate: req.TransactionDate,
@@ -176,15 +173,12 @@ func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, or
return mappers.PurchaseOrderEntityToResponse(createdPO), nil return mappers.PurchaseOrderEntityToResponse(createdPO), nil
} }
func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id, organizationID uuid.UUID, outletID *uuid.UUID, req *models.UpdatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error) { func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id, organizationID uuid.UUID, req *models.UpdatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error) {
// Get existing purchase order // Get existing purchase order
poEntity, err := p.purchaseOrderRepo.GetByIDAndOrganizationID(ctx, id, organizationID) poEntity, err := p.purchaseOrderRepo.GetByIDAndOrganizationID(ctx, id, organizationID)
if err != nil { if err != nil {
return nil, fmt.Errorf("purchase order not found: %w", err) 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) // Check if vendor exists and belongs to organization (if vendor is being updated)
if req.VendorID != nil { if req.VendorID != nil {
@@ -192,7 +186,7 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id
if err != nil { if err != nil {
return nil, fmt.Errorf("vendor not found: %w", err) 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) // Check if PO number already exists (if PO number is being updated)
@@ -283,7 +277,7 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id
UnitID: unitID, UnitID: unitID,
Amount: amount, Amount: amount,
} }
totalAmount += calculatePurchaseOrderItemTotal(quantity, amount) totalAmount += amount
} }
// Delete and recreate only after all replacement items are valid. // Delete and recreate only after all replacement items are valid.
@@ -453,7 +447,7 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte
// Calculate unit cost in ingredient's base unit // Calculate unit cost in ingredient's base unit
unitCost := 0.0 unitCost := 0.0
if quantityToAdd > 0 { if quantityToAdd > 0 {
unitCost = calculatePurchaseOrderItemTotal(item.Quantity, item.Amount) / quantityToAdd unitCost = item.Amount / quantityToAdd
} }
// Create inventory movement for ingredient purchase // Create inventory movement for ingredient purchase
@@ -482,12 +476,7 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte
} }
// Update the purchase order status // Update the purchase order status
statusOutletID := po.OutletID err = p.purchaseOrderRepo.UpdateStatus(ctx, id, status)
if statusOutletID == nil && outletID != uuid.Nil {
statusOutletID = &outletID
}
err = p.purchaseOrderRepo.UpdateStatusAndOutlet(ctx, id, status, statusOutletID)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to update purchase order status: %w", err) return nil, fmt.Errorf("failed to update purchase order status: %w", err)
} }
@@ -517,11 +506,3 @@ func (p *PurchaseOrderProcessorImpl) validatePurchaseCategory(ctx context.Contex
return category, nil return category, nil
} }
func calculatePurchaseOrderItemTotal(quantity *float64, amount float64) float64 {
if quantity == nil {
return amount
}
return *quantity * amount
}
@@ -19,7 +19,6 @@ type PurchaseOrderRepository interface {
GetByStatus(ctx context.Context, organizationID uuid.UUID, status string) ([]*entities.PurchaseOrder, error) GetByStatus(ctx context.Context, organizationID uuid.UUID, status string) ([]*entities.PurchaseOrder, error)
GetOverdue(ctx context.Context, organizationID uuid.UUID) ([]*entities.PurchaseOrder, error) GetOverdue(ctx context.Context, organizationID uuid.UUID) ([]*entities.PurchaseOrder, error)
UpdateStatus(ctx context.Context, id uuid.UUID, status string) 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 UpdateTotalAmount(ctx context.Context, id uuid.UUID, totalAmount float64) error
CreateItem(ctx context.Context, item *entities.PurchaseOrderItem) error CreateItem(ctx context.Context, item *entities.PurchaseOrderItem) error
UpdateItem(ctx context.Context, item *entities.PurchaseOrderItem) error UpdateItem(ctx context.Context, item *entities.PurchaseOrderItem) error
+46 -495
View File
@@ -2,7 +2,6 @@ package repository
import ( import (
"context" "context"
"sort"
"time" "time"
"apskel-pos-be/internal/entities" "apskel-pos-be/internal/entities"
@@ -21,7 +20,6 @@ type AnalyticsRepository interface {
GetProfitLossAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) (*entities.ProfitLossAnalytics, 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) 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) 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 { type AnalyticsRepositoryImpl struct {
@@ -41,34 +39,6 @@ func (r *AnalyticsRepositoryImpl) resolveOutletID(query *gorm.DB, outletID *uuid
return query 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) { func (r *AnalyticsRepositoryImpl) GetPaymentMethodAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]*entities.PaymentMethodAnalytics, error) {
var results []*entities.PaymentMethodAnalytics var results []*entities.PaymentMethodAnalytics
@@ -183,23 +153,18 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex
summaryQuery := r.db.WithContext(ctx). summaryQuery := r.db.WithContext(ctx).
Table("purchase_orders po"). Table("purchase_orders po").
Select(` Select(`
COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as total_purchases, COALESCE(SUM(poi.amount), 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 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, COALESCE(SUM(poi.quantity), 0) as total_quantity,
CASE CASE
WHEN COUNT(DISTINCT po.id) > 0 WHEN COUNT(DISTINCT po.id) > 0
THEN COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) / COUNT(DISTINCT po.id) THEN COALESCE(SUM(poi.amount), 0) / COUNT(DISTINCT po.id)
ELSE 0 ELSE 0
END as average_purchase_order_value, END as average_purchase_order_value,
COUNT(DISTINCT i.id) as total_ingredients, COUNT(DISTINCT i.id) as total_ingredients,
COUNT(DISTINCT COALESCE(po.vendor_id::text, 'no-vendor')) as total_vendors COUNT(DISTINCT po.vendor_id) as total_vendors
`). `).
Joins("LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.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 ingredients i ON poi.ingredient_id = i.id").
Joins("LEFT JOIN units u ON poi.unit_id = u.id"). Joins("LEFT JOIN units u ON poi.unit_id = u.id").
Where("po.organization_id = ?", organizationID). Where("po.organization_id = ?", organizationID).
@@ -224,60 +189,21 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex
} }
var data []entities.PurchasingAnalyticsData var data []entities.PurchasingAnalyticsData
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)
if err := outletQuery.Scan(&outletData).Error; err != nil {
return nil, err
}
} else {
dataQuery := r.db.WithContext(ctx). dataQuery := r.db.WithContext(ctx).
Table("purchase_orders po"). Table("purchase_orders po").
Select(` Select(`
`+dateFormat+` as date, `+dateFormat+` as date,
COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as purchases, COALESCE(SUM(poi.amount), 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 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, COALESCE(SUM(poi.quantity), 0) as quantity,
COUNT(DISTINCT i.id) as ingredients, COUNT(DISTINCT i.id) as ingredients,
COUNT(DISTINCT COALESCE(po.vendor_id::text, 'no-vendor')) as vendors COUNT(DISTINCT po.vendor_id) as vendors
`). `).
Joins("LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.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 ingredients i ON poi.ingredient_id = i.id").
Joins("LEFT JOIN units u ON poi.unit_id = u.id"). Joins("LEFT JOIN units u ON poi.unit_id = u.id").
Where("po.organization_id = ?", organizationID). Where("po.organization_id = ?", organizationID).
Where("po.status != ?", "cancelled"). Where("po.status != ?", "cancelled").
Where("pc.type = ?", entities.PurchaseCategoryTypeRawMaterial).
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo). Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo).
Group(dateFormat). Group(dateFormat).
Order(dateFormat) Order(dateFormat)
@@ -286,7 +212,6 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex
if err := dataQuery.Scan(&data).Error; err != nil { if err := dataQuery.Scan(&data).Error; err != nil {
return nil, err return nil, err
} }
}
var ingredientData []entities.PurchasingIngredientData var ingredientData []entities.PurchasingIngredientData
ingredientQuery := r.db.WithContext(ctx). ingredientQuery := r.db.WithContext(ctx).
@@ -295,16 +220,15 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex
i.id as ingredient_id, i.id as ingredient_id,
i.name as ingredient_name, i.name as ingredient_name,
COALESCE(SUM(poi.quantity), 0) as quantity, COALESCE(SUM(poi.quantity), 0) as quantity,
COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as total_cost, COALESCE(SUM(poi.amount), 0) as total_cost,
CASE CASE
WHEN SUM(poi.quantity) > 0 WHEN SUM(poi.quantity) > 0
THEN COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) / SUM(poi.quantity) THEN COALESCE(SUM(poi.amount), 0) / SUM(poi.quantity)
ELSE 0 ELSE 0
END as average_unit_cost, END as average_unit_cost,
COUNT(DISTINCT po.id) as purchase_order_count COUNT(DISTINCT po.id) as purchase_order_count
`). `).
Joins("JOIN purchase_orders po ON poi.purchase_order_id = po.id"). 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("JOIN ingredients i ON poi.ingredient_id = i.id").
Joins("LEFT JOIN units u ON poi.unit_id = u.id"). Joins("LEFT JOIN units u ON poi.unit_id = u.id").
Where("po.organization_id = ?", organizationID). Where("po.organization_id = ?", organizationID).
@@ -323,21 +247,20 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex
Table("purchase_orders po"). Table("purchase_orders po").
Select(` Select(`
v.id as vendor_id, v.id as vendor_id,
COALESCE(v.name, 'No Vendor') as vendor_name, v.name as vendor_name,
COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as total_cost, COALESCE(SUM(poi.amount), 0) as total_cost,
COUNT(DISTINCT po.id) as purchase_order_count, COUNT(DISTINCT po.id) as purchase_order_count,
COUNT(DISTINCT i.id) as ingredient_count, COUNT(DISTINCT i.id) as ingredient_count,
COALESCE(SUM(poi.quantity), 0) as quantity COALESCE(SUM(poi.quantity), 0) as quantity
`). `).
Joins("LEFT JOIN vendors v ON po.vendor_id = v.id"). Joins("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_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 ingredients i ON poi.ingredient_id = i.id").
Joins("LEFT JOIN units u ON poi.unit_id = u.id"). Joins("LEFT JOIN units u ON poi.unit_id = u.id").
Where("po.organization_id = ?", organizationID). Where("po.organization_id = ?", organizationID).
Where("po.status != ?", "cancelled"). Where("po.status != ?", "cancelled").
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo). Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo).
Group("v.id, COALESCE(v.name, 'No Vendor')"). Group("v.id, v.name").
Order("total_cost DESC") Order("total_cost DESC")
vendorQuery = r.applyPurchaseOrderItemOutletFilter(vendorQuery, outletID) vendorQuery = r.applyPurchaseOrderItemOutletFilter(vendorQuery, outletID)
@@ -349,7 +272,6 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex
OutletName: outletName, OutletName: outletName,
Summary: summary, Summary: summary,
Data: data, Data: data,
OutletData: outletData,
IngredientData: ingredientData, IngredientData: ingredientData,
VendorData: vendorData, VendorData: vendorData,
}, nil }, nil
@@ -359,7 +281,7 @@ func (r *AnalyticsRepositoryImpl) applyPurchaseOrderItemOutletFilter(query *gorm
if outletID == nil { if outletID == nil {
return query return query
} }
return query.Where("po.outlet_id = ?", *outletID) return query.Where("(i.outlet_id = ? OR u.outlet_id = ?)", *outletID, *outletID)
} }
func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, limit int) ([]*entities.ProductAnalytics, error) { func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, limit int) ([]*entities.ProductAnalytics, error) {
@@ -370,13 +292,6 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organ
Select(` Select(`
p.id as product_id, p.id as product_id,
p.name as product_name, p.name as product_name,
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.id as category_id,
c.name as category_name, c.name as category_name,
c.order as category_order, c.order as category_order,
@@ -410,7 +325,6 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organ
Joins("JOIN products p ON oi.product_id = p.id"). Joins("JOIN products p ON oi.product_id = p.id").
Joins("JOIN categories c ON p.category_id = c.id"). Joins("JOIN categories c ON p.category_id = c.id").
Joins("JOIN orders o ON oi.order_id = o.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", Joins("LEFT JOIN (?) mahpp ON mahpp.product_id = p.id",
r.db.Table("product_recipes pr2"). 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"). 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").
@@ -436,7 +350,7 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organ
query = r.resolveOutletID(query, outletID, "o.outlet_id") query = r.resolveOutletID(query, outletID, "o.outlet_id")
err := query. err := query.
Group("p.id, p.name, p.sku, p.price, p.cost, pop.price, c.id, c.name, c.order, mahpp.hpp_per_unit"). Group("p.id, p.name, p.cost, c.id, c.name, c.order, mahpp.hpp_per_unit").
Order("revenue DESC"). Order("revenue DESC").
Limit(limit). Limit(limit).
Scan(&results).Error Scan(&results).Error
@@ -523,41 +437,6 @@ func (r *AnalyticsRepositoryImpl) GetDashboardOverview(ctx context.Context, orga
return nil, err 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 return &result, nil
} }
@@ -607,11 +486,6 @@ func (r *AnalyticsRepositoryImpl) GetProfitLossAnalytics(ctx context.Context, or
if err := summaryQuery.Scan(&summary).Error; err != nil { if err := summaryQuery.Scan(&summary).Error; err != nil {
return nil, err return nil, err
} }
periodHPP, err := r.getPurchaseOrderRawMaterialTotal(ctx, organizationID, outletID, dateFrom, dateTo)
if err != nil {
return nil, err
}
applyProfitLossSummaryCost(&summary, periodHPP)
var timeFormat string var timeFormat string
switch groupBy { switch groupBy {
@@ -659,11 +533,6 @@ func (r *AnalyticsRepositoryImpl) GetProfitLossAnalytics(ctx context.Context, or
if err := dataQuery.Scan(&data).Error; err != nil { if err := dataQuery.Scan(&data).Error; err != nil {
return nil, err 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 var productData []entities.ProductProfitData
productQuery := r.db.WithContext(ctx). productQuery := r.db.WithContext(ctx).
@@ -724,11 +593,6 @@ func (r *AnalyticsRepositoryImpl) GetProfitLossAnalytics(ctx context.Context, or
if err := todayQuery.Scan(&todayRC).Error; err != nil { if err := todayQuery.Scan(&todayRC).Error; err != nil {
return nil, err return nil, err
} }
todayHPP, err := r.getPurchaseOrderRawMaterialTotal(ctx, organizationID, outletID, todayStart, todayEnd)
if err != nil {
return nil, err
}
todayRC.Cost = todayHPP
var mtdRC revenueCostResult var mtdRC revenueCostResult
mtdQuery := r.db.WithContext(ctx). mtdQuery := r.db.WithContext(ctx).
@@ -746,55 +610,21 @@ func (r *AnalyticsRepositoryImpl) GetProfitLossAnalytics(ctx context.Context, or
if err := mtdQuery.Scan(&mtdRC).Error; err != nil { if err := mtdQuery.Scan(&mtdRC).Error; err != nil {
return nil, err 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) todayExpenseByCategory, err := r.getExpenseByCategory(ctx, organizationID, outletID, todayStart, todayEnd)
if err != nil { if err != nil {
return nil, err 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) mtdExpenseByCategory, err := r.getExpenseByCategory(ctx, organizationID, outletID, mtdStart, todayEnd)
if err != nil { if err != nil {
return nil, err 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) opsItems, err := r.getOperationalExpenseItems(ctx, organizationID, outletID, mtdStart, todayEnd)
if err != nil { if err != nil {
return nil, err 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{ return &entities.ProfitLossAnalytics{
Summary: summary, Summary: summary,
@@ -804,275 +634,12 @@ func (r *AnalyticsRepositoryImpl) GetProfitLossAnalytics(ctx context.Context, or
TodayCost: todayRC.Cost, TodayCost: todayRC.Cost,
MtdRevenue: mtdRC.Revenue, MtdRevenue: mtdRC.Revenue,
MtdCost: mtdRC.Cost, 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, TodayExpenseByCategory: todayExpenseByCategory,
MtdExpenseByCategory: mtdExpenseByCategory, MtdExpenseByCategory: mtdExpenseByCategory,
OperationalExpenseItems: opsItems, OperationalExpenseItems: opsItems,
}, nil }, 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) { func (r *AnalyticsRepositoryImpl) getExpenseByCategory(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]entities.ExpenseCategoryTotal, error) {
var results []entities.ExpenseCategoryTotal var results []entities.ExpenseCategoryTotal
@@ -1150,7 +717,7 @@ func (r *AnalyticsRepositoryImpl) GetExclusiveSummaryAnalytics(ctx context.Conte
return nil, err return nil, err
} }
operationalExpenseBreakdown, err := r.getExclusiveSummaryOperationalExpenseBreakdown(ctx, organizationID, outletID, dateFrom, dateTo) operationalExpenseBreakdown, err := r.getExclusiveSummaryOperationalExpenseBreakdown(ctx, organizationID, dateFrom, dateTo)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -1183,7 +750,7 @@ func (r *AnalyticsRepositoryImpl) getExclusiveSummaryHPPBreakdown(ctx context.Co
Select(` Select(`
pc.code as category_code, pc.code as category_code,
pc.name as category_name, pc.name as category_name,
COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as amount COALESCE(SUM(poi.amount), 0) as amount
`). `).
Joins("JOIN purchase_orders po ON poi.purchase_order_id = po.id"). 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 purchase_categories pc ON poi.purchase_category_id = pc.id").
@@ -1193,7 +760,9 @@ func (r *AnalyticsRepositoryImpl) getExclusiveSummaryHPPBreakdown(ctx context.Co
Where("pc.type = ?", entities.PurchaseCategoryTypeRawMaterial). Where("pc.type = ?", entities.PurchaseCategoryTypeRawMaterial).
Where("po.status = ?", "received"). Where("po.status = ?", "received").
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo) Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo)
query = r.applyPurchaseOrderItemOutletFilter(query, outletID) if outletID != nil {
query = query.Where("(i.outlet_id = ? OR u.outlet_id = ?)", *outletID, *outletID)
}
err := query. err := query.
Group("pc.id, pc.code, pc.name, pc.sort_order"). Group("pc.id, pc.code, pc.name, pc.sort_order").
@@ -1203,25 +772,22 @@ func (r *AnalyticsRepositoryImpl) getExclusiveSummaryHPPBreakdown(ctx context.Co
return results, err return results, err
} }
func (r *AnalyticsRepositoryImpl) getExclusiveSummaryOperationalExpenseBreakdown(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]entities.ExclusiveSummaryCategoryTotal, error) { func (r *AnalyticsRepositoryImpl) getExclusiveSummaryOperationalExpenseBreakdown(ctx context.Context, organizationID uuid.UUID, dateFrom, dateTo time.Time) ([]entities.ExclusiveSummaryCategoryTotal, error) {
var results []entities.ExclusiveSummaryCategoryTotal var results []entities.ExclusiveSummaryCategoryTotal
query := r.db.WithContext(ctx). err := r.db.WithContext(ctx).
Table("purchase_order_items poi"). Table("purchase_order_items poi").
Select(` Select(`
pc.code as category_code, pc.code as category_code,
pc.name as category_name, pc.name as category_name,
COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as amount COALESCE(SUM(poi.amount), 0) as amount
`). `).
Joins("JOIN purchase_orders po ON poi.purchase_order_id = po.id"). 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 purchase_categories pc ON poi.purchase_category_id = pc.id").
Where("po.organization_id = ?", organizationID). Where("po.organization_id = ?", organizationID).
Where("pc.type = ?", entities.PurchaseCategoryTypeExpense). Where("pc.type = ?", entities.PurchaseCategoryTypeNonInventory).
Where("po.status = ?", "received"). Where("po.status = ?", "received").
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo) 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"). Group("pc.id, pc.code, pc.name, pc.sort_order").
Order("pc.sort_order ASC, pc.name ASC"). Order("pc.sort_order ASC, pc.name ASC").
Scan(&results).Error Scan(&results).Error
@@ -1231,7 +797,7 @@ func (r *AnalyticsRepositoryImpl) getExclusiveSummaryOperationalExpenseBreakdown
func (r *AnalyticsRepositoryImpl) getExclusiveSummaryDailySummary(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]entities.ExclusiveSummaryDailySummary, error) { func (r *AnalyticsRepositoryImpl) getExclusiveSummaryDailySummary(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]entities.ExclusiveSummaryDailySummary, error) {
var results []entities.ExclusiveSummaryDailySummary var results []entities.ExclusiveSummaryDailySummary
rawQuery, args := r.exclusiveSummaryPurchaseOrderItemQuery(organizationID, outletID, dateFrom, dateTo) rawQuery, args := r.exclusiveSummaryPurchaseOrderItemsQuery(organizationID, outletID, dateFrom, dateTo)
err := r.db.WithContext(ctx).Raw(` err := r.db.WithContext(ctx).Raw(`
SELECT date, COUNT(*) as transaction_count, COALESCE(SUM(amount), 0) as total_cost SELECT date, COUNT(*) as transaction_count, COALESCE(SUM(amount), 0) as total_cost
@@ -1245,7 +811,7 @@ func (r *AnalyticsRepositoryImpl) getExclusiveSummaryDailySummary(ctx context.Co
func (r *AnalyticsRepositoryImpl) getExclusiveSummaryDailyTransactions(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]entities.ExclusiveSummaryDailyTransaction, error) { func (r *AnalyticsRepositoryImpl) getExclusiveSummaryDailyTransactions(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]entities.ExclusiveSummaryDailyTransaction, error) {
var results []entities.ExclusiveSummaryDailyTransaction var results []entities.ExclusiveSummaryDailyTransaction
rawQuery, args := r.exclusiveSummaryPurchaseOrderItemQuery(organizationID, outletID, dateFrom, dateTo) rawQuery, args := r.exclusiveSummaryPurchaseOrderItemsQuery(organizationID, outletID, dateFrom, dateTo)
err := r.db.WithContext(ctx).Raw(` err := r.db.WithContext(ctx).Raw(`
SELECT date, category_code, category_name, description, amount, source SELECT date, category_code, category_name, description, amount, source
@@ -1256,18 +822,19 @@ func (r *AnalyticsRepositoryImpl) getExclusiveSummaryDailyTransactions(ctx conte
return results, err return results, err
} }
func (r *AnalyticsRepositoryImpl) exclusiveSummaryPurchaseOrderItemQuery(organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (string, []interface{}) { func (r *AnalyticsRepositoryImpl) exclusiveSummaryPurchaseOrderItemsQuery(organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (string, []interface{}) {
outletFilter := ""
args := []interface{}{ args := []interface{}{
organizationID, organizationID,
entities.PurchaseCategoryTypeRawMaterial,
entities.PurchaseCategoryTypeNonInventory,
"received", "received",
dateFrom, dateFrom,
dateTo, dateTo,
} }
outletFilter := ""
if outletID != nil { if outletID != nil {
outletFilter = "AND po.outlet_id = ?" outletFilter = "AND (pc.type = ? OR i.outlet_id = ? OR u.outlet_id = ?)"
args = append(args, *outletID) args = append(args, entities.PurchaseCategoryTypeNonInventory, *outletID, *outletID)
} }
query := ` query := `
@@ -1276,7 +843,7 @@ func (r *AnalyticsRepositoryImpl) exclusiveSummaryPurchaseOrderItemQuery(organiz
pc.code as category_code, pc.code as category_code,
pc.name as category_name, pc.name as category_name,
COALESCE(NULLIF(poi.description, ''), i.name, pc.name) as description, COALESCE(NULLIF(poi.description, ''), i.name, pc.name) as description,
` + purchaseOrderItemTotalAmountSQL() + ` as amount, poi.amount as amount,
'purchase_order' as source 'purchase_order' as source
FROM purchase_order_items poi FROM purchase_order_items poi
JOIN purchase_orders po ON poi.purchase_order_id = po.id JOIN purchase_orders po ON poi.purchase_order_id = po.id
@@ -1284,6 +851,7 @@ func (r *AnalyticsRepositoryImpl) exclusiveSummaryPurchaseOrderItemQuery(organiz
LEFT JOIN ingredients i ON poi.ingredient_id = i.id LEFT JOIN ingredients i ON poi.ingredient_id = i.id
LEFT JOIN units u ON poi.unit_id = u.id LEFT JOIN units u ON poi.unit_id = u.id
WHERE po.organization_id = ? WHERE po.organization_id = ?
AND pc.type IN (?, ?)
AND po.status = ? AND po.status = ?
AND po.transaction_date >= ? AND po.transaction_date <= ? AND po.transaction_date >= ? AND po.transaction_date <= ?
` + outletFilter + ` ` + outletFilter + `
@@ -1293,45 +861,28 @@ func (r *AnalyticsRepositoryImpl) exclusiveSummaryPurchaseOrderItemQuery(organiz
} }
func (r *AnalyticsRepositoryImpl) GetExclusiveSummaryBankBalances(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) ([]entities.ExclusiveSummaryBankBalance, error) { func (r *AnalyticsRepositoryImpl) GetExclusiveSummaryBankBalances(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) ([]entities.ExclusiveSummaryBankBalance, error) {
type accountBalance struct { var results []entities.ExclusiveSummaryBankBalance
Name string
OpeningBalance float64
CurrentBalance float64
Description *string
}
var accounts []accountBalance
query := r.db.WithContext(ctx). query := r.db.WithContext(ctx).
Table("accounts"). Table("accounts").
Select("name, opening_balance, current_balance, description"). Select(`
name as bank,
account_type,
opening_balance,
current_balance as closing_balance,
description
`).
Where("organization_id = ?", organizationID). Where("organization_id = ?", organizationID).
Where("account_type IN ?", []entities.AccountType{entities.AccountTypeCash, entities.AccountTypeWallet, entities.AccountTypeBank}). Where("is_active = ?", true).
Where("is_active = ?", true) Where("account_type IN ?", []entities.AccountType{entities.AccountTypeBank, entities.AccountTypeWallet, entities.AccountTypeCash})
if outletID != nil { if outletID != nil {
query = query.Where("outlet_id = ? OR outlet_id IS NULL", *outletID) query = query.Where("outlet_id = ? OR outlet_id IS NULL", *outletID)
} else {
query = query.Where("outlet_id IS NULL")
} }
err := query. err := query.
Order("number ASC, name ASC"). Order("CASE account_type WHEN 'bank' THEN 1 WHEN 'wallet' THEN 2 WHEN 'cash' THEN 3 ELSE 4 END, number ASC, name ASC").
Scan(&accounts).Error Scan(&results).Error
if err != nil {
return nil, err
}
balances := make([]entities.ExclusiveSummaryBankBalance, len(accounts)) return results, err
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
} }
@@ -196,18 +196,6 @@ func (r *PurchaseOrderRepositoryImpl) UpdateStatus(ctx context.Context, id uuid.
Update("status", status).Error 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 { func (r *PurchaseOrderRepositoryImpl) UpdateTotalAmount(ctx context.Context, id uuid.UUID, totalAmount float64) error {
return r.db.WithContext(ctx). return r.db.WithContext(ctx).
Model(&entities.PurchaseOrder{}). Model(&entities.PurchaseOrder{}).
+6 -8
View File
@@ -339,7 +339,6 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
analytics.GET("/profit-loss", r.analyticsHandler.GetProfitLossAnalytics) analytics.GET("/profit-loss", r.analyticsHandler.GetProfitLossAnalytics)
analytics.GET("/exclusive-summary/period", r.analyticsHandler.GetExclusiveSummaryPeriod) analytics.GET("/exclusive-summary/period", r.analyticsHandler.GetExclusiveSummaryPeriod)
analytics.GET("/exclusive-summary/monthly", r.analyticsHandler.GetExclusiveSummaryMonthly) analytics.GET("/exclusive-summary/monthly", r.analyticsHandler.GetExclusiveSummaryMonthly)
analytics.GET("/exclusive-summary/mtd", r.analyticsHandler.GetExclusiveSummaryMTD)
} }
tables := protected.Group("/tables") tables := protected.Group("/tables")
@@ -356,7 +355,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
} }
ingredients := protected.Group("/ingredients") ingredients := protected.Group("/ingredients")
ingredients.Use(r.authMiddleware.RequireAdminOrManagerOrPurchasing()) ingredients.Use(r.authMiddleware.RequireAdminOrManager())
{ {
ingredients.POST("", r.ingredientHandler.Create) ingredients.POST("", r.ingredientHandler.Create)
ingredients.GET("", r.ingredientHandler.GetAll) ingredients.GET("", r.ingredientHandler.GetAll)
@@ -369,7 +368,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
} }
vendors := protected.Group("/vendors") vendors := protected.Group("/vendors")
vendors.Use(r.authMiddleware.RequireAdminOrManagerOrPurchasing()) vendors.Use(r.authMiddleware.RequireAdminOrManager())
{ {
vendors.POST("", r.vendorHandler.CreateVendor) vendors.POST("", r.vendorHandler.CreateVendor)
vendors.GET("", r.vendorHandler.ListVendors) vendors.GET("", r.vendorHandler.ListVendors)
@@ -380,7 +379,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
} }
purchaseOrders := protected.Group("/purchase-orders") purchaseOrders := protected.Group("/purchase-orders")
purchaseOrders.Use(r.authMiddleware.RequireAdminOrManagerOrPurchasing()) purchaseOrders.Use(r.authMiddleware.RequireAdminOrManager())
{ {
purchaseOrders.POST("", r.purchaseOrderHandler.CreatePurchaseOrder) purchaseOrders.POST("", r.purchaseOrderHandler.CreatePurchaseOrder)
purchaseOrders.GET("", r.purchaseOrderHandler.ListPurchaseOrders) purchaseOrders.GET("", r.purchaseOrderHandler.ListPurchaseOrders)
@@ -393,7 +392,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
} }
purchaseCategories := protected.Group("/purchase-categories") purchaseCategories := protected.Group("/purchase-categories")
purchaseCategories.Use(r.authMiddleware.RequireAdminOrManagerOrPurchasing()) purchaseCategories.Use(r.authMiddleware.RequireAdminOrManager())
{ {
purchaseCategories.POST("", r.purchaseCategoryHandler.CreatePurchaseCategory) purchaseCategories.POST("", r.purchaseCategoryHandler.CreatePurchaseCategory)
purchaseCategories.GET("", r.purchaseCategoryHandler.ListPurchaseCategories) purchaseCategories.GET("", r.purchaseCategoryHandler.ListPurchaseCategories)
@@ -403,7 +402,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
} }
unitConverters := protected.Group("/unit-converters") unitConverters := protected.Group("/unit-converters")
unitConverters.Use(r.authMiddleware.RequireAdminOrManagerOrPurchasing()) unitConverters.Use(r.authMiddleware.RequireAdminOrManager())
{ {
unitConverters.POST("", r.unitConverterHandler.CreateIngredientUnitConverter) unitConverters.POST("", r.unitConverterHandler.CreateIngredientUnitConverter)
unitConverters.GET("", r.unitConverterHandler.ListIngredientUnitConverters) unitConverters.GET("", r.unitConverterHandler.ListIngredientUnitConverters)
@@ -465,7 +464,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
} }
expenses := protected.Group("/expenses") expenses := protected.Group("/expenses")
expenses.Use(r.authMiddleware.RequireAdminOrManagerOrPurchasing()) expenses.Use(r.authMiddleware.RequireAdminOrManager())
{ {
expenses.POST("", r.expenseHandler.CreateExpense) expenses.POST("", r.expenseHandler.CreateExpense)
expenses.GET("", r.expenseHandler.ListExpenses) expenses.GET("", r.expenseHandler.ListExpenses)
@@ -620,7 +619,6 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
outlets.GET("/:outlet_id/tables/occupied", r.tableHandler.GetOccupiedTables) outlets.GET("/:outlet_id/tables/occupied", r.tableHandler.GetOccupiedTables)
// Reports // Reports
outlets.GET("/:outlet_id/reports/daily-transaction.pdf", r.reportHandler.GetDailyTransactionReportPDF) 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 // User device routes - accessible by authenticated users for their own devices
-31
View File
@@ -20,7 +20,6 @@ type AnalyticsService interface {
GetProfitLossAnalytics(ctx context.Context, req *models.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsResponse, error) GetProfitLossAnalytics(ctx context.Context, req *models.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsResponse, error)
GetExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error) GetExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error)
GetExclusiveSummaryMonthly(ctx context.Context, req *models.ExclusiveSummaryMonthlyRequest) (*models.ExclusiveSummaryMonthlyResponse, error) GetExclusiveSummaryMonthly(ctx context.Context, req *models.ExclusiveSummaryMonthlyRequest) (*models.ExclusiveSummaryMonthlyResponse, error)
GetExclusiveSummaryMTD(ctx context.Context, req *models.ExclusiveSummaryMTDRequest) (*models.ExclusiveSummaryPeriodResponse, error)
} }
type AnalyticsServiceImpl struct { type AnalyticsServiceImpl struct {
@@ -200,7 +199,6 @@ func (s *AnalyticsServiceImpl) validatePurchasingAnalyticsRequest(req *models.Pu
"hour": true, "hour": true,
"week": true, "week": true,
"month": true, "month": true,
"outlet_id": true,
} }
if !validGroupBy[req.GroupBy] { if !validGroupBy[req.GroupBy] {
return fmt.Errorf("invalid group_by value: %s", req.GroupBy) return fmt.Errorf("invalid group_by value: %s", req.GroupBy)
@@ -351,19 +349,6 @@ func (s *AnalyticsServiceImpl) GetExclusiveSummaryMonthly(ctx context.Context, r
return response, nil 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 { func (s *AnalyticsServiceImpl) validateExclusiveSummaryPeriodRequest(req *models.ExclusiveSummaryPeriodRequest) error {
if req == nil { if req == nil {
return fmt.Errorf("request cannot be nil") return fmt.Errorf("request cannot be nil")
@@ -388,22 +373,6 @@ func (s *AnalyticsServiceImpl) validateExclusiveSummaryPeriodRequest(req *models
return nil 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 { func (s *AnalyticsServiceImpl) validateExclusiveSummaryMonthlyRequest(req *models.ExclusiveSummaryMonthlyRequest) error {
if req == nil { if req == nil {
return fmt.Errorf("request cannot be nil") return fmt.Errorf("request cannot be nil")
-116
View File
@@ -49,10 +49,6 @@ func (analyticsProcessorStub) GetExclusiveSummaryMonthly(context.Context, *model
return &models.ExclusiveSummaryMonthlyResponse{}, nil return &models.ExclusiveSummaryMonthlyResponse{}, nil
} }
func (analyticsProcessorStub) GetExclusiveSummaryMTD(context.Context, *models.ExclusiveSummaryMTDRequest) (*models.ExclusiveSummaryPeriodResponse, error) {
return &models.ExclusiveSummaryPeriodResponse{}, nil
}
func TestAnalyticsServiceGetPurchasingAnalyticsValidation(t *testing.T) { func TestAnalyticsServiceGetPurchasingAnalyticsValidation(t *testing.T) {
service := NewAnalyticsServiceImpl(analyticsProcessorStub{}) service := NewAnalyticsServiceImpl(analyticsProcessorStub{})
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
@@ -132,21 +128,6 @@ func TestAnalyticsServiceGetPurchasingAnalyticsAllowsEmptyGroupBy(t *testing.T)
require.NotNil(t, resp) 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) { func TestAnalyticsServiceGetProfitLossAnalyticsValidation(t *testing.T) {
service := NewAnalyticsServiceImpl(analyticsProcessorStub{}) service := NewAnalyticsServiceImpl(analyticsProcessorStub{})
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
@@ -217,100 +198,3 @@ func TestAnalyticsServiceGetProfitLossAnalyticsAllowsEmptyGroupBy(t *testing.T)
require.NoError(t, err) require.NoError(t, err)
require.Nil(t, resp) 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)
})
}
}
+2 -12
View File
@@ -40,12 +40,7 @@ func (s *PurchaseOrderServiceImpl) CreatePurchaseOrder(ctx context.Context, apct
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp}) return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
} }
var outletID *uuid.UUID poResponse, err := s.purchaseOrderProcessor.CreatePurchaseOrder(ctx, apctx.OrganizationID, modelReq)
if apctx.OutletID != uuid.Nil {
outletID = &apctx.OutletID
}
poResponse, err := s.purchaseOrderProcessor.CreatePurchaseOrder(ctx, apctx.OrganizationID, outletID, modelReq)
if err != nil { if err != nil {
errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.PurchaseOrderServiceEntity, err.Error()) errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.PurchaseOrderServiceEntity, err.Error())
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp}) return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
@@ -62,12 +57,7 @@ func (s *PurchaseOrderServiceImpl) UpdatePurchaseOrder(ctx context.Context, apct
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp}) return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
} }
var outletID *uuid.UUID poResponse, err := s.purchaseOrderProcessor.UpdatePurchaseOrder(ctx, id, apctx.OrganizationID, modelReq)
if apctx.OutletID != uuid.Nil {
outletID = &apctx.OutletID
}
poResponse, err := s.purchaseOrderProcessor.UpdatePurchaseOrder(ctx, id, apctx.OrganizationID, outletID, modelReq)
if err != nil { if err != nil {
errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.PurchaseOrderServiceEntity, err.Error()) errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.PurchaseOrderServiceEntity, err.Error())
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp}) return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
-294
View File
@@ -17,7 +17,6 @@ import (
type ReportService interface { type ReportService interface {
// Returns (publicURL, fileName, error) // Returns (publicURL, fileName, error)
GenerateDailyTransactionPDF(ctx context.Context, organizationID string, outletID string, reportDate *time.Time, generatedBy string) (string, string, 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 { type ReportServiceImpl struct {
@@ -219,296 +218,3 @@ func getPLPctByID(rows []models.ProfitLossSummaryRow, id string) float64 {
} }
return 0 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)
}
+6 -91
View File
@@ -66,7 +66,6 @@ func PaymentMethodAnalyticsModelToContract(resp *models.PaymentMethodAnalyticsRe
return &contract.PaymentMethodAnalyticsResponse{ return &contract.PaymentMethodAnalyticsResponse{
OrganizationID: resp.OrganizationID, OrganizationID: resp.OrganizationID,
OutletID: resp.OutletID, OutletID: resp.OutletID,
OutletName: resp.OutletName,
DateFrom: resp.DateFrom, DateFrom: resp.DateFrom,
DateTo: resp.DateTo, DateTo: resp.DateTo,
GroupBy: resp.GroupBy, GroupBy: resp.GroupBy,
@@ -123,7 +122,6 @@ func SalesAnalyticsModelToContract(resp *models.SalesAnalyticsResponse) *contrac
return &contract.SalesAnalyticsResponse{ return &contract.SalesAnalyticsResponse{
OrganizationID: resp.OrganizationID, OrganizationID: resp.OrganizationID,
OutletID: resp.OutletID, OutletID: resp.OutletID,
OutletName: resp.OutletName,
DateFrom: resp.DateFrom, DateFrom: resp.DateFrom,
DateTo: resp.DateTo, DateTo: resp.DateTo,
GroupBy: resp.GroupBy, GroupBy: resp.GroupBy,
@@ -184,23 +182,6 @@ func PurchasingAnalyticsModelToContract(resp *models.PurchasingAnalyticsResponse
} }
} }
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,
}
}
ingredientData := make([]contract.PurchasingIngredientData, len(resp.IngredientData)) ingredientData := make([]contract.PurchasingIngredientData, len(resp.IngredientData))
for i, item := range resp.IngredientData { for i, item := range resp.IngredientData {
ingredientData[i] = contract.PurchasingIngredientData{ ingredientData[i] = contract.PurchasingIngredientData{
@@ -245,7 +226,6 @@ func PurchasingAnalyticsModelToContract(resp *models.PurchasingAnalyticsResponse
TotalVendors: resp.Summary.TotalVendors, TotalVendors: resp.Summary.TotalVendors,
}, },
Data: data, Data: data,
OutletData: outletData,
IngredientData: ingredientData, IngredientData: ingredientData,
VendorData: vendorData, VendorData: vendorData,
} }
@@ -305,7 +285,6 @@ func ProductAnalyticsModelToContract(resp *models.ProductAnalyticsResponse) *con
return &contract.ProductAnalyticsResponse{ return &contract.ProductAnalyticsResponse{
OrganizationID: resp.OrganizationID, OrganizationID: resp.OrganizationID,
OutletID: resp.OutletID, OutletID: resp.OutletID,
OutletName: resp.OutletName,
DateFrom: resp.DateFrom, DateFrom: resp.DateFrom,
DateTo: resp.DateTo, DateTo: resp.DateTo,
Data: data, Data: data,
@@ -358,7 +337,6 @@ func ProductAnalyticsPerCategoryModelToContract(resp *models.ProductAnalyticsPer
return &contract.ProductAnalyticsPerCategoryResponse{ return &contract.ProductAnalyticsPerCategoryResponse{
OrganizationID: resp.OrganizationID, OrganizationID: resp.OrganizationID,
OutletID: resp.OutletID, OutletID: resp.OutletID,
OutletName: resp.OutletName,
DateFrom: resp.DateFrom, DateFrom: resp.DateFrom,
DateTo: resp.DateTo, DateTo: resp.DateTo,
Data: data, Data: data,
@@ -443,7 +421,6 @@ func DashboardAnalyticsModelToContract(resp *models.DashboardAnalyticsResponse)
return &contract.DashboardAnalyticsResponse{ return &contract.DashboardAnalyticsResponse{
OrganizationID: resp.OrganizationID, OrganizationID: resp.OrganizationID,
OutletID: resp.OutletID, OutletID: resp.OutletID,
OutletName: resp.OutletName,
DateFrom: resp.DateFrom, DateFrom: resp.DateFrom,
DateTo: resp.DateTo, DateTo: resp.DateTo,
Overview: contract.DashboardOverview{ Overview: contract.DashboardOverview{
@@ -453,9 +430,6 @@ func DashboardAnalyticsModelToContract(resp *models.DashboardAnalyticsResponse)
TotalCustomers: resp.Overview.TotalCustomers, TotalCustomers: resp.Overview.TotalCustomers,
VoidedOrders: resp.Overview.VoidedOrders, VoidedOrders: resp.Overview.VoidedOrders,
RefundedOrders: resp.Overview.RefundedOrders, RefundedOrders: resp.Overview.RefundedOrders,
TotalItemSold: resp.Overview.TotalItemSold,
TotalLowStock: resp.Overview.TotalLowStock,
TotalProductActive: resp.Overview.TotalProductActive,
}, },
TopProducts: topProducts, TopProducts: topProducts,
PaymentMethods: paymentMethods, PaymentMethods: paymentMethods,
@@ -542,20 +516,9 @@ 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{ return &contract.ProfitLossAnalyticsResponse{
OrganizationID: resp.OrganizationID, OrganizationID: resp.OrganizationID,
OutletID: resp.OutletID, OutletID: resp.OutletID,
OutletName: resp.OutletName,
DateFrom: resp.DateFrom, DateFrom: resp.DateFrom,
DateTo: resp.DateTo, DateTo: resp.DateTo,
GroupBy: resp.GroupBy, GroupBy: resp.GroupBy,
@@ -575,15 +538,6 @@ func ProfitLossAnalyticsModelToContract(resp *models.ProfitLossAnalyticsResponse
Data: data, Data: data,
ProductData: productData, ProductData: productData,
MainSummary: mainSummary, 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, OperationalExpenses: opsItems,
OperationalExpensesTotal: resp.OperationalExpensesTotal, OperationalExpensesTotal: resp.OperationalExpensesTotal,
} }
@@ -650,27 +604,6 @@ func ExclusiveSummaryMonthlyContractToModel(req *contract.ExclusiveSummaryMonthl
}, nil }, 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 { func ExclusiveSummaryPeriodModelToContract(resp *models.ExclusiveSummaryPeriodResponse) *contract.ExclusiveSummaryPeriodResponse {
if resp == nil { if resp == nil {
return nil return nil
@@ -710,7 +643,6 @@ func ExclusiveSummaryPeriodModelToContract(resp *models.ExclusiveSummaryPeriodRe
return &contract.ExclusiveSummaryPeriodResponse{ return &contract.ExclusiveSummaryPeriodResponse{
OrganizationID: resp.OrganizationID, OrganizationID: resp.OrganizationID,
OutletID: resp.OutletID, OutletID: resp.OutletID,
OutletName: resp.OutletName,
Period: contract.ExclusiveSummaryPeriodRange{ Period: contract.ExclusiveSummaryPeriodRange{
DateFrom: resp.Period.DateFrom, DateFrom: resp.Period.DateFrom,
DateTo: resp.Period.DateTo, DateTo: resp.Period.DateTo,
@@ -773,7 +705,6 @@ func ExclusiveSummaryMonthlyModelToContract(resp *models.ExclusiveSummaryMonthly
return &contract.ExclusiveSummaryMonthlyResponse{ return &contract.ExclusiveSummaryMonthlyResponse{
OrganizationID: resp.OrganizationID, OrganizationID: resp.OrganizationID,
OutletID: resp.OutletID, OutletID: resp.OutletID,
OutletName: resp.OutletName,
Month: resp.Month, Month: resp.Month,
Summary: contract.ExclusiveSummaryMonthlySummary{ Summary: contract.ExclusiveSummaryMonthlySummary{
TotalSales: resp.Summary.TotalSales, TotalSales: resp.Summary.TotalSales,
@@ -822,18 +753,18 @@ func parseISODateToJakartaTime(dateStr string, endOfDay bool) (*time.Time, error
return nil, nil return nil, nil
} }
date, err := time.Parse("2006-01-02", dateStr)
if err != nil {
return nil, err
}
location, err := time.LoadLocation("Asia/Jakarta") location, err := time.LoadLocation("Asia/Jakarta")
if err != nil { if err != nil {
return nil, err return nil, err
} }
date, err := time.ParseInLocation("2006-01-02", dateStr, location)
if err != nil {
return nil, err
}
if endOfDay { if endOfDay {
result := time.Date(date.Year(), date.Month(), date.Day(), 23, 59, 59, 999999999, location) result := time.Date(date.Year(), date.Month(), date.Day(), 23, 59, 59, int(time.Second-time.Nanosecond), location)
return &result, nil return &result, nil
} }
@@ -841,22 +772,6 @@ func parseISODateToJakartaTime(dateStr string, endOfDay bool) (*time.Time, error
return &result, nil 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) { func parseMonthToJakartaTime(month string) (time.Time, error) {
location, err := time.LoadLocation("Asia/Jakarta") location, err := time.LoadLocation("Asia/Jakarta")
if err != nil { if err != nil {
@@ -105,40 +105,6 @@ func TestPurchasingAnalyticsModelToContractOmitsNilOutletName(t *testing.T) {
require.NotContains(t, string(payload), "outlet_name") 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) { func TestProfitLossAnalyticsContractToModelParsesDateRange(t *testing.T) {
orgID := uuid.New() orgID := uuid.New()
outletID := uuid.New().String() outletID := uuid.New().String()
@@ -217,7 +183,7 @@ func TestProfitLossAnalyticsModelToContractCopiesDateRange(t *testing.T) {
require.Equal(t, "total_omset", result.MainSummary[0].ID) require.Equal(t, "total_omset", result.MainSummary[0].ID)
} }
func TestExclusiveSummaryPeriodContractToModelParsesFlexibleDates(t *testing.T) { func TestExclusiveSummaryPeriodContractToModelParsesISODateRange(t *testing.T) {
orgID := uuid.New() orgID := uuid.New()
outletID := uuid.New().String() outletID := uuid.New().String()
@@ -243,16 +209,50 @@ func TestExclusiveSummaryPeriodContractToModelParsesFlexibleDates(t *testing.T)
func TestExclusiveSummaryMonthlyContractToModelParsesMonth(t *testing.T) { func TestExclusiveSummaryMonthlyContractToModelParsesMonth(t *testing.T) {
orgID := uuid.New() orgID := uuid.New()
outletID := uuid.New().String()
result, err := ExclusiveSummaryMonthlyContractToModel(&contract.ExclusiveSummaryMonthlyRequest{ result, err := ExclusiveSummaryMonthlyContractToModel(&contract.ExclusiveSummaryMonthlyRequest{
OrganizationID: orgID, OrganizationID: orgID,
OutletID: &outletID,
Month: "2026-05", Month: "2026-05",
}) })
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, orgID, result.OrganizationID) require.Equal(t, orgID, result.OrganizationID)
require.NotNil(t, result.OutletID)
require.Equal(t, outletID, result.OutletID.String())
location, err := time.LoadLocation("Asia/Jakarta") location, err := time.LoadLocation("Asia/Jakarta")
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, time.Date(2026, 5, 1, 0, 0, 0, 0, location), result.Month) require.Equal(t, time.Date(2026, 5, 1, 0, 0, 0, 0, location), result.Month)
} }
func TestExclusiveSummaryPeriodModelToContractCopiesBreakdowns(t *testing.T) {
dateFrom := time.Date(2026, 5, 26, 0, 0, 0, 0, time.UTC)
dateTo := time.Date(2026, 5, 31, 23, 59, 59, int(time.Second-time.Nanosecond), time.UTC)
result := ExclusiveSummaryPeriodModelToContract(&models.ExclusiveSummaryPeriodResponse{
OrganizationID: uuid.New(),
Period: models.ExclusiveSummaryPeriodRange{
DateFrom: dateFrom,
DateTo: dateTo,
},
Summary: models.ExclusiveSummaryPeriodSummary{
Sales: 1000,
HPP: 400,
TotalCost: 550,
NetProfit: 450,
SalaryStaff: 100,
},
HPPBreakdown: []models.ExclusiveSummaryCategoryBreakdown{
{CategoryCode: "hpp", CategoryName: "HPP", Amount: 400, Percentage: 100},
},
})
require.NotNil(t, result)
require.Equal(t, dateFrom, result.Period.DateFrom)
require.Equal(t, dateTo, result.Period.DateTo)
require.Equal(t, float64(1000), result.Summary.Sales)
require.Len(t, result.HPPBreakdown, 1)
require.Equal(t, "hpp", result.HPPBreakdown[0].CategoryCode)
}
@@ -120,7 +120,6 @@ func PurchaseOrderModelResponseToResponse(po *models.PurchaseOrderResponse) *con
response := &contract.PurchaseOrderResponse{ response := &contract.PurchaseOrderResponse{
ID: po.ID, ID: po.ID,
OrganizationID: po.OrganizationID, OrganizationID: po.OrganizationID,
OutletID: po.OutletID,
VendorID: po.VendorID, VendorID: po.VendorID,
PONumber: po.PONumber, PONumber: po.PONumber,
TransactionDate: po.TransactionDate, TransactionDate: po.TransactionDate,
@@ -12,13 +12,12 @@ import (
) )
func TestCreatePurchaseOrderRequestToModelAllowsMissingDueDate(t *testing.T) { func TestCreatePurchaseOrderRequestToModelAllowsMissingDueDate(t *testing.T) {
vendorID := uuid.New()
ingredientID := uuid.New() ingredientID := uuid.New()
quantity := 1.0 quantity := 1.0
unitID := uuid.New() unitID := uuid.New()
result, err := CreatePurchaseOrderRequestToModel(&contract.CreatePurchaseOrderRequest{ result, err := CreatePurchaseOrderRequestToModel(&contract.CreatePurchaseOrderRequest{
VendorID: &vendorID, VendorID: uuid.New(),
PONumber: "PO-001", PONumber: "PO-001",
TransactionDate: "2026-05-29", TransactionDate: "2026-05-29",
Items: []contract.CreatePurchaseOrderItemRequest{ Items: []contract.CreatePurchaseOrderItemRequest{
@@ -36,10 +35,9 @@ func TestCreatePurchaseOrderRequestToModelAllowsMissingDueDate(t *testing.T) {
} }
func TestPurchaseOrderModelResponseToResponseIncludesNullDueDate(t *testing.T) { func TestPurchaseOrderModelResponseToResponseIncludesNullDueDate(t *testing.T) {
vendorID := uuid.New()
result := PurchaseOrderModelResponseToResponse(&models.PurchaseOrderResponse{ result := PurchaseOrderModelResponseToResponse(&models.PurchaseOrderResponse{
ID: uuid.New(), ID: uuid.New(),
VendorID: &vendorID, VendorID: uuid.New(),
PONumber: "PO-001", PONumber: "PO-001",
}) })
@@ -47,19 +45,3 @@ func TestPurchaseOrderModelResponseToResponseIncludesNullDueDate(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.Contains(t, string(payload), `"due_date":null`) 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)
}
@@ -29,8 +29,8 @@ func (v *PurchaseOrderValidatorImpl) ValidateCreatePurchaseOrderRequest(req *con
return errors.New("request body is required"), constants.MissingFieldErrorCode return errors.New("request body is required"), constants.MissingFieldErrorCode
} }
if req.VendorID != nil && *req.VendorID == uuid.Nil { if req.VendorID == uuid.Nil {
return errors.New("vendor_id cannot be empty"), constants.MalformedFieldErrorCode return errors.New("vendor_id is required"), constants.MissingFieldErrorCode
} }
if strings.TrimSpace(req.PONumber) == "" { if strings.TrimSpace(req.PONumber) == "" {
@@ -11,13 +11,12 @@ import (
) )
func validCreatePurchaseOrderRequest() *contract.CreatePurchaseOrderRequest { func validCreatePurchaseOrderRequest() *contract.CreatePurchaseOrderRequest {
vendorID := uuid.New()
ingredientID := uuid.New() ingredientID := uuid.New()
quantity := 1.0 quantity := 1.0
unitID := uuid.New() unitID := uuid.New()
return &contract.CreatePurchaseOrderRequest{ return &contract.CreatePurchaseOrderRequest{
VendorID: &vendorID, VendorID: uuid.New(),
PONumber: "PO-001", PONumber: "PO-001",
TransactionDate: "2026-05-29", TransactionDate: "2026-05-29",
Items: []contract.CreatePurchaseOrderItemRequest{ Items: []contract.CreatePurchaseOrderItemRequest{
@@ -32,30 +31,6 @@ func validCreatePurchaseOrderRequest() *contract.CreatePurchaseOrderRequest {
} }
} }
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) { func TestPurchaseOrderValidatorCreateAllowsMissingDueDate(t *testing.T) {
validator := NewPurchaseOrderValidator() validator := NewPurchaseOrderValidator()
-2
View File
@@ -144,8 +144,6 @@ func isValidUserRole(role string) bool {
string(constants.RoleManager): true, string(constants.RoleManager): true,
string(constants.RoleCashier): true, string(constants.RoleCashier): true,
string(constants.RoleWaiter): true, string(constants.RoleWaiter): true,
string(constants.RoleOwner): true,
string(constants.RolePurchasing): true,
} }
return validRoles[role] return validRoles[role]
} }
@@ -1,2 +0,0 @@
ALTER TABLE purchase_orders
ALTER COLUMN vendor_id SET NOT NULL;
@@ -1,2 +0,0 @@
ALTER TABLE purchase_orders
ALTER COLUMN vendor_id DROP NOT NULL;
@@ -1,4 +0,0 @@
DROP INDEX IF EXISTS idx_purchase_orders_outlet_id;
ALTER TABLE purchase_orders
DROP COLUMN IF EXISTS outlet_id;
@@ -1,66 +0,0 @@
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;
@@ -1,4 +0,0 @@
-- 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'));
@@ -1,4 +0,0 @@
-- 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
@@ -1,394 +0,0 @@
<!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>