Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbc65c5606 | ||
|
|
9b0fc9a63b | ||
|
|
793919cf10 | ||
|
|
25024c210a | ||
|
|
3977370079 | ||
|
|
37bcb90ab0 | ||
|
|
e345aeee97 | ||
|
|
486d94335b | ||
|
|
7d5acb33e8 | ||
|
|
2138b44c53 | ||
|
|
503fb5734f | ||
|
|
ac06a4bbe9 | ||
|
|
87540fa1b7 | ||
|
|
66d4c9f0af | ||
|
|
55119b3e91 | ||
|
|
67a5c076e7 | ||
|
|
c1d859ebdd | ||
|
|
7a2060efdc | ||
|
|
2ad9e2f85f | ||
|
|
a8d62bc5e8 | ||
|
|
8816e4addc | ||
|
|
2921631ac3 | ||
|
|
0db838e2c4 | ||
|
|
4b6cbb69c1 | ||
|
|
9e0ba0ce56 | ||
|
|
6c19876a47 | ||
|
|
b2db56f855 | ||
|
|
8c4d9c69d0 | ||
|
|
657a201fc0 |
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
@@ -351,6 +351,8 @@ github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1
|
|||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/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=
|
||||||
@@ -380,7 +382,6 @@ go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J
|
|||||||
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
go.uber.org/atomic v1.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=
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ package constants
|
|||||||
type UserRole string
|
type UserRole string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
RoleAdmin UserRole = "admin"
|
RoleAdmin UserRole = "admin"
|
||||||
RoleManager UserRole = "manager"
|
RoleManager UserRole = "manager"
|
||||||
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 {
|
||||||
@@ -17,6 +18,7 @@ func GetAllUserRoles() []UserRole {
|
|||||||
RoleCashier,
|
RoleCashier,
|
||||||
RoleWaiter,
|
RoleWaiter,
|
||||||
RoleOwner,
|
RoleOwner,
|
||||||
|
RolePurchasing,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ 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"`
|
||||||
@@ -54,6 +55,7 @@ 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"`
|
||||||
@@ -88,7 +90,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"`
|
GroupBy string `form:"group_by,default=day" validate:"omitempty,oneof=day hour week month outlet_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PurchasingAnalyticsResponse struct {
|
type PurchasingAnalyticsResponse struct {
|
||||||
@@ -100,6 +102,7 @@ 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"`
|
||||||
}
|
}
|
||||||
@@ -130,6 +133,20 @@ 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"`
|
||||||
@@ -140,12 +157,12 @@ 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"`
|
||||||
IngredientCount int64 `json:"ingredient_count"`
|
IngredientCount int64 `json:"ingredient_count"`
|
||||||
Quantity float64 `json:"quantity"`
|
Quantity float64 `json:"quantity"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProductAnalyticsRequest represents the request for product analytics
|
// ProductAnalyticsRequest represents the request for product analytics
|
||||||
@@ -161,6 +178,7 @@ 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"`
|
||||||
@@ -198,6 +216,7 @@ 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"`
|
||||||
@@ -227,6 +246,7 @@ 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"`
|
||||||
@@ -237,12 +257,15 @@ type DashboardAnalyticsResponse struct {
|
|||||||
|
|
||||||
// DashboardOverview represents the overview data for dashboard
|
// DashboardOverview represents the overview data for dashboard
|
||||||
type DashboardOverview struct {
|
type DashboardOverview struct {
|
||||||
TotalSales float64 `json:"total_sales"`
|
TotalSales float64 `json:"total_sales"`
|
||||||
TotalOrders int64 `json:"total_orders"`
|
TotalOrders int64 `json:"total_orders"`
|
||||||
AverageOrderValue float64 `json:"average_order_value"`
|
AverageOrderValue float64 `json:"average_order_value"`
|
||||||
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 {
|
||||||
@@ -256,6 +279,7 @@ 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"`
|
||||||
@@ -263,10 +287,28 @@ 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"`
|
||||||
@@ -324,3 +366,123 @@ type OperationalExpenseItem struct {
|
|||||||
Item string `json:"item"`
|
Item string `json:"item"`
|
||||||
Nominal float64 `json:"nominal"`
|
Nominal float64 `json:"nominal"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryPeriodRequest struct {
|
||||||
|
OrganizationID uuid.UUID
|
||||||
|
OutletID *string `form:"outlet_id,omitempty"`
|
||||||
|
DateFrom string `form:"date_from" validate:"required"`
|
||||||
|
DateTo string `form:"date_to" validate:"required"`
|
||||||
|
ExcludeGajiStaffFromReimburse bool `form:"exclude_gaji_staff_from_reimburse"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryMonthlyRequest struct {
|
||||||
|
OrganizationID uuid.UUID
|
||||||
|
OutletID *string `form:"outlet_id,omitempty"`
|
||||||
|
Month string `form:"month" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryMTDRequest struct {
|
||||||
|
OrganizationID uuid.UUID
|
||||||
|
OutletID *string `form:"outlet_id,omitempty"`
|
||||||
|
DateTo string `form:"date_to" validate:"required"`
|
||||||
|
ExcludeGajiStaffFromReimburse bool `form:"exclude_gaji_staff_from_reimburse"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryPeriodResponse struct {
|
||||||
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
|
OutletName *string `json:"outlet_name,omitempty"`
|
||||||
|
Period ExclusiveSummaryPeriodRange `json:"period"`
|
||||||
|
Summary ExclusiveSummaryPeriodSummary `json:"summary"`
|
||||||
|
Reimburse ExclusiveSummaryReimburse `json:"reimburse"`
|
||||||
|
HPPBreakdown []ExclusiveSummaryCategoryBreakdown `json:"hpp_breakdown"`
|
||||||
|
OperationalExpenseBreakdown []ExclusiveSummaryCategoryBreakdown `json:"operational_expense_breakdown"`
|
||||||
|
DailySummary []ExclusiveSummaryDailySummary `json:"daily_summary"`
|
||||||
|
DailyTransactions []ExclusiveSummaryDailyTransaction `json:"daily_transactions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryPeriodRange struct {
|
||||||
|
DateFrom time.Time `json:"date_from"`
|
||||||
|
DateTo time.Time `json:"date_to"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryPeriodSummary struct {
|
||||||
|
Sales float64 `json:"sales"`
|
||||||
|
HPP float64 `json:"hpp"`
|
||||||
|
GrossProfit float64 `json:"gross_profit"`
|
||||||
|
SalaryTotal float64 `json:"salary_total"`
|
||||||
|
SalaryDW float64 `json:"salary_dw"`
|
||||||
|
SalaryStaff float64 `json:"salary_staff"`
|
||||||
|
SalaryOther float64 `json:"salary_other"`
|
||||||
|
OtherOperationalExpenses float64 `json:"other_operational_expenses"`
|
||||||
|
OperationalExpensesTotal float64 `json:"operational_expenses_total"`
|
||||||
|
TotalCost float64 `json:"total_cost"`
|
||||||
|
NetProfit float64 `json:"net_profit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryReimburse struct {
|
||||||
|
TotalCost float64 `json:"total_cost"`
|
||||||
|
ExcludedSalaryStaff float64 `json:"excluded_salary_staff"`
|
||||||
|
TotalReimburse float64 `json:"total_reimburse"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryCategoryBreakdown struct {
|
||||||
|
CategoryCode string `json:"category_code"`
|
||||||
|
CategoryName string `json:"category_name"`
|
||||||
|
Amount float64 `json:"amount"`
|
||||||
|
Percentage float64 `json:"percentage"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryDailySummary struct {
|
||||||
|
Date time.Time `json:"date"`
|
||||||
|
TransactionCount int64 `json:"transaction_count"`
|
||||||
|
TotalCost float64 `json:"total_cost"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryDailyTransaction struct {
|
||||||
|
Date time.Time `json:"date"`
|
||||||
|
CategoryCode string `json:"category_code"`
|
||||||
|
CategoryName string `json:"category_name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Amount float64 `json:"amount"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryMonthlyResponse struct {
|
||||||
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
|
OutletName *string `json:"outlet_name,omitempty"`
|
||||||
|
Month string `json:"month"`
|
||||||
|
Summary ExclusiveSummaryMonthlySummary `json:"summary"`
|
||||||
|
Periods []ExclusiveSummaryMonthlyPeriod `json:"periods"`
|
||||||
|
BankBalance []ExclusiveSummaryBankBalance `json:"bank_balance"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryMonthlySummary struct {
|
||||||
|
TotalSales float64 `json:"total_sales"`
|
||||||
|
HPP float64 `json:"hpp"`
|
||||||
|
GrossProfit float64 `json:"gross_profit"`
|
||||||
|
OperationalExpensesTotal float64 `json:"operational_expenses_total"`
|
||||||
|
TotalCost float64 `json:"total_cost"`
|
||||||
|
NetProfit float64 `json:"net_profit"`
|
||||||
|
NetProfitMargin float64 `json:"net_profit_margin"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryMonthlyPeriod struct {
|
||||||
|
Label string `json:"label"`
|
||||||
|
DateFrom time.Time `json:"date_from"`
|
||||||
|
DateTo time.Time `json:"date_to"`
|
||||||
|
Sales float64 `json:"sales"`
|
||||||
|
HPP float64 `json:"hpp"`
|
||||||
|
GrossProfit float64 `json:"gross_profit"`
|
||||||
|
GrossMargin float64 `json:"gross_margin"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryBankBalance struct {
|
||||||
|
Bank string `json:"bank"`
|
||||||
|
OpeningBalance *float64 `json:"opening_balance"`
|
||||||
|
IncomingMutation *float64 `json:"incoming_mutation"`
|
||||||
|
OutgoingMutation *float64 `json:"outgoing_mutation"`
|
||||||
|
ClosingBalance *float64 `json:"closing_balance"`
|
||||||
|
Notes *string `json:"notes"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type CreatePurchaseOrderRequest struct {
|
type CreatePurchaseOrderRequest struct {
|
||||||
VendorID uuid.UUID `json:"vendor_id" validate:"required"`
|
VendorID *uuid.UUID `json:"vendor_id,omitempty" validate:"omitempty"`
|
||||||
PONumber string `json:"po_number" validate:"required,min=1,max=50"`
|
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,7 +52,8 @@ 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"`
|
||||||
VendorID uuid.UUID `json:"vendor_id"`
|
OutletID *uuid.UUID `json:"outlet_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"`
|
||||||
|
|||||||
@@ -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"`
|
Role string `json:"role" validate:"required,oneof=admin manager cashier waiter owner purchasing"`
|
||||||
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"`
|
Role *string `json:"role,omitempty" validate:"omitempty,oneof=admin manager cashier waiter owner purchasing"`
|
||||||
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"`
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ 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"`
|
||||||
}
|
}
|
||||||
@@ -62,6 +63,20 @@ 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"`
|
||||||
@@ -72,12 +87,12 @@ 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"`
|
||||||
IngredientCount int64 `json:"ingredient_count"`
|
IngredientCount int64 `json:"ingredient_count"`
|
||||||
Quantity float64 `json:"quantity"`
|
Quantity float64 `json:"quantity"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProductAnalytics struct {
|
type ProductAnalytics struct {
|
||||||
@@ -114,25 +129,42 @@ type ProductAnalyticsPerCategory struct {
|
|||||||
|
|
||||||
// DashboardOverview represents dashboard overview data
|
// DashboardOverview represents dashboard overview data
|
||||||
type DashboardOverview struct {
|
type DashboardOverview struct {
|
||||||
TotalSales float64 `json:"total_sales"`
|
TotalSales float64 `json:"total_sales"`
|
||||||
TotalOrders int64 `json:"total_orders"`
|
TotalOrders int64 `json:"total_orders"`
|
||||||
AverageOrderValue float64 `json:"average_order_value"`
|
AverageOrderValue float64 `json:"average_order_value"`
|
||||||
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 {
|
||||||
Summary ProfitLossSummary
|
Summary ProfitLossSummary
|
||||||
Data []ProfitLossData
|
Data []ProfitLossData
|
||||||
ProductData []ProductProfitData
|
ProductData []ProductProfitData
|
||||||
TodayRevenue float64
|
TodayRevenue float64
|
||||||
TodayCost float64
|
TodayCost float64
|
||||||
MtdRevenue float64
|
MtdRevenue float64
|
||||||
MtdCost float64
|
MtdCost float64
|
||||||
TodayExpenseByCategory []ExpenseCategoryTotal
|
TodayPurchasing float64
|
||||||
MtdExpenseByCategory []ExpenseCategoryTotal
|
MtdPurchasing float64
|
||||||
OperationalExpenseItems []OperationalExpenseItem
|
TodayPurchasingRawMaterial float64
|
||||||
|
MtdPurchasingRawMaterial float64
|
||||||
|
TodayPurchasingExpense float64
|
||||||
|
MtdPurchasingExpense float64
|
||||||
|
PurchasingItems []PurchasingItemDetail
|
||||||
|
TodayExpenseByCategory []ExpenseCategoryTotal
|
||||||
|
MtdExpenseByCategory []ExpenseCategoryTotal
|
||||||
|
OperationalExpenseItems []OperationalExpenseItem
|
||||||
|
}
|
||||||
|
|
||||||
|
type PurchasingItemDetail struct {
|
||||||
|
Date time.Time
|
||||||
|
Item string
|
||||||
|
Quantity float64
|
||||||
|
Amount float64
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProfitLossSummary struct {
|
type ProfitLossSummary struct {
|
||||||
@@ -186,3 +218,42 @@ type OperationalExpenseItem struct {
|
|||||||
Item string
|
Item string
|
||||||
Amount float64
|
Amount float64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryAnalytics struct {
|
||||||
|
SalesTotal float64
|
||||||
|
SalesCount int64
|
||||||
|
HPPBreakdown []ExclusiveSummaryCategoryTotal
|
||||||
|
OperationalExpenseBreakdown []ExclusiveSummaryCategoryTotal
|
||||||
|
DailySummary []ExclusiveSummaryDailySummary
|
||||||
|
DailyTransactions []ExclusiveSummaryDailyTransaction
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryCategoryTotal struct {
|
||||||
|
CategoryCode string
|
||||||
|
CategoryName string
|
||||||
|
Amount float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryDailySummary struct {
|
||||||
|
Date time.Time
|
||||||
|
TransactionCount int64
|
||||||
|
TotalCost float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryDailyTransaction struct {
|
||||||
|
Date time.Time
|
||||||
|
CategoryCode string
|
||||||
|
CategoryName string
|
||||||
|
Description string
|
||||||
|
Amount float64
|
||||||
|
Source string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryBankBalance struct {
|
||||||
|
Bank string
|
||||||
|
OpeningBalance *float64
|
||||||
|
IncomingMutation *float64
|
||||||
|
OutgoingMutation *float64
|
||||||
|
ClosingBalance *float64
|
||||||
|
Notes *string
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ 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"`
|
||||||
VendorID uuid.UUID `gorm:"type:uuid;not null" json:"vendor_id" validate:"required"`
|
OutletID *uuid.UUID `gorm:"type:uuid;index" json:"outlet_id" validate:"omitempty"`
|
||||||
|
VendorID *uuid.UUID `gorm:"type:uuid" json:"vendor_id" validate:"omitempty"`
|
||||||
PONumber string `gorm:"not null;size:50" json:"po_number" validate:"required,min=1,max=50"`
|
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"`
|
||||||
@@ -23,6 +24,7 @@ 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"`
|
||||||
|
|||||||
@@ -13,10 +13,12 @@ import (
|
|||||||
type UserRole string
|
type UserRole string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
RoleAdmin UserRole = "admin"
|
RoleAdmin UserRole = "admin"
|
||||||
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{}
|
||||||
@@ -46,7 +48,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"`
|
Role UserRole `gorm:"not null;size:50" json:"role" validate:"required,oneof=admin manager cashier waiter owner purchasing"`
|
||||||
Permissions Permissions `gorm:"type:jsonb;default:'{}'" json:"permissions"`
|
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"`
|
||||||
|
|||||||
@@ -210,3 +210,87 @@ func (h *AnalyticsHandler) GetProfitLossAnalytics(c *gin.Context) {
|
|||||||
contractResp := transformer.ProfitLossAnalyticsModelToContract(response)
|
contractResp := transformer.ProfitLossAnalyticsModelToContract(response)
|
||||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetProfitLossAnalytics")
|
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetProfitLossAnalytics")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *AnalyticsHandler) GetExclusiveSummaryPeriod(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
contextInfo := appcontext.FromGinContext(ctx)
|
||||||
|
|
||||||
|
var req contract.ExclusiveSummaryPeriodRequest
|
||||||
|
if err := c.ShouldBindQuery(&req); err != nil {
|
||||||
|
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetExclusiveSummaryPeriod", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryPeriod")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req.OrganizationID = contextInfo.OrganizationID
|
||||||
|
req.OutletID = h.resolveOutletID(c, contextInfo.OutletID)
|
||||||
|
modelReq, err := transformer.ExclusiveSummaryPeriodContractToModel(&req)
|
||||||
|
if err != nil {
|
||||||
|
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetExclusiveSummaryPeriod", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryPeriod")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := h.analyticsService.GetExclusiveSummaryPeriod(ctx, modelReq)
|
||||||
|
if err != nil {
|
||||||
|
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "AnalyticsHandler::GetExclusiveSummaryPeriod", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryPeriod")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
contractResp := transformer.ExclusiveSummaryPeriodModelToContract(response)
|
||||||
|
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetExclusiveSummaryPeriod")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AnalyticsHandler) GetExclusiveSummaryMonthly(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
contextInfo := appcontext.FromGinContext(ctx)
|
||||||
|
|
||||||
|
var req contract.ExclusiveSummaryMonthlyRequest
|
||||||
|
if err := c.ShouldBindQuery(&req); err != nil {
|
||||||
|
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetExclusiveSummaryMonthly", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryMonthly")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req.OrganizationID = contextInfo.OrganizationID
|
||||||
|
req.OutletID = h.resolveOutletID(c, contextInfo.OutletID)
|
||||||
|
modelReq, err := transformer.ExclusiveSummaryMonthlyContractToModel(&req)
|
||||||
|
if err != nil {
|
||||||
|
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetExclusiveSummaryMonthly", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryMonthly")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := h.analyticsService.GetExclusiveSummaryMonthly(ctx, modelReq)
|
||||||
|
if err != nil {
|
||||||
|
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "AnalyticsHandler::GetExclusiveSummaryMonthly", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryMonthly")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
contractResp := transformer.ExclusiveSummaryMonthlyModelToContract(response)
|
||||||
|
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetExclusiveSummaryMonthly")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AnalyticsHandler) GetExclusiveSummaryMTD(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
contextInfo := appcontext.FromGinContext(ctx)
|
||||||
|
|
||||||
|
var req contract.ExclusiveSummaryMTDRequest
|
||||||
|
if err := c.ShouldBindQuery(&req); err != nil {
|
||||||
|
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetExclusiveSummaryMTD", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryMTD")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req.OrganizationID = contextInfo.OrganizationID
|
||||||
|
req.OutletID = h.resolveOutletID(c, contextInfo.OutletID)
|
||||||
|
modelReq, err := transformer.ExclusiveSummaryMTDContractToModel(&req)
|
||||||
|
if err != nil {
|
||||||
|
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetExclusiveSummaryMTD", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryMTD")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := h.analyticsService.GetExclusiveSummaryMTD(ctx, modelReq)
|
||||||
|
if err != nil {
|
||||||
|
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "AnalyticsHandler::GetExclusiveSummaryMTD", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryMTD")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
contractResp := transformer.ExclusiveSummaryPeriodModelToContract(response)
|
||||||
|
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetExclusiveSummaryMTD")
|
||||||
|
}
|
||||||
|
|||||||
@@ -66,3 +66,35 @@ 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,6 +13,7 @@ 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,
|
||||||
@@ -34,6 +35,7 @@ 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,
|
||||||
@@ -55,6 +57,7 @@ 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,
|
||||||
|
|||||||
@@ -82,7 +82,11 @@ 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")
|
return m.RequireRole("superadmin", "admin", "manager", "owner", "purchasing")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *AuthMiddleware) RequireAdminOrManagerOrPurchasing() gin.HandlerFunc {
|
||||||
|
return m.RequireRole("superadmin", "admin", "manager", "owner", "purchasing")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *AuthMiddleware) RequireAdmin() gin.HandlerFunc {
|
func (m *AuthMiddleware) RequireAdmin() gin.HandlerFunc {
|
||||||
|
|||||||
+175
-13
@@ -19,6 +19,7 @@ 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"`
|
||||||
@@ -58,6 +59,7 @@ 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"`
|
||||||
@@ -93,7 +95,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"`
|
GroupBy string `validate:"omitempty,oneof=day hour week month outlet_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PurchasingAnalyticsResponse represents the response for purchasing analytics
|
// PurchasingAnalyticsResponse represents the response for purchasing analytics
|
||||||
@@ -106,6 +108,7 @@ 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"`
|
||||||
}
|
}
|
||||||
@@ -138,6 +141,20 @@ 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"`
|
||||||
@@ -150,12 +167,12 @@ 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"`
|
||||||
IngredientCount int64 `json:"ingredient_count"`
|
IngredientCount int64 `json:"ingredient_count"`
|
||||||
Quantity float64 `json:"quantity"`
|
Quantity float64 `json:"quantity"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProductAnalyticsRequest represents the request for product analytics
|
// ProductAnalyticsRequest represents the request for product analytics
|
||||||
@@ -171,6 +188,7 @@ 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"`
|
||||||
@@ -208,6 +226,7 @@ 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"`
|
||||||
@@ -237,6 +256,7 @@ 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"`
|
||||||
@@ -247,12 +267,15 @@ type DashboardAnalyticsResponse struct {
|
|||||||
|
|
||||||
// DashboardOverview represents the overview data for dashboard
|
// DashboardOverview represents the overview data for dashboard
|
||||||
type DashboardOverview struct {
|
type DashboardOverview struct {
|
||||||
TotalSales float64 `json:"total_sales"`
|
TotalSales float64 `json:"total_sales"`
|
||||||
TotalOrders int64 `json:"total_orders"`
|
TotalOrders int64 `json:"total_orders"`
|
||||||
AverageOrderValue float64 `json:"average_order_value"`
|
AverageOrderValue float64 `json:"average_order_value"`
|
||||||
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 {
|
||||||
@@ -266,6 +289,7 @@ 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"`
|
||||||
@@ -273,10 +297,28 @@ 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"`
|
||||||
@@ -334,3 +376,123 @@ type OperationalExpenseItem struct {
|
|||||||
Item string `json:"item"`
|
Item string `json:"item"`
|
||||||
Nominal float64 `json:"nominal"`
|
Nominal float64 `json:"nominal"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryPeriodRequest struct {
|
||||||
|
OrganizationID uuid.UUID `validate:"required"`
|
||||||
|
OutletID *uuid.UUID `validate:"omitempty"`
|
||||||
|
DateFrom time.Time `validate:"required"`
|
||||||
|
DateTo time.Time `validate:"required"`
|
||||||
|
ExcludeGajiStaffFromReimburse bool `validate:"omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryMonthlyRequest struct {
|
||||||
|
OrganizationID uuid.UUID `validate:"required"`
|
||||||
|
OutletID *uuid.UUID `validate:"omitempty"`
|
||||||
|
Month time.Time `validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryMTDRequest struct {
|
||||||
|
OrganizationID uuid.UUID `validate:"required"`
|
||||||
|
OutletID *uuid.UUID `validate:"omitempty"`
|
||||||
|
DateTo time.Time `validate:"required"`
|
||||||
|
ExcludeGajiStaffFromReimburse bool `validate:"omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryPeriodResponse struct {
|
||||||
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
|
OutletName *string `json:"outlet_name,omitempty"`
|
||||||
|
Period ExclusiveSummaryPeriodRange `json:"period"`
|
||||||
|
Summary ExclusiveSummaryPeriodSummary `json:"summary"`
|
||||||
|
Reimburse ExclusiveSummaryReimburse `json:"reimburse"`
|
||||||
|
HPPBreakdown []ExclusiveSummaryCategoryBreakdown `json:"hpp_breakdown"`
|
||||||
|
OperationalExpenseBreakdown []ExclusiveSummaryCategoryBreakdown `json:"operational_expense_breakdown"`
|
||||||
|
DailySummary []ExclusiveSummaryDailySummary `json:"daily_summary"`
|
||||||
|
DailyTransactions []ExclusiveSummaryDailyTransaction `json:"daily_transactions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryPeriodRange struct {
|
||||||
|
DateFrom time.Time `json:"date_from"`
|
||||||
|
DateTo time.Time `json:"date_to"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryPeriodSummary struct {
|
||||||
|
Sales float64 `json:"sales"`
|
||||||
|
HPP float64 `json:"hpp"`
|
||||||
|
GrossProfit float64 `json:"gross_profit"`
|
||||||
|
SalaryTotal float64 `json:"salary_total"`
|
||||||
|
SalaryDW float64 `json:"salary_dw"`
|
||||||
|
SalaryStaff float64 `json:"salary_staff"`
|
||||||
|
SalaryOther float64 `json:"salary_other"`
|
||||||
|
OtherOperationalExpenses float64 `json:"other_operational_expenses"`
|
||||||
|
OperationalExpensesTotal float64 `json:"operational_expenses_total"`
|
||||||
|
TotalCost float64 `json:"total_cost"`
|
||||||
|
NetProfit float64 `json:"net_profit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryReimburse struct {
|
||||||
|
TotalCost float64 `json:"total_cost"`
|
||||||
|
ExcludedSalaryStaff float64 `json:"excluded_salary_staff"`
|
||||||
|
TotalReimburse float64 `json:"total_reimburse"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryCategoryBreakdown struct {
|
||||||
|
CategoryCode string `json:"category_code"`
|
||||||
|
CategoryName string `json:"category_name"`
|
||||||
|
Amount float64 `json:"amount"`
|
||||||
|
Percentage float64 `json:"percentage"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryDailySummary struct {
|
||||||
|
Date time.Time `json:"date"`
|
||||||
|
TransactionCount int64 `json:"transaction_count"`
|
||||||
|
TotalCost float64 `json:"total_cost"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryDailyTransaction struct {
|
||||||
|
Date time.Time `json:"date"`
|
||||||
|
CategoryCode string `json:"category_code"`
|
||||||
|
CategoryName string `json:"category_name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Amount float64 `json:"amount"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryMonthlyResponse struct {
|
||||||
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
|
OutletName *string `json:"outlet_name,omitempty"`
|
||||||
|
Month string `json:"month"`
|
||||||
|
Summary ExclusiveSummaryMonthlySummary `json:"summary"`
|
||||||
|
Periods []ExclusiveSummaryMonthlyPeriod `json:"periods"`
|
||||||
|
BankBalance []ExclusiveSummaryBankBalance `json:"bank_balance"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryMonthlySummary struct {
|
||||||
|
TotalSales float64 `json:"total_sales"`
|
||||||
|
HPP float64 `json:"hpp"`
|
||||||
|
GrossProfit float64 `json:"gross_profit"`
|
||||||
|
OperationalExpensesTotal float64 `json:"operational_expenses_total"`
|
||||||
|
TotalCost float64 `json:"total_cost"`
|
||||||
|
NetProfit float64 `json:"net_profit"`
|
||||||
|
NetProfitMargin float64 `json:"net_profit_margin"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryMonthlyPeriod struct {
|
||||||
|
Label string `json:"label"`
|
||||||
|
DateFrom time.Time `json:"date_from"`
|
||||||
|
DateTo time.Time `json:"date_to"`
|
||||||
|
Sales float64 `json:"sales"`
|
||||||
|
HPP float64 `json:"hpp"`
|
||||||
|
GrossProfit float64 `json:"gross_profit"`
|
||||||
|
GrossMargin float64 `json:"gross_margin"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryBankBalance struct {
|
||||||
|
Bank string `json:"bank"`
|
||||||
|
OpeningBalance *float64 `json:"opening_balance"`
|
||||||
|
IncomingMutation *float64 `json:"incoming_mutation"`
|
||||||
|
OutgoingMutation *float64 `json:"outgoing_mutation"`
|
||||||
|
ClosingBalance *float64 `json:"closing_balance"`
|
||||||
|
Notes *string `json:"notes"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ 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"`
|
||||||
VendorID uuid.UUID `json:"vendor_id"`
|
OutletID *uuid.UUID `json:"outlet_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"`
|
||||||
@@ -44,7 +45,8 @@ 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"`
|
||||||
VendorID uuid.UUID `json:"vendor_id"`
|
OutletID *uuid.UUID `json:"outlet_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"`
|
||||||
@@ -84,7 +86,8 @@ type PurchaseOrderAttachmentResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type CreatePurchaseOrderRequest struct {
|
type CreatePurchaseOrderRequest struct {
|
||||||
VendorID uuid.UUID `json:"vendor_id"`
|
VendorID *uuid.UUID `json:"vendor_id,omitempty"`
|
||||||
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
PONumber string `json:"po_number"`
|
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"`
|
||||||
|
|||||||
@@ -63,10 +63,12 @@ type UserResponse struct {
|
|||||||
|
|
||||||
func (u *User) HasPermission(requiredRole constants.UserRole) bool {
|
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.RoleManager: 3,
|
constants.RolePurchasing: 3,
|
||||||
constants.RoleAdmin: 4,
|
constants.RoleManager: 4,
|
||||||
|
constants.RoleAdmin: 5,
|
||||||
|
constants.RoleOwner: 6,
|
||||||
}
|
}
|
||||||
|
|
||||||
userLevel := roleHierarchy[u.Role]
|
userLevel := roleHierarchy[u.Role]
|
||||||
|
|||||||
@@ -6,8 +6,11 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"apskel-pos-be/internal/entities"
|
||||||
"apskel-pos-be/internal/models"
|
"apskel-pos-be/internal/models"
|
||||||
"apskel-pos-be/internal/repository"
|
"apskel-pos-be/internal/repository"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AnalyticsProcessor interface {
|
type AnalyticsProcessor interface {
|
||||||
@@ -18,6 +21,9 @@ type AnalyticsProcessor interface {
|
|||||||
GetProductAnalyticsPerCategory(ctx context.Context, req *models.ProductAnalyticsPerCategoryRequest) (*models.ProductAnalyticsPerCategoryResponse, error)
|
GetProductAnalyticsPerCategory(ctx context.Context, req *models.ProductAnalyticsPerCategoryRequest) (*models.ProductAnalyticsPerCategoryResponse, error)
|
||||||
GetDashboardAnalytics(ctx context.Context, req *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error)
|
GetDashboardAnalytics(ctx context.Context, req *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error)
|
||||||
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)
|
||||||
|
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 {
|
||||||
@@ -32,6 +38,18 @@ func NewAnalyticsProcessorImpl(analyticsRepo repository.AnalyticsRepository, exp
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveOutletName fetches the outlet name from the database if outletID is provided
|
||||||
|
func (p *AnalyticsProcessorImpl) resolveOutletName(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) *string {
|
||||||
|
if outletID == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
name, err := p.analyticsRepo.GetOutletName(ctx, organizationID, *outletID)
|
||||||
|
if err != nil || name == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &name
|
||||||
|
}
|
||||||
|
|
||||||
func (p *AnalyticsProcessorImpl) GetPaymentMethodAnalytics(ctx context.Context, req *models.PaymentMethodAnalyticsRequest) (*models.PaymentMethodAnalyticsResponse, error) {
|
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")
|
||||||
@@ -86,6 +104,7 @@ 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,
|
||||||
@@ -160,6 +179,7 @@ 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,
|
||||||
@@ -198,6 +218,23 @@ 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{
|
||||||
@@ -242,6 +279,7 @@ 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
|
||||||
@@ -291,6 +329,7 @@ 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,
|
||||||
@@ -328,6 +367,7 @@ 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,
|
||||||
@@ -389,15 +429,19 @@ 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{
|
||||||
TotalSales: overview.TotalSales,
|
TotalSales: overview.TotalSales,
|
||||||
TotalOrders: overview.TotalOrders,
|
TotalOrders: overview.TotalOrders,
|
||||||
AverageOrderValue: overview.AverageOrderValue,
|
AverageOrderValue: overview.AverageOrderValue,
|
||||||
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,
|
||||||
@@ -600,9 +644,20 @@ 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,
|
||||||
@@ -619,9 +674,18 @@ func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req
|
|||||||
AverageProfit: result.Summary.AverageProfit,
|
AverageProfit: result.Summary.AverageProfit,
|
||||||
ProfitabilityRatio: result.Summary.ProfitabilityRatio,
|
ProfitabilityRatio: result.Summary.ProfitabilityRatio,
|
||||||
},
|
},
|
||||||
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
|
||||||
@@ -651,3 +715,280 @@ func slugify(s string) string {
|
|||||||
}
|
}
|
||||||
return string(result)
|
return string(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *AnalyticsProcessorImpl) GetExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error) {
|
||||||
|
if req.DateFrom.After(req.DateTo) {
|
||||||
|
return nil, fmt.Errorf("date_from cannot be after date_to")
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.buildExclusiveSummaryPeriod(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AnalyticsProcessorImpl) GetExclusiveSummaryMonthly(ctx context.Context, req *models.ExclusiveSummaryMonthlyRequest) (*models.ExclusiveSummaryMonthlyResponse, error) {
|
||||||
|
monthStart := time.Date(req.Month.Year(), req.Month.Month(), 1, 0, 0, 0, 0, req.Month.Location())
|
||||||
|
monthEnd := monthStart.AddDate(0, 1, 0).Add(-time.Nanosecond)
|
||||||
|
|
||||||
|
fullPeriod, err := p.buildExclusiveSummaryPeriod(ctx, &models.ExclusiveSummaryPeriodRequest{
|
||||||
|
OrganizationID: req.OrganizationID,
|
||||||
|
OutletID: req.OutletID,
|
||||||
|
DateFrom: monthStart,
|
||||||
|
DateTo: monthEnd,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
periods := make([]models.ExclusiveSummaryMonthlyPeriod, 0)
|
||||||
|
for _, bucket := range buildExclusiveSummaryMonthlyBuckets(monthStart) {
|
||||||
|
period, err := p.buildExclusiveSummaryPeriod(ctx, &models.ExclusiveSummaryPeriodRequest{
|
||||||
|
OrganizationID: req.OrganizationID,
|
||||||
|
OutletID: req.OutletID,
|
||||||
|
DateFrom: bucket.DateFrom,
|
||||||
|
DateTo: bucket.DateTo,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
periods = append(periods, models.ExclusiveSummaryMonthlyPeriod{
|
||||||
|
Label: bucket.Label,
|
||||||
|
DateFrom: bucket.DateFrom,
|
||||||
|
DateTo: bucket.DateTo,
|
||||||
|
Sales: period.Summary.Sales,
|
||||||
|
HPP: period.Summary.HPP,
|
||||||
|
GrossProfit: period.Summary.GrossProfit,
|
||||||
|
GrossMargin: percentage(period.Summary.GrossProfit, period.Summary.Sales),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
bankBalances, err := p.analyticsRepo.GetExclusiveSummaryBankBalances(ctx, req.OrganizationID, req.OutletID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get exclusive summary bank balances: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bankBalance := make([]models.ExclusiveSummaryBankBalance, len(bankBalances))
|
||||||
|
for i, item := range bankBalances {
|
||||||
|
bankBalance[i] = models.ExclusiveSummaryBankBalance{
|
||||||
|
Bank: item.Bank,
|
||||||
|
OpeningBalance: item.OpeningBalance,
|
||||||
|
IncomingMutation: item.IncomingMutation,
|
||||||
|
OutgoingMutation: item.OutgoingMutation,
|
||||||
|
ClosingBalance: item.ClosingBalance,
|
||||||
|
Notes: item.Notes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &models.ExclusiveSummaryMonthlyResponse{
|
||||||
|
OrganizationID: req.OrganizationID,
|
||||||
|
OutletID: req.OutletID,
|
||||||
|
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
|
||||||
|
Month: monthStart.Format("2006-01"),
|
||||||
|
Summary: models.ExclusiveSummaryMonthlySummary{
|
||||||
|
TotalSales: fullPeriod.Summary.Sales,
|
||||||
|
HPP: fullPeriod.Summary.HPP,
|
||||||
|
GrossProfit: fullPeriod.Summary.GrossProfit,
|
||||||
|
OperationalExpensesTotal: fullPeriod.Summary.OperationalExpensesTotal,
|
||||||
|
TotalCost: fullPeriod.Summary.TotalCost,
|
||||||
|
NetProfit: fullPeriod.Summary.NetProfit,
|
||||||
|
NetProfitMargin: percentage(fullPeriod.Summary.NetProfit, fullPeriod.Summary.Sales),
|
||||||
|
},
|
||||||
|
Periods: periods,
|
||||||
|
BankBalance: bankBalance,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AnalyticsProcessorImpl) GetExclusiveSummaryMTD(ctx context.Context, req *models.ExclusiveSummaryMTDRequest) (*models.ExclusiveSummaryPeriodResponse, error) {
|
||||||
|
mtdStart := time.Date(req.DateTo.Year(), req.DateTo.Month(), 1, 0, 0, 0, 0, req.DateTo.Location())
|
||||||
|
|
||||||
|
return p.buildExclusiveSummaryPeriod(ctx, &models.ExclusiveSummaryPeriodRequest{
|
||||||
|
OrganizationID: req.OrganizationID,
|
||||||
|
OutletID: req.OutletID,
|
||||||
|
DateFrom: mtdStart,
|
||||||
|
DateTo: req.DateTo,
|
||||||
|
ExcludeGajiStaffFromReimburse: req.ExcludeGajiStaffFromReimburse,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AnalyticsProcessorImpl) buildExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error) {
|
||||||
|
result, err := p.analyticsRepo.GetExclusiveSummaryAnalytics(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get exclusive summary analytics: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hppBreakdown, hppTotal := exclusiveSummaryCategoryBreakdown(result.HPPBreakdown)
|
||||||
|
operationalBreakdown, operationalTotal := exclusiveSummaryCategoryBreakdown(result.OperationalExpenseBreakdown)
|
||||||
|
salaryDW, salaryStaff, salaryOther := exclusiveSummarySalaryBreakdown(result.DailyTransactions)
|
||||||
|
salaryTotal := salaryDW + salaryStaff + salaryOther
|
||||||
|
otherOperationalExpenses := operationalTotal - salaryTotal
|
||||||
|
if otherOperationalExpenses < 0 {
|
||||||
|
otherOperationalExpenses = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
grossProfit := result.SalesTotal - hppTotal
|
||||||
|
totalCost := hppTotal + operationalTotal
|
||||||
|
netProfit := result.SalesTotal - totalCost
|
||||||
|
excludedSalaryStaff := 0.0
|
||||||
|
if req.ExcludeGajiStaffFromReimburse {
|
||||||
|
excludedSalaryStaff = salaryStaff
|
||||||
|
}
|
||||||
|
|
||||||
|
dailySummary := make([]models.ExclusiveSummaryDailySummary, len(result.DailySummary))
|
||||||
|
for i, item := range result.DailySummary {
|
||||||
|
dailySummary[i] = models.ExclusiveSummaryDailySummary{
|
||||||
|
Date: item.Date,
|
||||||
|
TransactionCount: item.TransactionCount,
|
||||||
|
TotalCost: item.TotalCost,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dailyTransactions := make([]models.ExclusiveSummaryDailyTransaction, len(result.DailyTransactions))
|
||||||
|
for i, item := range result.DailyTransactions {
|
||||||
|
dailyTransactions[i] = models.ExclusiveSummaryDailyTransaction{
|
||||||
|
Date: item.Date,
|
||||||
|
CategoryCode: item.CategoryCode,
|
||||||
|
CategoryName: item.CategoryName,
|
||||||
|
Description: item.Description,
|
||||||
|
Amount: item.Amount,
|
||||||
|
Source: item.Source,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &models.ExclusiveSummaryPeriodResponse{
|
||||||
|
OrganizationID: req.OrganizationID,
|
||||||
|
OutletID: req.OutletID,
|
||||||
|
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
|
||||||
|
Period: models.ExclusiveSummaryPeriodRange{
|
||||||
|
DateFrom: req.DateFrom,
|
||||||
|
DateTo: req.DateTo,
|
||||||
|
},
|
||||||
|
Summary: models.ExclusiveSummaryPeriodSummary{
|
||||||
|
Sales: result.SalesTotal,
|
||||||
|
HPP: hppTotal,
|
||||||
|
GrossProfit: grossProfit,
|
||||||
|
SalaryTotal: salaryTotal,
|
||||||
|
SalaryDW: salaryDW,
|
||||||
|
SalaryStaff: salaryStaff,
|
||||||
|
SalaryOther: salaryOther,
|
||||||
|
OtherOperationalExpenses: otherOperationalExpenses,
|
||||||
|
OperationalExpensesTotal: operationalTotal,
|
||||||
|
TotalCost: totalCost,
|
||||||
|
NetProfit: netProfit,
|
||||||
|
},
|
||||||
|
Reimburse: models.ExclusiveSummaryReimburse{
|
||||||
|
TotalCost: totalCost,
|
||||||
|
ExcludedSalaryStaff: excludedSalaryStaff,
|
||||||
|
TotalReimburse: totalCost - excludedSalaryStaff,
|
||||||
|
},
|
||||||
|
HPPBreakdown: hppBreakdown,
|
||||||
|
OperationalExpenseBreakdown: operationalBreakdown,
|
||||||
|
DailySummary: dailySummary,
|
||||||
|
DailyTransactions: dailyTransactions,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func exclusiveSummaryCategoryBreakdown(items []entities.ExclusiveSummaryCategoryTotal) ([]models.ExclusiveSummaryCategoryBreakdown, float64) {
|
||||||
|
var total float64
|
||||||
|
for _, item := range items {
|
||||||
|
total += item.Amount
|
||||||
|
}
|
||||||
|
|
||||||
|
breakdown := make([]models.ExclusiveSummaryCategoryBreakdown, len(items))
|
||||||
|
for i, item := range items {
|
||||||
|
breakdown[i] = models.ExclusiveSummaryCategoryBreakdown{
|
||||||
|
CategoryCode: item.CategoryCode,
|
||||||
|
CategoryName: item.CategoryName,
|
||||||
|
Amount: item.Amount,
|
||||||
|
Percentage: percentage(item.Amount, total),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return breakdown, total
|
||||||
|
}
|
||||||
|
|
||||||
|
func exclusiveSummarySalaryBreakdown(transactions []entities.ExclusiveSummaryDailyTransaction) (float64, float64, float64) {
|
||||||
|
var salaryDW float64
|
||||||
|
var salaryStaff float64
|
||||||
|
var salaryOther float64
|
||||||
|
|
||||||
|
for _, transaction := range transactions {
|
||||||
|
if !isExclusiveSummarySalary(transaction.CategoryCode, transaction.CategoryName, transaction.Description) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
classification := strings.ToLower(transaction.CategoryCode + " " + transaction.CategoryName + " " + transaction.Description)
|
||||||
|
switch {
|
||||||
|
case strings.Contains(classification, "staff") || strings.Contains(classification, "kary") || strings.Contains(classification, "karyawan"):
|
||||||
|
salaryStaff += transaction.Amount
|
||||||
|
case strings.Contains(classification, "dw"):
|
||||||
|
salaryDW += transaction.Amount
|
||||||
|
default:
|
||||||
|
salaryOther += transaction.Amount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return salaryDW, salaryStaff, salaryOther
|
||||||
|
}
|
||||||
|
|
||||||
|
func isExclusiveSummarySalary(parts ...string) bool {
|
||||||
|
text := strings.ToLower(strings.Join(parts, " "))
|
||||||
|
return strings.Contains(text, "gaji") || strings.Contains(text, "salary")
|
||||||
|
}
|
||||||
|
|
||||||
|
func percentage(numerator, denominator float64) float64 {
|
||||||
|
if denominator == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return (numerator / denominator) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
type exclusiveSummaryMonthlyBucket struct {
|
||||||
|
Label string
|
||||||
|
DateFrom time.Time
|
||||||
|
DateTo time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildExclusiveSummaryMonthlyBuckets(monthStart time.Time) []exclusiveSummaryMonthlyBucket {
|
||||||
|
monthEnd := monthStart.AddDate(0, 1, 0).Add(-time.Nanosecond)
|
||||||
|
buckets := make([]exclusiveSummaryMonthlyBucket, 0, 6)
|
||||||
|
currentStart := monthStart
|
||||||
|
|
||||||
|
for !currentStart.After(monthEnd) {
|
||||||
|
currentEnd := currentStart
|
||||||
|
for currentEnd.Weekday() != time.Sunday && currentEnd.Day() < monthEnd.Day() {
|
||||||
|
currentEnd = currentEnd.AddDate(0, 0, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
bucketEnd := time.Date(currentEnd.Year(), currentEnd.Month(), currentEnd.Day(), 23, 59, 59, int(time.Second-time.Nanosecond), currentEnd.Location())
|
||||||
|
if bucketEnd.After(monthEnd) {
|
||||||
|
bucketEnd = monthEnd
|
||||||
|
}
|
||||||
|
|
||||||
|
buckets = append(buckets, exclusiveSummaryMonthlyBucket{
|
||||||
|
Label: fmt.Sprintf("%d - %d %s", currentStart.Day(), bucketEnd.Day(), indonesianMonthName(currentStart.Month())),
|
||||||
|
DateFrom: currentStart,
|
||||||
|
DateTo: bucketEnd,
|
||||||
|
})
|
||||||
|
|
||||||
|
currentStart = time.Date(bucketEnd.Year(), bucketEnd.Month(), bucketEnd.Day(), 0, 0, 0, 0, bucketEnd.Location()).AddDate(0, 0, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buckets
|
||||||
|
}
|
||||||
|
|
||||||
|
func indonesianMonthName(month time.Month) string {
|
||||||
|
names := map[time.Month]string{
|
||||||
|
time.January: "Januari",
|
||||||
|
time.February: "Februari",
|
||||||
|
time.March: "Maret",
|
||||||
|
time.April: "April",
|
||||||
|
time.May: "Mei",
|
||||||
|
time.June: "Juni",
|
||||||
|
time.July: "Juli",
|
||||||
|
time.August: "Agustus",
|
||||||
|
time.September: "September",
|
||||||
|
time.October: "Oktober",
|
||||||
|
time.November: "November",
|
||||||
|
time.December: "Desember",
|
||||||
|
}
|
||||||
|
return names[month]
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,9 +13,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type analyticsRepositoryStub struct {
|
type analyticsRepositoryStub struct {
|
||||||
purchasingResult *entities.PurchasingAnalytics
|
purchasingResult *entities.PurchasingAnalytics
|
||||||
profitLossResult *entities.ProfitLossAnalytics
|
profitLossResult *entities.ProfitLossAnalytics
|
||||||
profitLossGroup string
|
exclusiveSummaryResults []*entities.ExclusiveSummaryAnalytics
|
||||||
|
bankBalances []entities.ExclusiveSummaryBankBalance
|
||||||
|
profitLossGroup string
|
||||||
|
exclusiveSummaryCalls int
|
||||||
|
exclusiveSummaryFrom []time.Time
|
||||||
|
exclusiveSummaryTo []time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func (analyticsRepositoryStub) GetPaymentMethodAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.PaymentMethodAnalytics, error) {
|
func (analyticsRepositoryStub) GetPaymentMethodAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.PaymentMethodAnalytics, error) {
|
||||||
@@ -47,6 +52,26 @@ func (s analyticsRepositoryStub) GetProfitLossAnalytics(_ context.Context, _ uui
|
|||||||
return s.profitLossResult, nil
|
return s.profitLossResult, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *analyticsRepositoryStub) GetExclusiveSummaryAnalytics(_ context.Context, _ uuid.UUID, _ *uuid.UUID, dateFrom, dateTo time.Time) (*entities.ExclusiveSummaryAnalytics, error) {
|
||||||
|
s.exclusiveSummaryFrom = append(s.exclusiveSummaryFrom, dateFrom)
|
||||||
|
s.exclusiveSummaryTo = append(s.exclusiveSummaryTo, dateTo)
|
||||||
|
if s.exclusiveSummaryCalls < len(s.exclusiveSummaryResults) {
|
||||||
|
result := s.exclusiveSummaryResults[s.exclusiveSummaryCalls]
|
||||||
|
s.exclusiveSummaryCalls++
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
s.exclusiveSummaryCalls++
|
||||||
|
return &entities.ExclusiveSummaryAnalytics{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *analyticsRepositoryStub) GetExclusiveSummaryBankBalances(context.Context, uuid.UUID, *uuid.UUID) ([]entities.ExclusiveSummaryBankBalance, error) {
|
||||||
|
return s.bankBalances, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (analyticsRepositoryStub) GetOutletName(context.Context, uuid.UUID, uuid.UUID) (string, error) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
type expenseRepositoryStub struct{}
|
type expenseRepositoryStub struct{}
|
||||||
|
|
||||||
func (expenseRepositoryStub) Create(context.Context, *entities.Expense) error { return nil }
|
func (expenseRepositoryStub) Create(context.Context, *entities.Expense) error { return nil }
|
||||||
@@ -71,7 +96,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{
|
||||||
@@ -120,11 +145,57 @@ 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,
|
||||||
@@ -196,7 +267,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,
|
||||||
@@ -273,3 +344,155 @@ func TestAnalyticsProcessorGetProfitLossAnalyticsDynamicExpenseCategories(t *tes
|
|||||||
require.Equal(t, float64(7400), result.MainSummary[6].MtdNominal)
|
require.Equal(t, float64(7400), result.MainSummary[6].MtdNominal)
|
||||||
require.True(t, result.MainSummary[6].IsBold)
|
require.True(t, result.MainSummary[6].IsBold)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAnalyticsProcessorGetExclusiveSummaryPeriodCalculatesTotalsAndReimburse(t *testing.T) {
|
||||||
|
now := time.Date(2026, 5, 26, 0, 0, 0, 0, time.UTC)
|
||||||
|
processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{
|
||||||
|
exclusiveSummaryResults: []*entities.ExclusiveSummaryAnalytics{
|
||||||
|
{
|
||||||
|
SalesTotal: 1000,
|
||||||
|
HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{
|
||||||
|
{CategoryCode: "RAW", CategoryName: "Raw", Amount: 400},
|
||||||
|
},
|
||||||
|
OperationalExpenseBreakdown: []entities.ExclusiveSummaryCategoryTotal{
|
||||||
|
{CategoryCode: "GAJI", CategoryName: "Gaji", Amount: 250},
|
||||||
|
{CategoryCode: "OPS", CategoryName: "Operasional", Amount: 100},
|
||||||
|
},
|
||||||
|
DailySummary: []entities.ExclusiveSummaryDailySummary{
|
||||||
|
{Date: now, TransactionCount: 3, TotalCost: 750},
|
||||||
|
},
|
||||||
|
DailyTransactions: []entities.ExclusiveSummaryDailyTransaction{
|
||||||
|
{Date: now, CategoryCode: "RAW", CategoryName: "Raw", Description: "beras", Amount: 400, Source: "purchase_order"},
|
||||||
|
{Date: now, CategoryCode: "GAJI", CategoryName: "Gaji", Description: "gaji karyawan", Amount: 200, Source: "purchase_order"},
|
||||||
|
{Date: now, CategoryCode: "GAJI", CategoryName: "Gaji", Description: "DW", Amount: 50, Source: "purchase_order"},
|
||||||
|
{Date: now, CategoryCode: "OPS", CategoryName: "Operasional", Description: "atk", Amount: 100, Source: "purchase_order"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, expenseRepositoryStub{})
|
||||||
|
|
||||||
|
result, err := processor.GetExclusiveSummaryPeriod(context.Background(), &models.ExclusiveSummaryPeriodRequest{
|
||||||
|
OrganizationID: uuid.New(),
|
||||||
|
DateFrom: now,
|
||||||
|
DateTo: now,
|
||||||
|
ExcludeGajiStaffFromReimburse: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, result)
|
||||||
|
require.Equal(t, float64(1000), result.Summary.Sales)
|
||||||
|
require.Equal(t, float64(400), result.Summary.HPP)
|
||||||
|
require.Equal(t, float64(600), result.Summary.GrossProfit)
|
||||||
|
require.Equal(t, float64(350), result.Summary.OperationalExpensesTotal)
|
||||||
|
require.Equal(t, float64(750), result.Summary.TotalCost)
|
||||||
|
require.Equal(t, float64(250), result.Summary.NetProfit)
|
||||||
|
require.Equal(t, float64(250), result.Summary.SalaryTotal)
|
||||||
|
require.Equal(t, float64(50), result.Summary.SalaryDW)
|
||||||
|
require.Equal(t, float64(200), result.Summary.SalaryStaff)
|
||||||
|
require.Equal(t, float64(100), result.Summary.OtherOperationalExpenses)
|
||||||
|
require.Equal(t, float64(200), result.Reimburse.ExcludedSalaryStaff)
|
||||||
|
require.Equal(t, float64(550), result.Reimburse.TotalReimburse)
|
||||||
|
require.Len(t, result.HPPBreakdown, 1)
|
||||||
|
require.Equal(t, float64(100), result.HPPBreakdown[0].Percentage)
|
||||||
|
require.Len(t, result.DailySummary, 1)
|
||||||
|
require.Len(t, result.DailyTransactions, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnalyticsProcessorGetExclusiveSummaryMonthlyBuildsSummaryAndBuckets(t *testing.T) {
|
||||||
|
location, err := time.LoadLocation("Asia/Jakarta")
|
||||||
|
require.NoError(t, err)
|
||||||
|
month := time.Date(2026, 5, 1, 0, 0, 0, 0, location)
|
||||||
|
openingBalance := 5000000.0
|
||||||
|
closingBalance := 5000000.0
|
||||||
|
notes := "Main cash account for daily transactions"
|
||||||
|
stub := &analyticsRepositoryStub{
|
||||||
|
exclusiveSummaryResults: []*entities.ExclusiveSummaryAnalytics{
|
||||||
|
{SalesTotal: 1000, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 400}}, OperationalExpenseBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 100}}},
|
||||||
|
{SalesTotal: 100, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 40}}},
|
||||||
|
{SalesTotal: 200, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 80}}},
|
||||||
|
{SalesTotal: 300, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 120}}},
|
||||||
|
{SalesTotal: 400, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 160}}},
|
||||||
|
{SalesTotal: 500, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 200}}},
|
||||||
|
},
|
||||||
|
bankBalances: []entities.ExclusiveSummaryBankBalance{
|
||||||
|
{Bank: "Cash and Bank", OpeningBalance: &openingBalance, ClosingBalance: &closingBalance, Notes: ¬es},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
processor := NewAnalyticsProcessorImpl(stub, expenseRepositoryStub{})
|
||||||
|
|
||||||
|
result, err := processor.GetExclusiveSummaryMonthly(context.Background(), &models.ExclusiveSummaryMonthlyRequest{
|
||||||
|
OrganizationID: uuid.New(),
|
||||||
|
Month: month,
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, result)
|
||||||
|
require.Equal(t, "2026-05", result.Month)
|
||||||
|
require.Equal(t, float64(1000), result.Summary.TotalSales)
|
||||||
|
require.Equal(t, float64(400), result.Summary.HPP)
|
||||||
|
require.Equal(t, float64(500), result.Summary.NetProfit)
|
||||||
|
require.InDelta(t, float64(50), result.Summary.NetProfitMargin, 0.0001)
|
||||||
|
require.Len(t, result.Periods, 5)
|
||||||
|
require.Equal(t, "1 - 3 Mei", result.Periods[0].Label)
|
||||||
|
require.Equal(t, "25 - 31 Mei", result.Periods[4].Label)
|
||||||
|
require.Len(t, result.BankBalance, 1)
|
||||||
|
require.Equal(t, "Cash and Bank", result.BankBalance[0].Bank)
|
||||||
|
require.NotNil(t, result.BankBalance[0].OpeningBalance)
|
||||||
|
require.Equal(t, openingBalance, *result.BankBalance[0].OpeningBalance)
|
||||||
|
require.NotNil(t, result.BankBalance[0].ClosingBalance)
|
||||||
|
require.Equal(t, closingBalance, *result.BankBalance[0].ClosingBalance)
|
||||||
|
require.Nil(t, result.BankBalance[0].IncomingMutation)
|
||||||
|
require.Nil(t, result.BankBalance[0].OutgoingMutation)
|
||||||
|
require.NotNil(t, result.BankBalance[0].Notes)
|
||||||
|
require.Equal(t, notes, *result.BankBalance[0].Notes)
|
||||||
|
require.Equal(t, 6, stub.exclusiveSummaryCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnalyticsProcessorGetExclusiveSummaryMTDBuildsMonthToDateBreakdown(t *testing.T) {
|
||||||
|
location, err := time.LoadLocation("Asia/Jakarta")
|
||||||
|
require.NoError(t, err)
|
||||||
|
dateTo := time.Date(2026, 6, 18, 23, 59, 59, int(time.Second-time.Nanosecond), location)
|
||||||
|
stub := &analyticsRepositoryStub{
|
||||||
|
exclusiveSummaryResults: []*entities.ExclusiveSummaryAnalytics{
|
||||||
|
{
|
||||||
|
SalesTotal: 1000,
|
||||||
|
HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{
|
||||||
|
{CategoryCode: "RAW", CategoryName: "Raw Material", Amount: 400},
|
||||||
|
},
|
||||||
|
OperationalExpenseBreakdown: []entities.ExclusiveSummaryCategoryTotal{
|
||||||
|
{CategoryCode: "OPS", CategoryName: "Operational", Amount: 100},
|
||||||
|
},
|
||||||
|
DailySummary: []entities.ExclusiveSummaryDailySummary{
|
||||||
|
{Date: dateTo, TransactionCount: 2, TotalCost: 500},
|
||||||
|
},
|
||||||
|
DailyTransactions: []entities.ExclusiveSummaryDailyTransaction{
|
||||||
|
{Date: dateTo, CategoryCode: "RAW", CategoryName: "Raw Material", Description: "beras", Amount: 400, Source: "purchase_order"},
|
||||||
|
{Date: dateTo, CategoryCode: "OPS", CategoryName: "Operational", Description: "atk", Amount: 100, Source: "expense"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
processor := NewAnalyticsProcessorImpl(stub, expenseRepositoryStub{})
|
||||||
|
|
||||||
|
result, err := processor.GetExclusiveSummaryMTD(context.Background(), &models.ExclusiveSummaryMTDRequest{
|
||||||
|
OrganizationID: uuid.New(),
|
||||||
|
DateTo: dateTo,
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, result)
|
||||||
|
require.Len(t, stub.exclusiveSummaryFrom, 1)
|
||||||
|
require.Equal(t, time.Date(2026, 6, 1, 0, 0, 0, 0, location), stub.exclusiveSummaryFrom[0])
|
||||||
|
require.Equal(t, dateTo, stub.exclusiveSummaryTo[0])
|
||||||
|
require.Equal(t, stub.exclusiveSummaryFrom[0], result.Period.DateFrom)
|
||||||
|
require.Equal(t, dateTo, result.Period.DateTo)
|
||||||
|
require.Equal(t, float64(1000), result.Summary.Sales)
|
||||||
|
require.Equal(t, float64(400), result.Summary.HPP)
|
||||||
|
require.Equal(t, float64(500), result.Summary.TotalCost)
|
||||||
|
require.Equal(t, float64(500), result.Summary.NetProfit)
|
||||||
|
require.Len(t, result.HPPBreakdown, 1)
|
||||||
|
require.Equal(t, float64(100), result.HPPBreakdown[0].Percentage)
|
||||||
|
require.Len(t, result.OperationalExpenseBreakdown, 1)
|
||||||
|
require.Len(t, result.DailySummary, 1)
|
||||||
|
require.Len(t, result.DailyTransactions, 2)
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type PurchaseOrderProcessor interface {
|
type PurchaseOrderProcessor interface {
|
||||||
CreatePurchaseOrder(ctx context.Context, organizationID uuid.UUID, req *models.CreatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error)
|
CreatePurchaseOrder(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, req *models.CreatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error)
|
||||||
UpdatePurchaseOrder(ctx context.Context, id, organizationID uuid.UUID, req *models.UpdatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error)
|
UpdatePurchaseOrder(ctx context.Context, id, organizationID uuid.UUID, outletID *uuid.UUID, req *models.UpdatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error)
|
||||||
DeletePurchaseOrder(ctx context.Context, id, organizationID uuid.UUID) error
|
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,11 +54,13 @@ func NewPurchaseOrderProcessorImpl(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, organizationID uuid.UUID, req *models.CreatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error) {
|
func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, req *models.CreatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error) {
|
||||||
// Check if vendor exists and belongs to organization
|
// Check if vendor exists and belongs to organization when provided.
|
||||||
_, err := p.vendorRepo.GetByIDAndOrganizationID(ctx, req.VendorID, organizationID)
|
if req.VendorID != nil {
|
||||||
if err != nil {
|
_, err := p.vendorRepo.GetByIDAndOrganizationID(ctx, *req.VendorID, organizationID)
|
||||||
return nil, fmt.Errorf("vendor not found: %w", err)
|
if err != nil {
|
||||||
|
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
|
||||||
@@ -107,12 +109,13 @@ 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 += item.Amount
|
totalAmount += calculatePurchaseOrderItemTotal(item.Quantity, 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,
|
||||||
@@ -173,12 +176,15 @@ 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, req *models.UpdatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error) {
|
func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id, organizationID uuid.UUID, outletID *uuid.UUID, req *models.UpdatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error) {
|
||||||
// Get existing purchase order
|
// 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 {
|
||||||
@@ -186,7 +192,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)
|
||||||
@@ -277,7 +283,7 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id
|
|||||||
UnitID: unitID,
|
UnitID: unitID,
|
||||||
Amount: amount,
|
Amount: amount,
|
||||||
}
|
}
|
||||||
totalAmount += amount
|
totalAmount += calculatePurchaseOrderItemTotal(quantity, amount)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete and recreate only after all replacement items are valid.
|
// Delete and recreate only after all replacement items are valid.
|
||||||
@@ -447,7 +453,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 = item.Amount / quantityToAdd
|
unitCost = calculatePurchaseOrderItemTotal(item.Quantity, item.Amount) / quantityToAdd
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create inventory movement for ingredient purchase
|
// Create inventory movement for ingredient purchase
|
||||||
@@ -476,7 +482,12 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update the purchase order status
|
// Update the purchase order status
|
||||||
err = p.purchaseOrderRepo.UpdateStatus(ctx, id, status)
|
statusOutletID := po.OutletID
|
||||||
|
if statusOutletID == nil && outletID != uuid.Nil {
|
||||||
|
statusOutletID = &outletID
|
||||||
|
}
|
||||||
|
|
||||||
|
err = p.purchaseOrderRepo.UpdateStatusAndOutlet(ctx, id, status, statusOutletID)
|
||||||
if err != nil {
|
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)
|
||||||
}
|
}
|
||||||
@@ -506,3 +517,11 @@ 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,6 +19,7 @@ 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
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package repository
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"apskel-pos-be/internal/entities"
|
"apskel-pos-be/internal/entities"
|
||||||
@@ -18,6 +19,9 @@ type AnalyticsRepository interface {
|
|||||||
GetProductAnalyticsPerCategory(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]*entities.ProductAnalyticsPerCategory, error)
|
GetProductAnalyticsPerCategory(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]*entities.ProductAnalyticsPerCategory, error)
|
||||||
GetDashboardOverview(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (*entities.DashboardOverview, error)
|
GetDashboardOverview(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (*entities.DashboardOverview, error)
|
||||||
GetProfitLossAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) (*entities.ProfitLossAnalytics, error)
|
GetProfitLossAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) (*entities.ProfitLossAnalytics, error)
|
||||||
|
GetExclusiveSummaryAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (*entities.ExclusiveSummaryAnalytics, error)
|
||||||
|
GetExclusiveSummaryBankBalances(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) ([]entities.ExclusiveSummaryBankBalance, error)
|
||||||
|
GetOutletName(ctx context.Context, organizationID uuid.UUID, outletID uuid.UUID) (string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type AnalyticsRepositoryImpl struct {
|
type AnalyticsRepositoryImpl struct {
|
||||||
@@ -37,6 +41,34 @@ 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
|
||||||
|
|
||||||
@@ -151,18 +183,23 @@ 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(poi.amount), 0) as total_purchases,
|
COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as total_purchases,
|
||||||
|
COALESCE(SUM(`+purchaseOrderRawMaterialAmountSQL()+`), 0) as raw_material_purchases,
|
||||||
|
COALESCE(SUM(`+purchaseOrderExpenseAmountSQL()+`), 0) as expense_purchases,
|
||||||
COUNT(DISTINCT po.id) as total_purchase_orders,
|
COUNT(DISTINCT 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(poi.amount), 0) / COUNT(DISTINCT po.id)
|
THEN COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 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 po.vendor_id) as total_vendors
|
COUNT(DISTINCT COALESCE(po.vendor_id::text, 'no-vendor')) 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).
|
||||||
@@ -187,28 +224,68 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex
|
|||||||
}
|
}
|
||||||
|
|
||||||
var data []entities.PurchasingAnalyticsData
|
var data []entities.PurchasingAnalyticsData
|
||||||
dataQuery := r.db.WithContext(ctx).
|
var outletData []entities.PurchasingOutletData
|
||||||
Table("purchase_orders po").
|
if groupBy == "outlet_id" {
|
||||||
Select(`
|
outletQuery := r.db.WithContext(ctx).
|
||||||
`+dateFormat+` as date,
|
Table("purchase_orders po").
|
||||||
COALESCE(SUM(poi.amount), 0) as purchases,
|
Select(`
|
||||||
COUNT(DISTINCT po.id) as purchase_orders,
|
po.outlet_id as outlet_id,
|
||||||
COALESCE(SUM(poi.quantity), 0) as quantity,
|
COALESCE(o.name, 'No Outlet') as outlet_name,
|
||||||
COUNT(DISTINCT i.id) as ingredients,
|
COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as purchases,
|
||||||
COUNT(DISTINCT po.vendor_id) as vendors
|
COALESCE(SUM(`+purchaseOrderRawMaterialAmountSQL()+`), 0) as raw_material_purchases,
|
||||||
`).
|
COALESCE(SUM(`+purchaseOrderExpenseAmountSQL()+`), 0) as expense_purchases,
|
||||||
Joins("LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id").
|
COUNT(DISTINCT po.id) as purchase_orders,
|
||||||
Joins("LEFT JOIN ingredients i ON poi.ingredient_id = i.id").
|
COUNT(DISTINCT CASE WHEN pc.type = '`+string(entities.PurchaseCategoryTypeRawMaterial)+`' THEN po.id END) as raw_material_purchase_orders,
|
||||||
Joins("LEFT JOIN units u ON poi.unit_id = u.id").
|
COUNT(CASE WHEN pc.type = '`+string(entities.PurchaseCategoryTypeExpense)+`' THEN poi.id END) as expense_count,
|
||||||
Where("po.organization_id = ?", organizationID).
|
COALESCE(SUM(poi.quantity), 0) as quantity,
|
||||||
Where("po.status != ?", "cancelled").
|
COUNT(DISTINCT i.id) as ingredients,
|
||||||
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo).
|
COUNT(DISTINCT COALESCE(po.vendor_id::text, 'no-vendor')) as vendors
|
||||||
Group(dateFormat).
|
`).
|
||||||
Order(dateFormat)
|
Joins("LEFT JOIN outlets o ON po.outlet_id = o.id").
|
||||||
dataQuery = r.applyPurchaseOrderItemOutletFilter(dataQuery, outletID)
|
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 := dataQuery.Scan(&data).Error; err != nil {
|
if err := outletQuery.Scan(&outletData).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dataQuery := r.db.WithContext(ctx).
|
||||||
|
Table("purchase_orders po").
|
||||||
|
Select(`
|
||||||
|
`+dateFormat+` as date,
|
||||||
|
COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as purchases,
|
||||||
|
COALESCE(SUM(`+purchaseOrderRawMaterialAmountSQL()+`), 0) as raw_material_purchases,
|
||||||
|
COALESCE(SUM(`+purchaseOrderExpenseAmountSQL()+`), 0) as expense_purchases,
|
||||||
|
COUNT(DISTINCT po.id) as purchase_orders,
|
||||||
|
COUNT(DISTINCT CASE WHEN pc.type = '`+string(entities.PurchaseCategoryTypeRawMaterial)+`' THEN po.id END) as raw_material_purchase_orders,
|
||||||
|
COUNT(CASE WHEN pc.type = '`+string(entities.PurchaseCategoryTypeExpense)+`' THEN poi.id END) as expense_count,
|
||||||
|
COALESCE(SUM(poi.quantity), 0) as quantity,
|
||||||
|
COUNT(DISTINCT i.id) as ingredients,
|
||||||
|
COUNT(DISTINCT COALESCE(po.vendor_id::text, 'no-vendor')) as vendors
|
||||||
|
`).
|
||||||
|
Joins("LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id").
|
||||||
|
Joins("LEFT JOIN purchase_categories pc ON poi.purchase_category_id = pc.id").
|
||||||
|
Joins("LEFT JOIN ingredients i ON poi.ingredient_id = i.id").
|
||||||
|
Joins("LEFT JOIN units u ON poi.unit_id = u.id").
|
||||||
|
Where("po.organization_id = ?", organizationID).
|
||||||
|
Where("po.status != ?", "cancelled").
|
||||||
|
Where("pc.type = ?", entities.PurchaseCategoryTypeRawMaterial).
|
||||||
|
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo).
|
||||||
|
Group(dateFormat).
|
||||||
|
Order(dateFormat)
|
||||||
|
dataQuery = r.applyPurchaseOrderItemOutletFilter(dataQuery, outletID)
|
||||||
|
|
||||||
|
if err := dataQuery.Scan(&data).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var ingredientData []entities.PurchasingIngredientData
|
var ingredientData []entities.PurchasingIngredientData
|
||||||
@@ -218,15 +295,16 @@ 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(poi.amount), 0) as total_cost,
|
COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as total_cost,
|
||||||
CASE
|
CASE
|
||||||
WHEN SUM(poi.quantity) > 0
|
WHEN SUM(poi.quantity) > 0
|
||||||
THEN COALESCE(SUM(poi.amount), 0) / SUM(poi.quantity)
|
THEN COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 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).
|
||||||
@@ -245,20 +323,21 @@ 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,
|
||||||
v.name as vendor_name,
|
COALESCE(v.name, 'No Vendor') as vendor_name,
|
||||||
COALESCE(SUM(poi.amount), 0) as total_cost,
|
COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 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("JOIN vendors v ON po.vendor_id = v.id").
|
Joins("LEFT JOIN vendors v ON po.vendor_id = v.id").
|
||||||
Joins("LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id").
|
Joins("LEFT JOIN purchase_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, v.name").
|
Group("v.id, COALESCE(v.name, 'No Vendor')").
|
||||||
Order("total_cost DESC")
|
Order("total_cost DESC")
|
||||||
vendorQuery = r.applyPurchaseOrderItemOutletFilter(vendorQuery, outletID)
|
vendorQuery = r.applyPurchaseOrderItemOutletFilter(vendorQuery, outletID)
|
||||||
|
|
||||||
@@ -270,6 +349,7 @@ 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
|
||||||
@@ -279,7 +359,7 @@ func (r *AnalyticsRepositoryImpl) applyPurchaseOrderItemOutletFilter(query *gorm
|
|||||||
if outletID == nil {
|
if outletID == nil {
|
||||||
return query
|
return query
|
||||||
}
|
}
|
||||||
return query.Where("(i.outlet_id = ? OR u.outlet_id = ?)", *outletID, *outletID)
|
return query.Where("po.outlet_id = ?", *outletID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, limit int) ([]*entities.ProductAnalytics, error) {
|
func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, limit int) ([]*entities.ProductAnalytics, error) {
|
||||||
@@ -290,6 +370,13 @@ 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,
|
||||||
@@ -323,6 +410,7 @@ 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").
|
||||||
@@ -348,7 +436,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.cost, c.id, c.name, c.order, mahpp.hpp_per_unit").
|
Group("p.id, p.name, p.sku, p.price, p.cost, pop.price, c.id, c.name, c.order, mahpp.hpp_per_unit").
|
||||||
Order("revenue DESC").
|
Order("revenue DESC").
|
||||||
Limit(limit).
|
Limit(limit).
|
||||||
Scan(&results).Error
|
Scan(&results).Error
|
||||||
@@ -435,6 +523,41 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -484,6 +607,11 @@ 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 {
|
||||||
@@ -531,6 +659,11 @@ 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).
|
||||||
@@ -591,6 +724,11 @@ 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).
|
||||||
@@ -608,36 +746,333 @@ 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,
|
||||||
Data: data,
|
Data: data,
|
||||||
ProductData: productData,
|
ProductData: productData,
|
||||||
TodayRevenue: todayRC.Revenue,
|
TodayRevenue: todayRC.Revenue,
|
||||||
TodayCost: todayRC.Cost,
|
TodayCost: todayRC.Cost,
|
||||||
MtdRevenue: mtdRC.Revenue,
|
MtdRevenue: mtdRC.Revenue,
|
||||||
MtdCost: mtdRC.Cost,
|
MtdCost: mtdRC.Cost,
|
||||||
TodayExpenseByCategory: todayExpenseByCategory,
|
TodayPurchasing: todayPurchasing.Total,
|
||||||
MtdExpenseByCategory: mtdExpenseByCategory,
|
MtdPurchasing: mtdPurchasing.Total,
|
||||||
OperationalExpenseItems: opsItems,
|
TodayPurchasingRawMaterial: todayPurchasing.RawMaterial,
|
||||||
|
MtdPurchasingRawMaterial: mtdPurchasing.RawMaterial,
|
||||||
|
TodayPurchasingExpense: todayPurchasing.Expense,
|
||||||
|
MtdPurchasingExpense: mtdPurchasing.Expense,
|
||||||
|
PurchasingItems: purchasingItems,
|
||||||
|
TodayExpenseByCategory: todayExpenseByCategory,
|
||||||
|
MtdExpenseByCategory: mtdExpenseByCategory,
|
||||||
|
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
|
||||||
|
|
||||||
@@ -686,3 +1121,217 @@ func (r *AnalyticsRepositoryImpl) getOperationalExpenseItems(ctx context.Context
|
|||||||
|
|
||||||
return results, err
|
return results, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *AnalyticsRepositoryImpl) GetExclusiveSummaryAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (*entities.ExclusiveSummaryAnalytics, error) {
|
||||||
|
type salesResult struct {
|
||||||
|
SalesTotal float64
|
||||||
|
SalesCount int64
|
||||||
|
}
|
||||||
|
|
||||||
|
var sales salesResult
|
||||||
|
salesQuery := r.db.WithContext(ctx).
|
||||||
|
Table("orders o").
|
||||||
|
Select(`
|
||||||
|
COALESCE(SUM(o.total_amount), 0) as sales_total,
|
||||||
|
COUNT(o.id) as sales_count
|
||||||
|
`).
|
||||||
|
Where("o.organization_id = ?", organizationID).
|
||||||
|
Where("o.status = ?", entities.OrderStatusCompleted).
|
||||||
|
Where("o.payment_status = ?", entities.PaymentStatusCompleted).
|
||||||
|
Where("o.is_void = false AND o.is_refund = false").
|
||||||
|
Where("o.created_at >= ? AND o.created_at <= ?", dateFrom, dateTo)
|
||||||
|
salesQuery = r.resolveOutletID(salesQuery, outletID, "o.outlet_id")
|
||||||
|
if err := salesQuery.Scan(&sales).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
hppBreakdown, err := r.getExclusiveSummaryHPPBreakdown(ctx, organizationID, outletID, dateFrom, dateTo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
operationalExpenseBreakdown, err := r.getExclusiveSummaryOperationalExpenseBreakdown(ctx, organizationID, outletID, dateFrom, dateTo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dailySummary, err := r.getExclusiveSummaryDailySummary(ctx, organizationID, outletID, dateFrom, dateTo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dailyTransactions, err := r.getExclusiveSummaryDailyTransactions(ctx, organizationID, outletID, dateFrom, dateTo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &entities.ExclusiveSummaryAnalytics{
|
||||||
|
SalesTotal: sales.SalesTotal,
|
||||||
|
SalesCount: sales.SalesCount,
|
||||||
|
HPPBreakdown: hppBreakdown,
|
||||||
|
OperationalExpenseBreakdown: operationalExpenseBreakdown,
|
||||||
|
DailySummary: dailySummary,
|
||||||
|
DailyTransactions: dailyTransactions,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AnalyticsRepositoryImpl) getExclusiveSummaryHPPBreakdown(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]entities.ExclusiveSummaryCategoryTotal, error) {
|
||||||
|
var results []entities.ExclusiveSummaryCategoryTotal
|
||||||
|
|
||||||
|
query := r.db.WithContext(ctx).
|
||||||
|
Table("purchase_order_items poi").
|
||||||
|
Select(`
|
||||||
|
pc.code as category_code,
|
||||||
|
pc.name as category_name,
|
||||||
|
COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as amount
|
||||||
|
`).
|
||||||
|
Joins("JOIN purchase_orders po ON poi.purchase_order_id = po.id").
|
||||||
|
Joins("JOIN purchase_categories pc ON poi.purchase_category_id = pc.id").
|
||||||
|
Joins("LEFT JOIN ingredients i ON poi.ingredient_id = i.id").
|
||||||
|
Joins("LEFT JOIN units u ON poi.unit_id = u.id").
|
||||||
|
Where("po.organization_id = ?", organizationID).
|
||||||
|
Where("pc.type = ?", entities.PurchaseCategoryTypeRawMaterial).
|
||||||
|
Where("po.status = ?", "received").
|
||||||
|
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo)
|
||||||
|
query = r.applyPurchaseOrderItemOutletFilter(query, outletID)
|
||||||
|
|
||||||
|
err := query.
|
||||||
|
Group("pc.id, pc.code, pc.name, pc.sort_order").
|
||||||
|
Order("pc.sort_order ASC, pc.name ASC").
|
||||||
|
Scan(&results).Error
|
||||||
|
|
||||||
|
return results, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AnalyticsRepositoryImpl) getExclusiveSummaryOperationalExpenseBreakdown(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]entities.ExclusiveSummaryCategoryTotal, error) {
|
||||||
|
var results []entities.ExclusiveSummaryCategoryTotal
|
||||||
|
|
||||||
|
query := r.db.WithContext(ctx).
|
||||||
|
Table("purchase_order_items poi").
|
||||||
|
Select(`
|
||||||
|
pc.code as category_code,
|
||||||
|
pc.name as category_name,
|
||||||
|
COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as amount
|
||||||
|
`).
|
||||||
|
Joins("JOIN purchase_orders po ON poi.purchase_order_id = po.id").
|
||||||
|
Joins("JOIN purchase_categories pc ON poi.purchase_category_id = pc.id").
|
||||||
|
Where("po.organization_id = ?", organizationID).
|
||||||
|
Where("pc.type = ?", entities.PurchaseCategoryTypeExpense).
|
||||||
|
Where("po.status = ?", "received").
|
||||||
|
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo)
|
||||||
|
query = r.applyPurchaseOrderItemOutletFilter(query, outletID)
|
||||||
|
|
||||||
|
err := query.
|
||||||
|
Group("pc.id, pc.code, pc.name, pc.sort_order").
|
||||||
|
Order("pc.sort_order ASC, pc.name ASC").
|
||||||
|
Scan(&results).Error
|
||||||
|
|
||||||
|
return results, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AnalyticsRepositoryImpl) getExclusiveSummaryDailySummary(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]entities.ExclusiveSummaryDailySummary, error) {
|
||||||
|
var results []entities.ExclusiveSummaryDailySummary
|
||||||
|
rawQuery, args := r.exclusiveSummaryPurchaseOrderItemQuery(organizationID, outletID, dateFrom, dateTo)
|
||||||
|
|
||||||
|
err := r.db.WithContext(ctx).Raw(`
|
||||||
|
SELECT date, COUNT(*) as transaction_count, COALESCE(SUM(amount), 0) as total_cost
|
||||||
|
FROM (`+rawQuery+`) transactions
|
||||||
|
GROUP BY date
|
||||||
|
ORDER BY date ASC
|
||||||
|
`, args...).Scan(&results).Error
|
||||||
|
|
||||||
|
return results, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AnalyticsRepositoryImpl) getExclusiveSummaryDailyTransactions(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]entities.ExclusiveSummaryDailyTransaction, error) {
|
||||||
|
var results []entities.ExclusiveSummaryDailyTransaction
|
||||||
|
rawQuery, args := r.exclusiveSummaryPurchaseOrderItemQuery(organizationID, outletID, dateFrom, dateTo)
|
||||||
|
|
||||||
|
err := r.db.WithContext(ctx).Raw(`
|
||||||
|
SELECT date, category_code, category_name, description, amount, source
|
||||||
|
FROM (`+rawQuery+`) transactions
|
||||||
|
ORDER BY date ASC, category_name ASC, description ASC
|
||||||
|
`, args...).Scan(&results).Error
|
||||||
|
|
||||||
|
return results, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AnalyticsRepositoryImpl) exclusiveSummaryPurchaseOrderItemQuery(organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (string, []interface{}) {
|
||||||
|
outletFilter := ""
|
||||||
|
args := []interface{}{
|
||||||
|
organizationID,
|
||||||
|
"received",
|
||||||
|
dateFrom,
|
||||||
|
dateTo,
|
||||||
|
}
|
||||||
|
|
||||||
|
if outletID != nil {
|
||||||
|
outletFilter = "AND po.outlet_id = ?"
|
||||||
|
args = append(args, *outletID)
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `
|
||||||
|
SELECT
|
||||||
|
DATE(po.transaction_date) as date,
|
||||||
|
pc.code as category_code,
|
||||||
|
pc.name as category_name,
|
||||||
|
COALESCE(NULLIF(poi.description, ''), i.name, pc.name) as description,
|
||||||
|
` + purchaseOrderItemTotalAmountSQL() + ` as amount,
|
||||||
|
'purchase_order' as source
|
||||||
|
FROM purchase_order_items poi
|
||||||
|
JOIN purchase_orders po ON poi.purchase_order_id = po.id
|
||||||
|
JOIN purchase_categories pc ON poi.purchase_category_id = pc.id
|
||||||
|
LEFT JOIN ingredients i ON poi.ingredient_id = i.id
|
||||||
|
LEFT JOIN units u ON poi.unit_id = u.id
|
||||||
|
WHERE po.organization_id = ?
|
||||||
|
AND po.status = ?
|
||||||
|
AND po.transaction_date >= ? AND po.transaction_date <= ?
|
||||||
|
` + outletFilter + `
|
||||||
|
`
|
||||||
|
|
||||||
|
return query, args
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AnalyticsRepositoryImpl) GetExclusiveSummaryBankBalances(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) ([]entities.ExclusiveSummaryBankBalance, error) {
|
||||||
|
type accountBalance struct {
|
||||||
|
Name string
|
||||||
|
OpeningBalance float64
|
||||||
|
CurrentBalance float64
|
||||||
|
Description *string
|
||||||
|
}
|
||||||
|
|
||||||
|
var accounts []accountBalance
|
||||||
|
query := r.db.WithContext(ctx).
|
||||||
|
Table("accounts").
|
||||||
|
Select("name, opening_balance, current_balance, description").
|
||||||
|
Where("organization_id = ?", organizationID).
|
||||||
|
Where("account_type IN ?", []entities.AccountType{entities.AccountTypeCash, entities.AccountTypeWallet, entities.AccountTypeBank}).
|
||||||
|
Where("is_active = ?", true)
|
||||||
|
|
||||||
|
if outletID != nil {
|
||||||
|
query = query.Where("outlet_id = ? OR outlet_id IS NULL", *outletID)
|
||||||
|
} else {
|
||||||
|
query = query.Where("outlet_id IS NULL")
|
||||||
|
}
|
||||||
|
|
||||||
|
err := query.
|
||||||
|
Order("number ASC, name ASC").
|
||||||
|
Scan(&accounts).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
balances := make([]entities.ExclusiveSummaryBankBalance, len(accounts))
|
||||||
|
for i, account := range accounts {
|
||||||
|
openingBalance := account.OpeningBalance
|
||||||
|
closingBalance := account.CurrentBalance
|
||||||
|
balances[i] = entities.ExclusiveSummaryBankBalance{
|
||||||
|
Bank: account.Name,
|
||||||
|
OpeningBalance: &openingBalance,
|
||||||
|
ClosingBalance: &closingBalance,
|
||||||
|
Notes: account.Description,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return balances, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -196,6 +196,18 @@ 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{}).
|
||||||
|
|||||||
@@ -337,6 +337,9 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
|||||||
analytics.GET("/categories", r.analyticsHandler.GetProductAnalyticsPerCategory)
|
analytics.GET("/categories", r.analyticsHandler.GetProductAnalyticsPerCategory)
|
||||||
analytics.GET("/dashboard", r.analyticsHandler.GetDashboardAnalytics)
|
analytics.GET("/dashboard", r.analyticsHandler.GetDashboardAnalytics)
|
||||||
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/monthly", r.analyticsHandler.GetExclusiveSummaryMonthly)
|
||||||
|
analytics.GET("/exclusive-summary/mtd", r.analyticsHandler.GetExclusiveSummaryMTD)
|
||||||
}
|
}
|
||||||
|
|
||||||
tables := protected.Group("/tables")
|
tables := protected.Group("/tables")
|
||||||
@@ -353,7 +356,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ingredients := protected.Group("/ingredients")
|
ingredients := protected.Group("/ingredients")
|
||||||
ingredients.Use(r.authMiddleware.RequireAdminOrManager())
|
ingredients.Use(r.authMiddleware.RequireAdminOrManagerOrPurchasing())
|
||||||
{
|
{
|
||||||
ingredients.POST("", r.ingredientHandler.Create)
|
ingredients.POST("", r.ingredientHandler.Create)
|
||||||
ingredients.GET("", r.ingredientHandler.GetAll)
|
ingredients.GET("", r.ingredientHandler.GetAll)
|
||||||
@@ -366,7 +369,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
vendors := protected.Group("/vendors")
|
vendors := protected.Group("/vendors")
|
||||||
vendors.Use(r.authMiddleware.RequireAdminOrManager())
|
vendors.Use(r.authMiddleware.RequireAdminOrManagerOrPurchasing())
|
||||||
{
|
{
|
||||||
vendors.POST("", r.vendorHandler.CreateVendor)
|
vendors.POST("", r.vendorHandler.CreateVendor)
|
||||||
vendors.GET("", r.vendorHandler.ListVendors)
|
vendors.GET("", r.vendorHandler.ListVendors)
|
||||||
@@ -377,7 +380,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
purchaseOrders := protected.Group("/purchase-orders")
|
purchaseOrders := protected.Group("/purchase-orders")
|
||||||
purchaseOrders.Use(r.authMiddleware.RequireAdminOrManager())
|
purchaseOrders.Use(r.authMiddleware.RequireAdminOrManagerOrPurchasing())
|
||||||
{
|
{
|
||||||
purchaseOrders.POST("", r.purchaseOrderHandler.CreatePurchaseOrder)
|
purchaseOrders.POST("", r.purchaseOrderHandler.CreatePurchaseOrder)
|
||||||
purchaseOrders.GET("", r.purchaseOrderHandler.ListPurchaseOrders)
|
purchaseOrders.GET("", r.purchaseOrderHandler.ListPurchaseOrders)
|
||||||
@@ -390,7 +393,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
purchaseCategories := protected.Group("/purchase-categories")
|
purchaseCategories := protected.Group("/purchase-categories")
|
||||||
purchaseCategories.Use(r.authMiddleware.RequireAdminOrManager())
|
purchaseCategories.Use(r.authMiddleware.RequireAdminOrManagerOrPurchasing())
|
||||||
{
|
{
|
||||||
purchaseCategories.POST("", r.purchaseCategoryHandler.CreatePurchaseCategory)
|
purchaseCategories.POST("", r.purchaseCategoryHandler.CreatePurchaseCategory)
|
||||||
purchaseCategories.GET("", r.purchaseCategoryHandler.ListPurchaseCategories)
|
purchaseCategories.GET("", r.purchaseCategoryHandler.ListPurchaseCategories)
|
||||||
@@ -400,7 +403,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
unitConverters := protected.Group("/unit-converters")
|
unitConverters := protected.Group("/unit-converters")
|
||||||
unitConverters.Use(r.authMiddleware.RequireAdminOrManager())
|
unitConverters.Use(r.authMiddleware.RequireAdminOrManagerOrPurchasing())
|
||||||
{
|
{
|
||||||
unitConverters.POST("", r.unitConverterHandler.CreateIngredientUnitConverter)
|
unitConverters.POST("", r.unitConverterHandler.CreateIngredientUnitConverter)
|
||||||
unitConverters.GET("", r.unitConverterHandler.ListIngredientUnitConverters)
|
unitConverters.GET("", r.unitConverterHandler.ListIngredientUnitConverters)
|
||||||
@@ -462,7 +465,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
expenses := protected.Group("/expenses")
|
expenses := protected.Group("/expenses")
|
||||||
expenses.Use(r.authMiddleware.RequireAdminOrManager())
|
expenses.Use(r.authMiddleware.RequireAdminOrManagerOrPurchasing())
|
||||||
{
|
{
|
||||||
expenses.POST("", r.expenseHandler.CreateExpense)
|
expenses.POST("", r.expenseHandler.CreateExpense)
|
||||||
expenses.GET("", r.expenseHandler.ListExpenses)
|
expenses.GET("", r.expenseHandler.ListExpenses)
|
||||||
@@ -617,6 +620,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
|||||||
outlets.GET("/:outlet_id/tables/occupied", r.tableHandler.GetOccupiedTables)
|
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
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ type AnalyticsService interface {
|
|||||||
GetProductAnalyticsPerCategory(ctx context.Context, req *models.ProductAnalyticsPerCategoryRequest) (*models.ProductAnalyticsPerCategoryResponse, error)
|
GetProductAnalyticsPerCategory(ctx context.Context, req *models.ProductAnalyticsPerCategoryRequest) (*models.ProductAnalyticsPerCategoryResponse, error)
|
||||||
GetDashboardAnalytics(ctx context.Context, req *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error)
|
GetDashboardAnalytics(ctx context.Context, req *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error)
|
||||||
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)
|
||||||
|
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 {
|
||||||
@@ -193,10 +196,11 @@ func (s *AnalyticsServiceImpl) validatePurchasingAnalyticsRequest(req *models.Pu
|
|||||||
|
|
||||||
if req.GroupBy != "" {
|
if req.GroupBy != "" {
|
||||||
validGroupBy := map[string]bool{
|
validGroupBy := map[string]bool{
|
||||||
"day": true,
|
"day": true,
|
||||||
"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)
|
||||||
@@ -320,3 +324,98 @@ func (s *AnalyticsServiceImpl) validateProfitLossAnalyticsRequest(req *models.Pr
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *AnalyticsServiceImpl) GetExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error) {
|
||||||
|
if err := s.validateExclusiveSummaryPeriodRequest(req); err != nil {
|
||||||
|
return nil, fmt.Errorf("validation error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := s.analyticsProcessor.GetExclusiveSummaryPeriod(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get exclusive summary period: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AnalyticsServiceImpl) GetExclusiveSummaryMonthly(ctx context.Context, req *models.ExclusiveSummaryMonthlyRequest) (*models.ExclusiveSummaryMonthlyResponse, error) {
|
||||||
|
if err := s.validateExclusiveSummaryMonthlyRequest(req); err != nil {
|
||||||
|
return nil, fmt.Errorf("validation error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := s.analyticsProcessor.GetExclusiveSummaryMonthly(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get exclusive summary monthly: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AnalyticsServiceImpl) GetExclusiveSummaryMTD(ctx context.Context, req *models.ExclusiveSummaryMTDRequest) (*models.ExclusiveSummaryPeriodResponse, error) {
|
||||||
|
if err := s.validateExclusiveSummaryMTDRequest(req); err != nil {
|
||||||
|
return nil, fmt.Errorf("validation error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := s.analyticsProcessor.GetExclusiveSummaryMTD(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get exclusive summary mtd: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AnalyticsServiceImpl) validateExclusiveSummaryPeriodRequest(req *models.ExclusiveSummaryPeriodRequest) error {
|
||||||
|
if req == nil {
|
||||||
|
return fmt.Errorf("request cannot be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.OrganizationID == uuid.Nil {
|
||||||
|
return fmt.Errorf("organization_id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.DateFrom.IsZero() {
|
||||||
|
return fmt.Errorf("date_from is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.DateTo.IsZero() {
|
||||||
|
return fmt.Errorf("date_to is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.DateFrom.After(req.DateTo) {
|
||||||
|
return fmt.Errorf("date_from cannot be after date_to")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AnalyticsServiceImpl) validateExclusiveSummaryMTDRequest(req *models.ExclusiveSummaryMTDRequest) error {
|
||||||
|
if req == nil {
|
||||||
|
return fmt.Errorf("request cannot be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.OrganizationID == uuid.Nil {
|
||||||
|
return fmt.Errorf("organization_id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.DateTo.IsZero() {
|
||||||
|
return fmt.Errorf("date_to is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AnalyticsServiceImpl) validateExclusiveSummaryMonthlyRequest(req *models.ExclusiveSummaryMonthlyRequest) error {
|
||||||
|
if req == nil {
|
||||||
|
return fmt.Errorf("request cannot be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.OrganizationID == uuid.Nil {
|
||||||
|
return fmt.Errorf("organization_id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Month.IsZero() {
|
||||||
|
return fmt.Errorf("month is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -41,6 +41,18 @@ func (analyticsProcessorStub) GetProfitLossAnalytics(context.Context, *models.Pr
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (analyticsProcessorStub) GetExclusiveSummaryPeriod(context.Context, *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error) {
|
||||||
|
return &models.ExclusiveSummaryPeriodResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (analyticsProcessorStub) GetExclusiveSummaryMonthly(context.Context, *models.ExclusiveSummaryMonthlyRequest) (*models.ExclusiveSummaryMonthlyResponse, error) {
|
||||||
|
return &models.ExclusiveSummaryMonthlyResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (analyticsProcessorStub) GetExclusiveSummaryMTD(context.Context, *models.ExclusiveSummaryMTDRequest) (*models.ExclusiveSummaryPeriodResponse, error) {
|
||||||
|
return &models.ExclusiveSummaryPeriodResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func TestAnalyticsServiceGetPurchasingAnalyticsValidation(t *testing.T) {
|
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)
|
||||||
@@ -120,6 +132,21 @@ 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)
|
||||||
@@ -190,3 +217,100 @@ 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -40,7 +40,12 @@ func (s *PurchaseOrderServiceImpl) CreatePurchaseOrder(ctx context.Context, apct
|
|||||||
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
|
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
|
||||||
}
|
}
|
||||||
|
|
||||||
poResponse, err := s.purchaseOrderProcessor.CreatePurchaseOrder(ctx, apctx.OrganizationID, modelReq)
|
var outletID *uuid.UUID
|
||||||
|
if apctx.OutletID != uuid.Nil {
|
||||||
|
outletID = &apctx.OutletID
|
||||||
|
}
|
||||||
|
|
||||||
|
poResponse, err := s.purchaseOrderProcessor.CreatePurchaseOrder(ctx, apctx.OrganizationID, outletID, modelReq)
|
||||||
if err != nil {
|
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})
|
||||||
@@ -57,7 +62,12 @@ func (s *PurchaseOrderServiceImpl) UpdatePurchaseOrder(ctx context.Context, apct
|
|||||||
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
|
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
|
||||||
}
|
}
|
||||||
|
|
||||||
poResponse, err := s.purchaseOrderProcessor.UpdatePurchaseOrder(ctx, id, apctx.OrganizationID, modelReq)
|
var outletID *uuid.UUID
|
||||||
|
if apctx.OutletID != uuid.Nil {
|
||||||
|
outletID = &apctx.OutletID
|
||||||
|
}
|
||||||
|
|
||||||
|
poResponse, err := s.purchaseOrderProcessor.UpdatePurchaseOrder(ctx, id, apctx.OrganizationID, outletID, modelReq)
|
||||||
if err != nil {
|
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})
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ 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 {
|
||||||
@@ -218,3 +219,296 @@ 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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ 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,
|
||||||
@@ -122,6 +123,7 @@ 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,
|
||||||
@@ -182,6 +184,23 @@ 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{
|
||||||
@@ -226,6 +245,7 @@ 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,
|
||||||
}
|
}
|
||||||
@@ -285,6 +305,7 @@ 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,
|
||||||
@@ -337,6 +358,7 @@ 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,
|
||||||
@@ -421,15 +443,19 @@ 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{
|
||||||
TotalSales: resp.Overview.TotalSales,
|
TotalSales: resp.Overview.TotalSales,
|
||||||
TotalOrders: resp.Overview.TotalOrders,
|
TotalOrders: resp.Overview.TotalOrders,
|
||||||
AverageOrderValue: resp.Overview.AverageOrderValue,
|
AverageOrderValue: resp.Overview.AverageOrderValue,
|
||||||
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,
|
||||||
@@ -516,9 +542,20 @@ func ProfitLossAnalyticsModelToContract(resp *models.ProfitLossAnalyticsResponse
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
purchasingItems := make([]contract.ProfitLossPurchasingItem, len(resp.Purchasing.Items))
|
||||||
|
for i, item := range resp.Purchasing.Items {
|
||||||
|
purchasingItems[i] = contract.ProfitLossPurchasingItem{
|
||||||
|
Date: item.Date,
|
||||||
|
Item: item.Item,
|
||||||
|
Quantity: item.Quantity,
|
||||||
|
Nominal: item.Nominal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return &contract.ProfitLossAnalyticsResponse{
|
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,
|
||||||
@@ -535,9 +572,18 @@ func ProfitLossAnalyticsModelToContract(resp *models.ProfitLossAnalyticsResponse
|
|||||||
AverageProfit: resp.Summary.AverageProfit,
|
AverageProfit: resp.Summary.AverageProfit,
|
||||||
ProfitabilityRatio: resp.Summary.ProfitabilityRatio,
|
ProfitabilityRatio: resp.Summary.ProfitabilityRatio,
|
||||||
},
|
},
|
||||||
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,
|
||||||
}
|
}
|
||||||
@@ -559,3 +605,268 @@ func profitLossSummaryRowModelToContract(row models.ProfitLossSummaryRow) contra
|
|||||||
SubItems: subItems,
|
SubItems: subItems,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ExclusiveSummaryPeriodContractToModel(req *contract.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodRequest, error) {
|
||||||
|
if req == nil {
|
||||||
|
return nil, fmt.Errorf("request cannot be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
dateFrom, dateTo, err := parseFlexibleDateRangeToJakartaTime(req.DateFrom, req.DateTo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid date range: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if dateFrom == nil {
|
||||||
|
return nil, fmt.Errorf("date_from is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if dateTo == nil {
|
||||||
|
return nil, fmt.Errorf("date_to is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &models.ExclusiveSummaryPeriodRequest{
|
||||||
|
OrganizationID: req.OrganizationID,
|
||||||
|
OutletID: parseOutletID(req.OutletID),
|
||||||
|
DateFrom: *dateFrom,
|
||||||
|
DateTo: *dateTo,
|
||||||
|
ExcludeGajiStaffFromReimburse: req.ExcludeGajiStaffFromReimburse,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExclusiveSummaryMonthlyContractToModel(req *contract.ExclusiveSummaryMonthlyRequest) (*models.ExclusiveSummaryMonthlyRequest, error) {
|
||||||
|
if req == nil {
|
||||||
|
return nil, fmt.Errorf("request cannot be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
month, err := parseMonthToJakartaTime(req.Month)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid month: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &models.ExclusiveSummaryMonthlyRequest{
|
||||||
|
OrganizationID: req.OrganizationID,
|
||||||
|
OutletID: parseOutletID(req.OutletID),
|
||||||
|
Month: month,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExclusiveSummaryMTDContractToModel(req *contract.ExclusiveSummaryMTDRequest) (*models.ExclusiveSummaryMTDRequest, error) {
|
||||||
|
if req == nil {
|
||||||
|
return nil, fmt.Errorf("request cannot be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
dateTo, err := parseFlexibleDateToJakartaTime(req.DateTo, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid date_to: %w", err)
|
||||||
|
}
|
||||||
|
if dateTo == nil {
|
||||||
|
return nil, fmt.Errorf("date_to is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &models.ExclusiveSummaryMTDRequest{
|
||||||
|
OrganizationID: req.OrganizationID,
|
||||||
|
OutletID: parseOutletID(req.OutletID),
|
||||||
|
DateTo: *dateTo,
|
||||||
|
ExcludeGajiStaffFromReimburse: req.ExcludeGajiStaffFromReimburse,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExclusiveSummaryPeriodModelToContract(resp *models.ExclusiveSummaryPeriodResponse) *contract.ExclusiveSummaryPeriodResponse {
|
||||||
|
if resp == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
hppBreakdown := make([]contract.ExclusiveSummaryCategoryBreakdown, len(resp.HPPBreakdown))
|
||||||
|
for i, item := range resp.HPPBreakdown {
|
||||||
|
hppBreakdown[i] = exclusiveSummaryCategoryBreakdownModelToContract(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
operationalBreakdown := make([]contract.ExclusiveSummaryCategoryBreakdown, len(resp.OperationalExpenseBreakdown))
|
||||||
|
for i, item := range resp.OperationalExpenseBreakdown {
|
||||||
|
operationalBreakdown[i] = exclusiveSummaryCategoryBreakdownModelToContract(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
dailySummary := make([]contract.ExclusiveSummaryDailySummary, len(resp.DailySummary))
|
||||||
|
for i, item := range resp.DailySummary {
|
||||||
|
dailySummary[i] = contract.ExclusiveSummaryDailySummary{
|
||||||
|
Date: item.Date,
|
||||||
|
TransactionCount: item.TransactionCount,
|
||||||
|
TotalCost: item.TotalCost,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dailyTransactions := make([]contract.ExclusiveSummaryDailyTransaction, len(resp.DailyTransactions))
|
||||||
|
for i, item := range resp.DailyTransactions {
|
||||||
|
dailyTransactions[i] = contract.ExclusiveSummaryDailyTransaction{
|
||||||
|
Date: item.Date,
|
||||||
|
CategoryCode: item.CategoryCode,
|
||||||
|
CategoryName: item.CategoryName,
|
||||||
|
Description: item.Description,
|
||||||
|
Amount: item.Amount,
|
||||||
|
Source: item.Source,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &contract.ExclusiveSummaryPeriodResponse{
|
||||||
|
OrganizationID: resp.OrganizationID,
|
||||||
|
OutletID: resp.OutletID,
|
||||||
|
OutletName: resp.OutletName,
|
||||||
|
Period: contract.ExclusiveSummaryPeriodRange{
|
||||||
|
DateFrom: resp.Period.DateFrom,
|
||||||
|
DateTo: resp.Period.DateTo,
|
||||||
|
},
|
||||||
|
Summary: contract.ExclusiveSummaryPeriodSummary{
|
||||||
|
Sales: resp.Summary.Sales,
|
||||||
|
HPP: resp.Summary.HPP,
|
||||||
|
GrossProfit: resp.Summary.GrossProfit,
|
||||||
|
SalaryTotal: resp.Summary.SalaryTotal,
|
||||||
|
SalaryDW: resp.Summary.SalaryDW,
|
||||||
|
SalaryStaff: resp.Summary.SalaryStaff,
|
||||||
|
SalaryOther: resp.Summary.SalaryOther,
|
||||||
|
OtherOperationalExpenses: resp.Summary.OtherOperationalExpenses,
|
||||||
|
OperationalExpensesTotal: resp.Summary.OperationalExpensesTotal,
|
||||||
|
TotalCost: resp.Summary.TotalCost,
|
||||||
|
NetProfit: resp.Summary.NetProfit,
|
||||||
|
},
|
||||||
|
Reimburse: contract.ExclusiveSummaryReimburse{
|
||||||
|
TotalCost: resp.Reimburse.TotalCost,
|
||||||
|
ExcludedSalaryStaff: resp.Reimburse.ExcludedSalaryStaff,
|
||||||
|
TotalReimburse: resp.Reimburse.TotalReimburse,
|
||||||
|
},
|
||||||
|
HPPBreakdown: hppBreakdown,
|
||||||
|
OperationalExpenseBreakdown: operationalBreakdown,
|
||||||
|
DailySummary: dailySummary,
|
||||||
|
DailyTransactions: dailyTransactions,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExclusiveSummaryMonthlyModelToContract(resp *models.ExclusiveSummaryMonthlyResponse) *contract.ExclusiveSummaryMonthlyResponse {
|
||||||
|
if resp == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
periods := make([]contract.ExclusiveSummaryMonthlyPeriod, len(resp.Periods))
|
||||||
|
for i, item := range resp.Periods {
|
||||||
|
periods[i] = contract.ExclusiveSummaryMonthlyPeriod{
|
||||||
|
Label: item.Label,
|
||||||
|
DateFrom: item.DateFrom,
|
||||||
|
DateTo: item.DateTo,
|
||||||
|
Sales: item.Sales,
|
||||||
|
HPP: item.HPP,
|
||||||
|
GrossProfit: item.GrossProfit,
|
||||||
|
GrossMargin: item.GrossMargin,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bankBalance := make([]contract.ExclusiveSummaryBankBalance, len(resp.BankBalance))
|
||||||
|
for i, item := range resp.BankBalance {
|
||||||
|
bankBalance[i] = contract.ExclusiveSummaryBankBalance{
|
||||||
|
Bank: item.Bank,
|
||||||
|
OpeningBalance: item.OpeningBalance,
|
||||||
|
IncomingMutation: item.IncomingMutation,
|
||||||
|
OutgoingMutation: item.OutgoingMutation,
|
||||||
|
ClosingBalance: item.ClosingBalance,
|
||||||
|
Notes: item.Notes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &contract.ExclusiveSummaryMonthlyResponse{
|
||||||
|
OrganizationID: resp.OrganizationID,
|
||||||
|
OutletID: resp.OutletID,
|
||||||
|
OutletName: resp.OutletName,
|
||||||
|
Month: resp.Month,
|
||||||
|
Summary: contract.ExclusiveSummaryMonthlySummary{
|
||||||
|
TotalSales: resp.Summary.TotalSales,
|
||||||
|
HPP: resp.Summary.HPP,
|
||||||
|
GrossProfit: resp.Summary.GrossProfit,
|
||||||
|
OperationalExpensesTotal: resp.Summary.OperationalExpensesTotal,
|
||||||
|
TotalCost: resp.Summary.TotalCost,
|
||||||
|
NetProfit: resp.Summary.NetProfit,
|
||||||
|
NetProfitMargin: resp.Summary.NetProfitMargin,
|
||||||
|
},
|
||||||
|
Periods: periods,
|
||||||
|
BankBalance: bankBalance,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func exclusiveSummaryCategoryBreakdownModelToContract(item models.ExclusiveSummaryCategoryBreakdown) contract.ExclusiveSummaryCategoryBreakdown {
|
||||||
|
return contract.ExclusiveSummaryCategoryBreakdown{
|
||||||
|
CategoryCode: item.CategoryCode,
|
||||||
|
CategoryName: item.CategoryName,
|
||||||
|
Amount: item.Amount,
|
||||||
|
Percentage: item.Percentage,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFlexibleDateRangeToJakartaTime(dateFrom, dateTo string) (*time.Time, *time.Time, error) {
|
||||||
|
fromTime, toTime, err := util.ParseDateRangeToJakartaTime(dateFrom, dateTo)
|
||||||
|
if err == nil {
|
||||||
|
return fromTime, toTime, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fromTime, err = parseISODateToJakartaTime(dateFrom, false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
toTime, err = parseISODateToJakartaTime(dateTo, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return fromTime, toTime, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseISODateToJakartaTime(dateStr string, endOfDay bool) (*time.Time, error) {
|
||||||
|
if dateStr == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
date, err := time.Parse("2006-01-02", dateStr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
location, err := time.LoadLocation("Asia/Jakarta")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if endOfDay {
|
||||||
|
result := time.Date(date.Year(), date.Month(), date.Day(), 23, 59, 59, 999999999, location)
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, location)
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFlexibleDateToJakartaTime(dateStr string, endOfDay bool) (*time.Time, error) {
|
||||||
|
if dateStr == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fromTime, toTime, err := util.ParseDateRangeToJakartaTime(dateStr, dateStr)
|
||||||
|
if err == nil {
|
||||||
|
if endOfDay {
|
||||||
|
return toTime, nil
|
||||||
|
}
|
||||||
|
return fromTime, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseISODateToJakartaTime(dateStr, endOfDay)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseMonthToJakartaTime(month string) (time.Time, error) {
|
||||||
|
location, err := time.LoadLocation("Asia/Jakarta")
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := time.ParseInLocation("2006-01", month, location)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return time.Date(parsed.Year(), parsed.Month(), 1, 0, 0, 0, 0, location), nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -105,6 +105,40 @@ 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()
|
||||||
@@ -182,3 +216,43 @@ func TestProfitLossAnalyticsModelToContractCopiesDateRange(t *testing.T) {
|
|||||||
require.Len(t, result.MainSummary, 1)
|
require.Len(t, result.MainSummary, 1)
|
||||||
require.Equal(t, "total_omset", result.MainSummary[0].ID)
|
require.Equal(t, "total_omset", result.MainSummary[0].ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExclusiveSummaryPeriodContractToModelParsesFlexibleDates(t *testing.T) {
|
||||||
|
orgID := uuid.New()
|
||||||
|
outletID := uuid.New().String()
|
||||||
|
|
||||||
|
result, err := ExclusiveSummaryPeriodContractToModel(&contract.ExclusiveSummaryPeriodRequest{
|
||||||
|
OrganizationID: orgID,
|
||||||
|
OutletID: &outletID,
|
||||||
|
DateFrom: "2026-05-26",
|
||||||
|
DateTo: "2026-05-31",
|
||||||
|
ExcludeGajiStaffFromReimburse: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, orgID, result.OrganizationID)
|
||||||
|
require.NotNil(t, result.OutletID)
|
||||||
|
require.Equal(t, outletID, result.OutletID.String())
|
||||||
|
require.True(t, result.ExcludeGajiStaffFromReimburse)
|
||||||
|
|
||||||
|
location, err := time.LoadLocation("Asia/Jakarta")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, time.Date(2026, 5, 26, 0, 0, 0, 0, location), result.DateFrom)
|
||||||
|
require.Equal(t, time.Date(2026, 5, 31, 23, 59, 59, int(time.Second-time.Nanosecond), location), result.DateTo)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExclusiveSummaryMonthlyContractToModelParsesMonth(t *testing.T) {
|
||||||
|
orgID := uuid.New()
|
||||||
|
|
||||||
|
result, err := ExclusiveSummaryMonthlyContractToModel(&contract.ExclusiveSummaryMonthlyRequest{
|
||||||
|
OrganizationID: orgID,
|
||||||
|
Month: "2026-05",
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, orgID, result.OrganizationID)
|
||||||
|
|
||||||
|
location, err := time.LoadLocation("Asia/Jakarta")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, time.Date(2026, 5, 1, 0, 0, 0, 0, location), result.Month)
|
||||||
|
}
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ 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,12 +12,13 @@ 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: uuid.New(),
|
VendorID: &vendorID,
|
||||||
PONumber: "PO-001",
|
PONumber: "PO-001",
|
||||||
TransactionDate: "2026-05-29",
|
TransactionDate: "2026-05-29",
|
||||||
Items: []contract.CreatePurchaseOrderItemRequest{
|
Items: []contract.CreatePurchaseOrderItemRequest{
|
||||||
@@ -35,9 +36,10 @@ 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: uuid.New(),
|
VendorID: &vendorID,
|
||||||
PONumber: "PO-001",
|
PONumber: "PO-001",
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -45,3 +47,19 @@ 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 == uuid.Nil {
|
if req.VendorID != nil && *req.VendorID == uuid.Nil {
|
||||||
return errors.New("vendor_id is required"), constants.MissingFieldErrorCode
|
return errors.New("vendor_id cannot be empty"), constants.MalformedFieldErrorCode
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.TrimSpace(req.PONumber) == "" {
|
if strings.TrimSpace(req.PONumber) == "" {
|
||||||
|
|||||||
@@ -11,12 +11,13 @@ 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: uuid.New(),
|
VendorID: &vendorID,
|
||||||
PONumber: "PO-001",
|
PONumber: "PO-001",
|
||||||
TransactionDate: "2026-05-29",
|
TransactionDate: "2026-05-29",
|
||||||
Items: []contract.CreatePurchaseOrderItemRequest{
|
Items: []contract.CreatePurchaseOrderItemRequest{
|
||||||
@@ -31,6 +32,30 @@ 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()
|
||||||
|
|
||||||
|
|||||||
@@ -140,10 +140,12 @@ func (v *UserValidatorImpl) ValidateUserID(userID uuid.UUID) (error, string) {
|
|||||||
|
|
||||||
func isValidUserRole(role string) bool {
|
func isValidUserRole(role string) bool {
|
||||||
validRoles := map[string]bool{
|
validRoles := map[string]bool{
|
||||||
string(constants.RoleAdmin): true,
|
string(constants.RoleAdmin): true,
|
||||||
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]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE purchase_orders
|
||||||
|
ALTER COLUMN vendor_id SET NOT NULL;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE purchase_orders
|
||||||
|
ALTER COLUMN vendor_id DROP NOT NULL;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
DROP INDEX IF EXISTS idx_purchase_orders_outlet_id;
|
||||||
|
|
||||||
|
ALTER TABLE purchase_orders
|
||||||
|
DROP COLUMN IF EXISTS outlet_id;
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
ALTER TABLE purchase_orders
|
||||||
|
ADD COLUMN IF NOT EXISTS outlet_id UUID REFERENCES outlets(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_purchase_orders_outlet_id
|
||||||
|
ON purchase_orders(outlet_id);
|
||||||
|
|
||||||
|
WITH movement_outlets AS (
|
||||||
|
SELECT
|
||||||
|
poi.purchase_order_id,
|
||||||
|
MIN(im.outlet_id::text)::uuid AS outlet_id
|
||||||
|
FROM inventory_movements im
|
||||||
|
JOIN purchase_order_items poi ON im.purchase_order_item_id = poi.id
|
||||||
|
WHERE im.outlet_id IS NOT NULL
|
||||||
|
AND im.purchase_order_item_id IS NOT NULL
|
||||||
|
GROUP BY poi.purchase_order_id
|
||||||
|
HAVING COUNT(DISTINCT im.outlet_id) = 1
|
||||||
|
)
|
||||||
|
UPDATE purchase_orders po
|
||||||
|
SET outlet_id = movement_outlets.outlet_id
|
||||||
|
FROM movement_outlets
|
||||||
|
WHERE po.id = movement_outlets.purchase_order_id
|
||||||
|
AND po.outlet_id IS NULL;
|
||||||
|
|
||||||
|
WITH candidate_item_outlets AS (
|
||||||
|
SELECT
|
||||||
|
poi.purchase_order_id,
|
||||||
|
i.outlet_id
|
||||||
|
FROM purchase_order_items poi
|
||||||
|
JOIN ingredients i ON poi.ingredient_id = i.id
|
||||||
|
WHERE i.outlet_id IS NOT NULL
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
poi.purchase_order_id,
|
||||||
|
u.outlet_id
|
||||||
|
FROM purchase_order_items poi
|
||||||
|
JOIN units u ON poi.unit_id = u.id
|
||||||
|
WHERE u.outlet_id IS NOT NULL
|
||||||
|
), item_outlets AS (
|
||||||
|
SELECT
|
||||||
|
purchase_order_id,
|
||||||
|
MIN(outlet_id::text)::uuid AS outlet_id
|
||||||
|
FROM candidate_item_outlets
|
||||||
|
GROUP BY purchase_order_id
|
||||||
|
HAVING COUNT(DISTINCT outlet_id) = 1
|
||||||
|
)
|
||||||
|
UPDATE purchase_orders po
|
||||||
|
SET outlet_id = item_outlets.outlet_id
|
||||||
|
FROM item_outlets
|
||||||
|
WHERE po.id = item_outlets.purchase_order_id
|
||||||
|
AND po.outlet_id IS NULL;
|
||||||
|
|
||||||
|
WITH single_outlet_organizations AS (
|
||||||
|
SELECT
|
||||||
|
organization_id,
|
||||||
|
MIN(id::text)::uuid AS outlet_id
|
||||||
|
FROM outlets
|
||||||
|
GROUP BY organization_id
|
||||||
|
HAVING COUNT(*) = 1
|
||||||
|
)
|
||||||
|
UPDATE purchase_orders po
|
||||||
|
SET outlet_id = single_outlet_organizations.outlet_id
|
||||||
|
FROM single_outlet_organizations
|
||||||
|
WHERE po.organization_id = single_outlet_organizations.organization_id
|
||||||
|
AND po.outlet_id IS NULL;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Revert to original roles
|
||||||
|
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_role_check;
|
||||||
|
UPDATE users SET role = 'admin' WHERE role NOT IN ('admin', 'manager', 'cashier', 'waiter');
|
||||||
|
ALTER TABLE users ADD CONSTRAINT users_role_check CHECK (role IN ('admin', 'manager', 'cashier', 'waiter'));
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Add 'owner' and 'purchasing' roles to users table
|
||||||
|
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_role_check;
|
||||||
|
UPDATE users SET role = 'admin' WHERE role NOT IN ('admin', 'manager', 'cashier', 'waiter', 'owner', 'purchasing');
|
||||||
|
ALTER TABLE users ADD CONSTRAINT users_role_check CHECK (role IN ('admin', 'manager', 'cashier', 'waiter', 'owner', 'purchasing'));
|
||||||
@@ -0,0 +1,394 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="id">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Laporan Penjualan Harian</title>
|
||||||
|
<style>
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 10mm 12mm;
|
||||||
|
}
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #2d3748;
|
||||||
|
line-height: 1.4;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.report-container {
|
||||||
|
max-width: 210mm;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.report-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.header-logo {
|
||||||
|
display: inline-block;
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
background: #dc2626;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
.header-logo svg {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
.header-title {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #1a1a1a;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.header-org {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #dc2626;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.header-period {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #666;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Summary Boxes */
|
||||||
|
.summary-boxes {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border: 2px solid #1a1a1a;
|
||||||
|
}
|
||||||
|
.summary-box {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid #1a1a1a;
|
||||||
|
}
|
||||||
|
.summary-box:nth-child(odd) {
|
||||||
|
border-right: 1px solid #1a1a1a;
|
||||||
|
}
|
||||||
|
.summary-box:nth-child(n + 3) {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.summary-box.highlight-green {
|
||||||
|
background: #dcfce7;
|
||||||
|
}
|
||||||
|
.summary-box.highlight-red {
|
||||||
|
background: #fee2e2;
|
||||||
|
}
|
||||||
|
.summary-box-label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1a1a1a;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.summary-box-value {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #1a1a1a;
|
||||||
|
font-family: "Courier New", monospace;
|
||||||
|
}
|
||||||
|
.summary-box-value.negative {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
.summary-box-value.positive {
|
||||||
|
color: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section Title */
|
||||||
|
.section-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #dc2626;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main Summary Table */
|
||||||
|
.summary-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.summary-table thead th {
|
||||||
|
background: #dc2626;
|
||||||
|
color: #ffffff;
|
||||||
|
padding: 8px 10px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
border: 1px solid #dc2626;
|
||||||
|
}
|
||||||
|
.summary-table thead th:first-child {
|
||||||
|
width: 30px;
|
||||||
|
}
|
||||||
|
.summary-table thead th:nth-child(2) {
|
||||||
|
text-align: left;
|
||||||
|
width: 35%;
|
||||||
|
}
|
||||||
|
.summary-table tbody td {
|
||||||
|
padding: 7px 10px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.summary-table tbody td:first-child {
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.summary-table tbody td.nominal {
|
||||||
|
text-align: right;
|
||||||
|
font-family: "Courier New", monospace;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.summary-table tbody td.pct {
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.summary-table tbody tr.bold-row td {
|
||||||
|
font-weight: 800;
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
.summary-table tbody tr.highlight-row td {
|
||||||
|
font-weight: 800;
|
||||||
|
background: #fef3c7;
|
||||||
|
}
|
||||||
|
.summary-table tbody tr.highlight-green-row td {
|
||||||
|
font-weight: 800;
|
||||||
|
background: #dcfce7;
|
||||||
|
}
|
||||||
|
.summary-table tbody tr.sub-item td {
|
||||||
|
padding-left: 30px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rincian Biaya Table */
|
||||||
|
.detail-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.detail-table thead th {
|
||||||
|
background: #dc2626;
|
||||||
|
color: #ffffff;
|
||||||
|
padding: 8px 10px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
border: 1px solid #dc2626;
|
||||||
|
}
|
||||||
|
.detail-table thead th:first-child {
|
||||||
|
width: 30px;
|
||||||
|
}
|
||||||
|
.detail-table thead th:nth-child(2) {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.detail-table thead th:last-child {
|
||||||
|
text-align: right;
|
||||||
|
width: 25%;
|
||||||
|
}
|
||||||
|
.detail-table tbody td {
|
||||||
|
padding: 7px 10px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.detail-table tbody td:first-child {
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.detail-table tbody td:last-child {
|
||||||
|
text-align: right;
|
||||||
|
font-family: "Courier New", monospace;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.detail-table tbody tr.total-row td {
|
||||||
|
font-weight: 800;
|
||||||
|
background: #fef3c7;
|
||||||
|
border-top: 2px solid #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.report-footer {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
font-size: 9px;
|
||||||
|
color: #9ca3af;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="report-container">
|
||||||
|
<!-- HEADER -->
|
||||||
|
<div class="report-header">
|
||||||
|
<div class="header-logo">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 500 500"
|
||||||
|
width="40"
|
||||||
|
height="40"
|
||||||
|
>
|
||||||
|
<rect fill="#dc2626" x="0" y="0" width="500" height="500" rx="60" />
|
||||||
|
<g transform="translate(250,250) scale(3.2)">
|
||||||
|
<rect
|
||||||
|
x="-40"
|
||||||
|
y="-50"
|
||||||
|
width="80"
|
||||||
|
height="10"
|
||||||
|
rx="3"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x="-30"
|
||||||
|
y="-35"
|
||||||
|
width="60"
|
||||||
|
height="60"
|
||||||
|
rx="5"
|
||||||
|
fill="none"
|
||||||
|
stroke="white"
|
||||||
|
stroke-width="5"
|
||||||
|
/>
|
||||||
|
<circle cx="0" cy="-55" r="8" fill="white" />
|
||||||
|
<line
|
||||||
|
x1="-10"
|
||||||
|
y1="-60"
|
||||||
|
x2="10"
|
||||||
|
y2="-50"
|
||||||
|
stroke="white"
|
||||||
|
stroke-width="3"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1="10"
|
||||||
|
y1="-60"
|
||||||
|
x2="-10"
|
||||||
|
y2="-50"
|
||||||
|
stroke="white"
|
||||||
|
stroke-width="3"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="header-title">LAPORAN PENJUALAN HARIAN</div>
|
||||||
|
<div class="header-org">{{.OrganizationName}}</div>
|
||||||
|
<div class="header-period">
|
||||||
|
Bulan: {{.MonthName}} / Tanggal Report: {{.ReportDate}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SUMMARY BOXES -->
|
||||||
|
<div class="summary-boxes">
|
||||||
|
<div class="summary-box">
|
||||||
|
<span class="summary-box-label"
|
||||||
|
>TOTAL PENJUALAN {{.ReportDateUpper}}</span
|
||||||
|
>
|
||||||
|
<span class="summary-box-value">{{.TotalPenjualan}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-box">
|
||||||
|
<span class="summary-box-label"
|
||||||
|
>TOTAL BIAYA {{.ReportDateUpper}}</span
|
||||||
|
>
|
||||||
|
<span class="summary-box-value">{{.TotalBiaya}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-box {{.LabaRugiClass}}">
|
||||||
|
<span class="summary-box-label">LABA/RUGI {{.ReportDateUpper}}</span>
|
||||||
|
<span class="summary-box-value {{.LabaRugiValueClass}}"
|
||||||
|
>{{.LabaRugi}}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="summary-box {{.LabaRugiMtdClass}}">
|
||||||
|
<span class="summary-box-label">LABA/RUGI MTD</span>
|
||||||
|
<span class="summary-box-value {{.LabaRugiMtdValueClass}}"
|
||||||
|
>{{.LabaRugiMtd}}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 1. RINGKASAN LAPORAN -->
|
||||||
|
<div class="section-title">1. Ringkasan Laporan</div>
|
||||||
|
<table class="summary-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>NO</th>
|
||||||
|
<th>KETERANGAN</th>
|
||||||
|
<th>TANGGAL REPORT<br />Nominal</th>
|
||||||
|
<th>%</th>
|
||||||
|
<th>MTD<br />Nominal</th>
|
||||||
|
<th>%</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range $i, $row := .MainSummary}}
|
||||||
|
<tr class="{{$row.RowClass}}">
|
||||||
|
<td>{{if $row.Number}}{{$row.Number}}{{end}}</td>
|
||||||
|
<td>{{$row.Label}}</td>
|
||||||
|
<td class="nominal">{{$row.TodayNominal}}</td>
|
||||||
|
<td class="pct">{{$row.TodayPct}}</td>
|
||||||
|
<td class="nominal">{{$row.MtdNominal}}</td>
|
||||||
|
<td class="pct">{{$row.MtdPct}}</td>
|
||||||
|
</tr>
|
||||||
|
{{range $j, $sub := $row.SubItems}}
|
||||||
|
<tr class="sub-item">
|
||||||
|
<td></td>
|
||||||
|
<td>{{$sub.Label}}</td>
|
||||||
|
<td class="nominal">{{$sub.TodayNominal}}</td>
|
||||||
|
<td class="pct">{{$sub.TodayPct}}</td>
|
||||||
|
<td class="nominal">{{$sub.MtdNominal}}</td>
|
||||||
|
<td class="pct">{{$sub.MtdPct}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}} {{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- 2. RINCIAN BIAYA / CATATAN -->
|
||||||
|
<div class="section-title">2. Rincian Biaya / Catatan</div>
|
||||||
|
<table class="detail-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>NO</th>
|
||||||
|
<th>KETERANGAN</th>
|
||||||
|
<th>JUMLAH</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range $i, $item := .PurchasingItems}}
|
||||||
|
<tr>
|
||||||
|
<td>{{add $i 1}}</td>
|
||||||
|
<td>{{$item.Name}}</td>
|
||||||
|
<td>{{$item.Amount}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
<tr class="total-row">
|
||||||
|
<td></td>
|
||||||
|
<td>TOTAL</td>
|
||||||
|
<td>{{.PurchasingTotal}}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- FOOTER -->
|
||||||
|
<div class="report-footer">
|
||||||
|
Dicetak oleh: {{.GeneratedBy}} | {{.PrintTime}} | Powered by APSKEL
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user