Compare commits

..

No commits in common. "main" and "feature/expense-revisi" have entirely different histories.

40 changed files with 145 additions and 1832 deletions

View File

@ -1 +0,0 @@
{}

3
go.sum
View File

@ -351,8 +351,6 @@ github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM= github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM=
github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
@ -382,6 +380,7 @@ go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI=
go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=

View File

@ -3,12 +3,11 @@ 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 {
@ -18,7 +17,6 @@ func GetAllUserRoles() []UserRole {
RoleCashier, RoleCashier,
RoleWaiter, RoleWaiter,
RoleOwner, RoleOwner,
RolePurchasing,
} }
} }

View File

@ -18,7 +18,6 @@ type PaymentMethodAnalyticsRequest struct {
type PaymentMethodAnalyticsResponse struct { type PaymentMethodAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"` DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"` DateTo time.Time `json:"date_to"`
GroupBy string `json:"group_by"` GroupBy string `json:"group_by"`
@ -55,7 +54,6 @@ type SalesAnalyticsRequest struct {
type SalesAnalyticsResponse struct { type SalesAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"` DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"` DateTo time.Time `json:"date_to"`
GroupBy string `json:"group_by"` GroupBy string `json:"group_by"`
@ -142,12 +140,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
@ -163,7 +161,6 @@ type ProductAnalyticsRequest struct {
type ProductAnalyticsResponse struct { type ProductAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"` DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"` DateTo time.Time `json:"date_to"`
Data []ProductAnalyticsData `json:"data"` Data []ProductAnalyticsData `json:"data"`
@ -201,7 +198,6 @@ type ProductAnalyticsPerCategoryRequest struct {
type ProductAnalyticsPerCategoryResponse struct { type ProductAnalyticsPerCategoryResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"` DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"` DateTo time.Time `json:"date_to"`
Data []ProductAnalyticsPerCategoryData `json:"data"` Data []ProductAnalyticsPerCategoryData `json:"data"`
@ -231,7 +227,6 @@ type DashboardAnalyticsRequest struct {
type DashboardAnalyticsResponse struct { type DashboardAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"` DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"` DateTo time.Time `json:"date_to"`
Overview DashboardOverview `json:"overview"` Overview DashboardOverview `json:"overview"`
@ -242,15 +237,12 @@ 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 {
@ -264,7 +256,6 @@ type ProfitLossAnalyticsRequest struct {
type ProfitLossAnalyticsResponse struct { type ProfitLossAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"` DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"` DateTo time.Time `json:"date_to"`
GroupBy string `json:"group_by"` GroupBy string `json:"group_by"`
@ -272,28 +263,10 @@ type ProfitLossAnalyticsResponse struct {
Data []ProfitLossData `json:"data"` Data []ProfitLossData `json:"data"`
ProductData []ProductProfitData `json:"product_data"` ProductData []ProductProfitData `json:"product_data"`
MainSummary []ProfitLossSummaryRow `json:"main_summary"` MainSummary []ProfitLossSummaryRow `json:"main_summary"`
Purchasing ProfitLossPurchasing `json:"purchasing"`
OperationalExpenses []OperationalExpenseItem `json:"operational_expenses"` OperationalExpenses []OperationalExpenseItem `json:"operational_expenses"`
OperationalExpensesTotal float64 `json:"operational_expenses_total"` OperationalExpensesTotal float64 `json:"operational_expenses_total"`
} }
type ProfitLossPurchasing struct {
TodayTotal float64 `json:"today_total"`
MtdTotal float64 `json:"mtd_total"`
TodayRawMaterial float64 `json:"today_raw_material"`
MtdRawMaterial float64 `json:"mtd_raw_material"`
TodayExpense float64 `json:"today_expense"`
MtdExpense float64 `json:"mtd_expense"`
Items []ProfitLossPurchasingItem `json:"items"`
}
type ProfitLossPurchasingItem struct {
Date time.Time `json:"date"`
Item string `json:"item"`
Quantity float64 `json:"quantity"`
Nominal float64 `json:"nominal"`
}
type ProfitLossSummary struct { type ProfitLossSummary struct {
TotalRevenue float64 `json:"total_revenue"` TotalRevenue float64 `json:"total_revenue"`
TotalCost float64 `json:"total_cost"` TotalCost float64 `json:"total_cost"`
@ -366,17 +339,9 @@ type ExclusiveSummaryMonthlyRequest struct {
Month string `form:"month" validate:"required"` Month string `form:"month" validate:"required"`
} }
type ExclusiveSummaryMTDRequest struct {
OrganizationID uuid.UUID
OutletID *string `form:"outlet_id,omitempty"`
DateTo string `form:"date_to" validate:"required"`
ExcludeGajiStaffFromReimburse bool `form:"exclude_gaji_staff_from_reimburse"`
}
type ExclusiveSummaryPeriodResponse struct { type ExclusiveSummaryPeriodResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
Period ExclusiveSummaryPeriodRange `json:"period"` Period ExclusiveSummaryPeriodRange `json:"period"`
Summary ExclusiveSummaryPeriodSummary `json:"summary"` Summary ExclusiveSummaryPeriodSummary `json:"summary"`
Reimburse ExclusiveSummaryReimburse `json:"reimburse"` Reimburse ExclusiveSummaryReimburse `json:"reimburse"`
@ -436,7 +401,6 @@ type ExclusiveSummaryDailyTransaction struct {
type ExclusiveSummaryMonthlyResponse struct { type ExclusiveSummaryMonthlyResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
Month string `json:"month"` Month string `json:"month"`
Summary ExclusiveSummaryMonthlySummary `json:"summary"` Summary ExclusiveSummaryMonthlySummary `json:"summary"`
Periods []ExclusiveSummaryMonthlyPeriod `json:"periods"` Periods []ExclusiveSummaryMonthlyPeriod `json:"periods"`

View File

@ -7,7 +7,7 @@ import (
) )
type CreatePurchaseOrderRequest struct { type CreatePurchaseOrderRequest struct {
VendorID *uuid.UUID `json:"vendor_id,omitempty" validate:"omitempty"` VendorID uuid.UUID `json:"vendor_id" validate:"required"`
PONumber string `json:"po_number" validate:"required,min=1,max=50"` PONumber string `json:"po_number" validate:"required,min=1,max=50"`
TransactionDate string `json:"transaction_date" validate:"required"` // Format: YYYY-MM-DD TransactionDate string `json:"transaction_date" validate:"required"` // Format: YYYY-MM-DD
DueDate *string `json:"due_date,omitempty" validate:"omitempty"` // Format: YYYY-MM-DD DueDate *string `json:"due_date,omitempty" validate:"omitempty"` // Format: YYYY-MM-DD
@ -52,8 +52,7 @@ type UpdatePurchaseOrderItemRequest struct {
type PurchaseOrderResponse struct { type PurchaseOrderResponse struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id"` VendorID uuid.UUID `json:"vendor_id"`
VendorID *uuid.UUID `json:"vendor_id"`
PONumber string `json:"po_number"` PONumber string `json:"po_number"`
TransactionDate time.Time `json:"transaction_date"` TransactionDate time.Time `json:"transaction_date"`
DueDate *time.Time `json:"due_date"` DueDate *time.Time `json:"due_date"`

View File

@ -12,14 +12,14 @@ type CreateUserRequest struct {
Name string `json:"name" validate:"required,min=1,max=255"` Name string `json:"name" validate:"required,min=1,max=255"`
Email string `json:"email" validate:"required,email"` Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required,min=6"` Password string `json:"password" validate:"required,min=6"`
Role string `json:"role" validate:"required,oneof=admin manager cashier waiter owner purchasing"` Role string `json:"role" validate:"required,oneof=admin manager cashier waiter"`
Permissions map[string]interface{} `json:"permissions,omitempty"` Permissions map[string]interface{} `json:"permissions,omitempty"`
} }
type UpdateUserRequest struct { type UpdateUserRequest struct {
Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"` Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"`
Email *string `json:"email,omitempty" validate:"omitempty,email"` Email *string `json:"email,omitempty" validate:"omitempty,email"`
Role *string `json:"role,omitempty" validate:"omitempty,oneof=admin manager cashier waiter owner purchasing"` Role *string `json:"role,omitempty" validate:"omitempty,oneof=admin manager cashier waiter"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
IsActive *bool `json:"is_active,omitempty"` IsActive *bool `json:"is_active,omitempty"`
Permissions *map[string]interface{} `json:"permissions,omitempty"` Permissions *map[string]interface{} `json:"permissions,omitempty"`

View File

@ -72,12 +72,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 {
@ -120,36 +120,19 @@ type DashboardOverview struct {
TotalCustomers int64 `json:"total_customers"` TotalCustomers int64 `json:"total_customers"`
VoidedOrders int64 `json:"voided_orders"` VoidedOrders int64 `json:"voided_orders"`
RefundedOrders int64 `json:"refunded_orders"` RefundedOrders int64 `json:"refunded_orders"`
TotalItemSold int64 `json:"total_item_sold"`
TotalLowStock int64 `json:"total_low_stock"`
TotalProductActive int64 `json:"total_product_active"`
} }
type ProfitLossAnalytics struct { type ProfitLossAnalytics struct {
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
TodayPurchasing float64 TodayExpenseByCategory []ExpenseCategoryTotal
MtdPurchasing float64 MtdExpenseByCategory []ExpenseCategoryTotal
TodayPurchasingRawMaterial float64 OperationalExpenseItems []OperationalExpenseItem
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 {

View File

@ -11,8 +11,7 @@ import (
type PurchaseOrder struct { type PurchaseOrder struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id" validate:"required"` OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id" validate:"required"`
OutletID *uuid.UUID `gorm:"type:uuid;index" json:"outlet_id" validate:"omitempty"` VendorID uuid.UUID `gorm:"type:uuid;not null" json:"vendor_id" validate:"required"`
VendorID *uuid.UUID `gorm:"type:uuid" json:"vendor_id" validate:"omitempty"`
PONumber string `gorm:"not null;size:50" json:"po_number" validate:"required,min=1,max=50"` PONumber string `gorm:"not null;size:50" json:"po_number" validate:"required,min=1,max=50"`
TransactionDate time.Time `gorm:"type:date;not null" json:"transaction_date" validate:"required"` TransactionDate time.Time `gorm:"type:date;not null" json:"transaction_date" validate:"required"`
DueDate *time.Time `gorm:"type:date" json:"due_date" validate:"omitempty"` DueDate *time.Time `gorm:"type:date" json:"due_date" validate:"omitempty"`
@ -24,7 +23,6 @@ type PurchaseOrder struct {
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
Organization *Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"` Organization *Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"`
Outlet *Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"`
Vendor *Vendor `gorm:"foreignKey:VendorID" json:"vendor,omitempty"` Vendor *Vendor `gorm:"foreignKey:VendorID" json:"vendor,omitempty"`
Items []PurchaseOrderItem `gorm:"foreignKey:PurchaseOrderID" json:"items,omitempty"` Items []PurchaseOrderItem `gorm:"foreignKey:PurchaseOrderID" json:"items,omitempty"`
Attachments []PurchaseOrderAttachment `gorm:"foreignKey:PurchaseOrderID" json:"attachments,omitempty"` Attachments []PurchaseOrderAttachment `gorm:"foreignKey:PurchaseOrderID" json:"attachments,omitempty"`

View File

@ -13,12 +13,10 @@ 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{}
@ -48,7 +46,7 @@ type User struct {
Name string `gorm:"not null;size:255" json:"name" validate:"required,min=1,max=255"` Name string `gorm:"not null;size:255" json:"name" validate:"required,min=1,max=255"`
Email string `gorm:"uniqueIndex;not null;size:255" json:"email" validate:"required,email"` Email string `gorm:"uniqueIndex;not null;size:255" json:"email" validate:"required,email"`
PasswordHash string `gorm:"not null;size:255" json:"-"` PasswordHash string `gorm:"not null;size:255" json:"-"`
Role UserRole `gorm:"not null;size:50" json:"role" validate:"required,oneof=admin manager cashier waiter owner purchasing"` Role UserRole `gorm:"not null;size:50" json:"role" validate:"required,oneof=admin manager cashier waiter"`
Permissions Permissions `gorm:"type:jsonb;default:'{}'" json:"permissions"` Permissions Permissions `gorm:"type:jsonb;default:'{}'" json:"permissions"`
IsActive bool `gorm:"default:true" json:"is_active"` IsActive bool `gorm:"default:true" json:"is_active"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`

View File

@ -266,31 +266,3 @@ func (h *AnalyticsHandler) GetExclusiveSummaryMonthly(c *gin.Context) {
contractResp := transformer.ExclusiveSummaryMonthlyModelToContract(response) contractResp := transformer.ExclusiveSummaryMonthlyModelToContract(response)
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetExclusiveSummaryMonthly") util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetExclusiveSummaryMonthly")
} }
func (h *AnalyticsHandler) GetExclusiveSummaryMTD(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
var req contract.ExclusiveSummaryMTDRequest
if err := c.ShouldBindQuery(&req); err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetExclusiveSummaryMTD", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryMTD")
return
}
req.OrganizationID = contextInfo.OrganizationID
req.OutletID = h.resolveOutletID(c, contextInfo.OutletID)
modelReq, err := transformer.ExclusiveSummaryMTDContractToModel(&req)
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetExclusiveSummaryMTD", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryMTD")
return
}
response, err := h.analyticsService.GetExclusiveSummaryMTD(ctx, modelReq)
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "AnalyticsHandler::GetExclusiveSummaryMTD", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryMTD")
return
}
contractResp := transformer.ExclusiveSummaryPeriodModelToContract(response)
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetExclusiveSummaryMTD")
}

View File

@ -66,35 +66,3 @@ func (h *ReportHandler) GetDailyTransactionReportPDF(c *gin.Context) {
"file_name": fileName, "file_name": fileName,
}), "ReportHandler::GetDailyTransactionReportPDF") }), "ReportHandler::GetDailyTransactionReportPDF")
} }
func (h *ReportHandler) GetProfitLossReportPDF(c *gin.Context) {
ctx := c.Request.Context()
ci := appcontext.FromGinContext(ctx)
outletID := h.resolveOutletID(c, ci.OutletID)
var dayPtr *time.Time
if d := c.Query("date"); d != "" {
if t, err := time.Parse("2006-01-02", d); err == nil {
dayPtr = &t
}
}
user, err := h.userService.GetUserByID(ctx, ci.UserID)
var genBy string
if err != nil {
genBy = ci.UserID.String()
} else {
genBy = user.Name
}
publicURL, fileName, err := h.reportService.GenerateProfitLossPDF(ctx, ci.OrganizationID.String(), outletID, dayPtr, genBy)
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "ReportHandler::GetProfitLossReportPDF", err.Error())}), "ReportHandler::GetProfitLossReportPDF")
return
}
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(map[string]string{
"url": publicURL,
"file_name": fileName,
}), "ReportHandler::GetProfitLossReportPDF")
}

View File

@ -13,7 +13,6 @@ func PurchaseOrderEntityToModel(entity *entities.PurchaseOrder) *models.Purchase
return &models.PurchaseOrder{ return &models.PurchaseOrder{
ID: entity.ID, ID: entity.ID,
OrganizationID: entity.OrganizationID, OrganizationID: entity.OrganizationID,
OutletID: entity.OutletID,
VendorID: entity.VendorID, VendorID: entity.VendorID,
PONumber: entity.PONumber, PONumber: entity.PONumber,
TransactionDate: entity.TransactionDate, TransactionDate: entity.TransactionDate,
@ -35,7 +34,6 @@ func PurchaseOrderModelToEntity(model *models.PurchaseOrder) *entities.PurchaseO
return &entities.PurchaseOrder{ return &entities.PurchaseOrder{
ID: model.ID, ID: model.ID,
OrganizationID: model.OrganizationID, OrganizationID: model.OrganizationID,
OutletID: model.OutletID,
VendorID: model.VendorID, VendorID: model.VendorID,
PONumber: model.PONumber, PONumber: model.PONumber,
TransactionDate: model.TransactionDate, TransactionDate: model.TransactionDate,
@ -57,7 +55,6 @@ func PurchaseOrderEntityToResponse(entity *entities.PurchaseOrder) *models.Purch
response := &models.PurchaseOrderResponse{ response := &models.PurchaseOrderResponse{
ID: entity.ID, ID: entity.ID,
OrganizationID: entity.OrganizationID, OrganizationID: entity.OrganizationID,
OutletID: entity.OutletID,
VendorID: entity.VendorID, VendorID: entity.VendorID,
PONumber: entity.PONumber, PONumber: entity.PONumber,
TransactionDate: entity.TransactionDate, TransactionDate: entity.TransactionDate,

View File

@ -82,11 +82,7 @@ func (m *AuthMiddleware) RequireRole(allowedRoles ...string) gin.HandlerFunc {
} }
func (m *AuthMiddleware) RequireAdminOrManager() gin.HandlerFunc { func (m *AuthMiddleware) RequireAdminOrManager() gin.HandlerFunc {
return m.RequireRole("superadmin", "admin", "manager", "owner", "purchasing") return m.RequireRole("superadmin", "admin", "manager")
}
func (m *AuthMiddleware) RequireAdminOrManagerOrPurchasing() gin.HandlerFunc {
return m.RequireRole("superadmin", "admin", "manager", "owner", "purchasing")
} }
func (m *AuthMiddleware) RequireAdmin() gin.HandlerFunc { func (m *AuthMiddleware) RequireAdmin() gin.HandlerFunc {

View File

@ -19,7 +19,6 @@ type PaymentMethodAnalyticsRequest struct {
type PaymentMethodAnalyticsResponse struct { type PaymentMethodAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"` DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"` DateTo time.Time `json:"date_to"`
GroupBy string `json:"group_by"` GroupBy string `json:"group_by"`
@ -59,7 +58,6 @@ type SalesAnalyticsRequest struct {
type SalesAnalyticsResponse struct { type SalesAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"` DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"` DateTo time.Time `json:"date_to"`
GroupBy string `json:"group_by"` GroupBy string `json:"group_by"`
@ -152,12 +150,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
@ -173,7 +171,6 @@ type ProductAnalyticsRequest struct {
type ProductAnalyticsResponse struct { type ProductAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"` DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"` DateTo time.Time `json:"date_to"`
Data []ProductAnalyticsData `json:"data"` Data []ProductAnalyticsData `json:"data"`
@ -211,7 +208,6 @@ type ProductAnalyticsPerCategoryRequest struct {
type ProductAnalyticsPerCategoryResponse struct { type ProductAnalyticsPerCategoryResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"` DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"` DateTo time.Time `json:"date_to"`
Data []ProductAnalyticsPerCategoryData `json:"data"` Data []ProductAnalyticsPerCategoryData `json:"data"`
@ -241,7 +237,6 @@ type DashboardAnalyticsRequest struct {
type DashboardAnalyticsResponse struct { type DashboardAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"` DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"` DateTo time.Time `json:"date_to"`
Overview DashboardOverview `json:"overview"` Overview DashboardOverview `json:"overview"`
@ -252,15 +247,12 @@ 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 {
@ -274,7 +266,6 @@ type ProfitLossAnalyticsRequest struct {
type ProfitLossAnalyticsResponse struct { type ProfitLossAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"` DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"` DateTo time.Time `json:"date_to"`
GroupBy string `json:"group_by"` GroupBy string `json:"group_by"`
@ -282,28 +273,10 @@ type ProfitLossAnalyticsResponse struct {
Data []ProfitLossData `json:"data"` Data []ProfitLossData `json:"data"`
ProductData []ProductProfitData `json:"product_data"` ProductData []ProductProfitData `json:"product_data"`
MainSummary []ProfitLossSummaryRow `json:"main_summary"` MainSummary []ProfitLossSummaryRow `json:"main_summary"`
Purchasing ProfitLossPurchasing `json:"purchasing"`
OperationalExpenses []OperationalExpenseItem `json:"operational_expenses"` OperationalExpenses []OperationalExpenseItem `json:"operational_expenses"`
OperationalExpensesTotal float64 `json:"operational_expenses_total"` OperationalExpensesTotal float64 `json:"operational_expenses_total"`
} }
type ProfitLossPurchasing struct {
TodayTotal float64 `json:"today_total"`
MtdTotal float64 `json:"mtd_total"`
TodayRawMaterial float64 `json:"today_raw_material"`
MtdRawMaterial float64 `json:"mtd_raw_material"`
TodayExpense float64 `json:"today_expense"`
MtdExpense float64 `json:"mtd_expense"`
Items []ProfitLossPurchasingItem `json:"items"`
}
type ProfitLossPurchasingItem struct {
Date time.Time `json:"date"`
Item string `json:"item"`
Quantity float64 `json:"quantity"`
Nominal float64 `json:"nominal"`
}
type ProfitLossSummary struct { type ProfitLossSummary struct {
TotalRevenue float64 `json:"total_revenue"` TotalRevenue float64 `json:"total_revenue"`
TotalCost float64 `json:"total_cost"` TotalCost float64 `json:"total_cost"`
@ -376,17 +349,9 @@ type ExclusiveSummaryMonthlyRequest struct {
Month time.Time `validate:"required"` Month time.Time `validate:"required"`
} }
type ExclusiveSummaryMTDRequest struct {
OrganizationID uuid.UUID `validate:"required"`
OutletID *uuid.UUID `validate:"omitempty"`
DateTo time.Time `validate:"required"`
ExcludeGajiStaffFromReimburse bool `validate:"omitempty"`
}
type ExclusiveSummaryPeriodResponse struct { type ExclusiveSummaryPeriodResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
Period ExclusiveSummaryPeriodRange `json:"period"` Period ExclusiveSummaryPeriodRange `json:"period"`
Summary ExclusiveSummaryPeriodSummary `json:"summary"` Summary ExclusiveSummaryPeriodSummary `json:"summary"`
Reimburse ExclusiveSummaryReimburse `json:"reimburse"` Reimburse ExclusiveSummaryReimburse `json:"reimburse"`
@ -446,7 +411,6 @@ type ExclusiveSummaryDailyTransaction struct {
type ExclusiveSummaryMonthlyResponse struct { type ExclusiveSummaryMonthlyResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
Month string `json:"month"` Month string `json:"month"`
Summary ExclusiveSummaryMonthlySummary `json:"summary"` Summary ExclusiveSummaryMonthlySummary `json:"summary"`
Periods []ExclusiveSummaryMonthlyPeriod `json:"periods"` Periods []ExclusiveSummaryMonthlyPeriod `json:"periods"`

View File

@ -9,8 +9,7 @@ import (
type PurchaseOrder struct { type PurchaseOrder struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id"` VendorID uuid.UUID `json:"vendor_id"`
VendorID *uuid.UUID `json:"vendor_id"`
PONumber string `json:"po_number"` PONumber string `json:"po_number"`
TransactionDate time.Time `json:"transaction_date"` TransactionDate time.Time `json:"transaction_date"`
DueDate *time.Time `json:"due_date"` DueDate *time.Time `json:"due_date"`
@ -45,8 +44,7 @@ type PurchaseOrderAttachment struct {
type PurchaseOrderResponse struct { type PurchaseOrderResponse struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id"` VendorID uuid.UUID `json:"vendor_id"`
VendorID *uuid.UUID `json:"vendor_id"`
PONumber string `json:"po_number"` PONumber string `json:"po_number"`
TransactionDate time.Time `json:"transaction_date"` TransactionDate time.Time `json:"transaction_date"`
DueDate *time.Time `json:"due_date"` DueDate *time.Time `json:"due_date"`
@ -86,8 +84,7 @@ type PurchaseOrderAttachmentResponse struct {
} }
type CreatePurchaseOrderRequest struct { type CreatePurchaseOrderRequest struct {
VendorID *uuid.UUID `json:"vendor_id,omitempty"` VendorID uuid.UUID `json:"vendor_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
PONumber string `json:"po_number"` PONumber string `json:"po_number"`
TransactionDate time.Time `json:"transaction_date"` TransactionDate time.Time `json:"transaction_date"`
DueDate *time.Time `json:"due_date,omitempty"` DueDate *time.Time `json:"due_date,omitempty"`

View File

@ -63,12 +63,10 @@ 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.RolePurchasing: 3, constants.RoleManager: 3,
constants.RoleManager: 4, constants.RoleAdmin: 4,
constants.RoleAdmin: 5,
constants.RoleOwner: 6,
} }
userLevel := roleHierarchy[u.Role] userLevel := roleHierarchy[u.Role]

View File

@ -9,8 +9,6 @@ import (
"apskel-pos-be/internal/entities" "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 {
@ -23,7 +21,6 @@ type AnalyticsProcessor interface {
GetProfitLossAnalytics(ctx context.Context, req *models.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsResponse, error) GetProfitLossAnalytics(ctx context.Context, req *models.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsResponse, error)
GetExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error) GetExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error)
GetExclusiveSummaryMonthly(ctx context.Context, req *models.ExclusiveSummaryMonthlyRequest) (*models.ExclusiveSummaryMonthlyResponse, error) GetExclusiveSummaryMonthly(ctx context.Context, req *models.ExclusiveSummaryMonthlyRequest) (*models.ExclusiveSummaryMonthlyResponse, error)
GetExclusiveSummaryMTD(ctx context.Context, req *models.ExclusiveSummaryMTDRequest) (*models.ExclusiveSummaryPeriodResponse, error)
} }
type AnalyticsProcessorImpl struct { type AnalyticsProcessorImpl struct {
@ -38,18 +35,6 @@ func NewAnalyticsProcessorImpl(analyticsRepo repository.AnalyticsRepository, exp
} }
} }
// resolveOutletName fetches the outlet name from the database if outletID is provided
func (p *AnalyticsProcessorImpl) resolveOutletName(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) *string {
if outletID == nil {
return nil
}
name, err := p.analyticsRepo.GetOutletName(ctx, organizationID, *outletID)
if err != nil || name == "" {
return nil
}
return &name
}
func (p *AnalyticsProcessorImpl) GetPaymentMethodAnalytics(ctx context.Context, req *models.PaymentMethodAnalyticsRequest) (*models.PaymentMethodAnalyticsResponse, error) { func (p *AnalyticsProcessorImpl) GetPaymentMethodAnalytics(ctx context.Context, req *models.PaymentMethodAnalyticsRequest) (*models.PaymentMethodAnalyticsResponse, error) {
if req.DateFrom.After(req.DateTo) { if req.DateFrom.After(req.DateTo) {
return nil, fmt.Errorf("date_from cannot be after date_to") return nil, fmt.Errorf("date_from cannot be after date_to")
@ -104,7 +89,6 @@ func (p *AnalyticsProcessorImpl) GetPaymentMethodAnalytics(ctx context.Context,
return &models.PaymentMethodAnalyticsResponse{ return &models.PaymentMethodAnalyticsResponse{
OrganizationID: req.OrganizationID, OrganizationID: req.OrganizationID,
OutletID: req.OutletID, OutletID: req.OutletID,
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
DateFrom: req.DateFrom, DateFrom: req.DateFrom,
DateTo: req.DateTo, DateTo: req.DateTo,
GroupBy: req.GroupBy, GroupBy: req.GroupBy,
@ -179,7 +163,6 @@ func (p *AnalyticsProcessorImpl) GetSalesAnalytics(ctx context.Context, req *mod
return &models.SalesAnalyticsResponse{ return &models.SalesAnalyticsResponse{
OrganizationID: req.OrganizationID, OrganizationID: req.OrganizationID,
OutletID: req.OutletID, OutletID: req.OutletID,
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
DateFrom: req.DateFrom, DateFrom: req.DateFrom,
DateTo: req.DateTo, DateTo: req.DateTo,
GroupBy: req.GroupBy, GroupBy: req.GroupBy,
@ -311,7 +294,6 @@ func (p *AnalyticsProcessorImpl) GetProductAnalytics(ctx context.Context, req *m
return &models.ProductAnalyticsResponse{ return &models.ProductAnalyticsResponse{
OrganizationID: req.OrganizationID, OrganizationID: req.OrganizationID,
OutletID: req.OutletID, OutletID: req.OutletID,
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
DateFrom: req.DateFrom, DateFrom: req.DateFrom,
DateTo: req.DateTo, DateTo: req.DateTo,
Data: resultData, Data: resultData,
@ -349,7 +331,6 @@ func (p *AnalyticsProcessorImpl) GetProductAnalyticsPerCategory(ctx context.Cont
return &models.ProductAnalyticsPerCategoryResponse{ return &models.ProductAnalyticsPerCategoryResponse{
OrganizationID: req.OrganizationID, OrganizationID: req.OrganizationID,
OutletID: req.OutletID, OutletID: req.OutletID,
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
DateFrom: req.DateFrom, DateFrom: req.DateFrom,
DateTo: req.DateTo, DateTo: req.DateTo,
Data: resultData, Data: resultData,
@ -411,19 +392,15 @@ 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,
@ -626,20 +603,9 @@ func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req
opsTotal += item.Amount opsTotal += item.Amount
} }
purchasingItems := make([]models.ProfitLossPurchasingItem, len(result.PurchasingItems))
for i, item := range result.PurchasingItems {
purchasingItems[i] = models.ProfitLossPurchasingItem{
Date: item.Date,
Item: item.Item,
Quantity: item.Quantity,
Nominal: item.Amount,
}
}
return &models.ProfitLossAnalyticsResponse{ return &models.ProfitLossAnalyticsResponse{
OrganizationID: req.OrganizationID, OrganizationID: req.OrganizationID,
OutletID: req.OutletID, OutletID: req.OutletID,
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
DateFrom: req.DateFrom, DateFrom: req.DateFrom,
DateTo: req.DateTo, DateTo: req.DateTo,
GroupBy: req.GroupBy, GroupBy: req.GroupBy,
@ -656,18 +622,9 @@ 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
@ -763,7 +720,6 @@ func (p *AnalyticsProcessorImpl) GetExclusiveSummaryMonthly(ctx context.Context,
return &models.ExclusiveSummaryMonthlyResponse{ return &models.ExclusiveSummaryMonthlyResponse{
OrganizationID: req.OrganizationID, OrganizationID: req.OrganizationID,
OutletID: req.OutletID, OutletID: req.OutletID,
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
Month: monthStart.Format("2006-01"), Month: monthStart.Format("2006-01"),
Summary: models.ExclusiveSummaryMonthlySummary{ Summary: models.ExclusiveSummaryMonthlySummary{
TotalSales: fullPeriod.Summary.Sales, TotalSales: fullPeriod.Summary.Sales,
@ -779,18 +735,6 @@ func (p *AnalyticsProcessorImpl) GetExclusiveSummaryMonthly(ctx context.Context,
}, nil }, 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) { 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) result, err := p.analyticsRepo.GetExclusiveSummaryAnalytics(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo)
if err != nil { if err != nil {
@ -838,7 +782,6 @@ func (p *AnalyticsProcessorImpl) buildExclusiveSummaryPeriod(ctx context.Context
return &models.ExclusiveSummaryPeriodResponse{ return &models.ExclusiveSummaryPeriodResponse{
OrganizationID: req.OrganizationID, OrganizationID: req.OrganizationID,
OutletID: req.OutletID, OutletID: req.OutletID,
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
Period: models.ExclusiveSummaryPeriodRange{ Period: models.ExclusiveSummaryPeriodRange{
DateFrom: req.DateFrom, DateFrom: req.DateFrom,
DateTo: req.DateTo, DateTo: req.DateTo,

View File

@ -19,8 +19,6 @@ type analyticsRepositoryStub struct {
bankBalances []entities.ExclusiveSummaryBankBalance bankBalances []entities.ExclusiveSummaryBankBalance
profitLossGroup string profitLossGroup string
exclusiveSummaryCalls int exclusiveSummaryCalls int
exclusiveSummaryFrom []time.Time
exclusiveSummaryTo []time.Time
} }
func (analyticsRepositoryStub) GetPaymentMethodAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.PaymentMethodAnalytics, error) { func (analyticsRepositoryStub) GetPaymentMethodAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.PaymentMethodAnalytics, error) {
@ -52,9 +50,7 @@ func (s analyticsRepositoryStub) GetProfitLossAnalytics(_ context.Context, _ uui
return s.profitLossResult, nil return s.profitLossResult, nil
} }
func (s *analyticsRepositoryStub) GetExclusiveSummaryAnalytics(_ context.Context, _ uuid.UUID, _ *uuid.UUID, dateFrom, dateTo time.Time) (*entities.ExclusiveSummaryAnalytics, error) { func (s *analyticsRepositoryStub) GetExclusiveSummaryAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) (*entities.ExclusiveSummaryAnalytics, error) {
s.exclusiveSummaryFrom = append(s.exclusiveSummaryFrom, dateFrom)
s.exclusiveSummaryTo = append(s.exclusiveSummaryTo, dateTo)
if s.exclusiveSummaryCalls < len(s.exclusiveSummaryResults) { if s.exclusiveSummaryCalls < len(s.exclusiveSummaryResults) {
result := s.exclusiveSummaryResults[s.exclusiveSummaryCalls] result := s.exclusiveSummaryResults[s.exclusiveSummaryCalls]
s.exclusiveSummaryCalls++ s.exclusiveSummaryCalls++
@ -68,10 +64,6 @@ func (s *analyticsRepositoryStub) GetExclusiveSummaryBankBalances(context.Contex
return s.bankBalances, nil return s.bankBalances, nil
} }
func (analyticsRepositoryStub) GetOutletName(context.Context, uuid.UUID, uuid.UUID) (string, error) {
return "", nil
}
type expenseRepositoryStub struct{} type expenseRepositoryStub struct{}
func (expenseRepositoryStub) Create(context.Context, *entities.Expense) error { return nil } func (expenseRepositoryStub) Create(context.Context, *entities.Expense) error { return nil }
@ -401,52 +393,3 @@ func TestAnalyticsProcessorGetExclusiveSummaryMonthlyBuildsSummaryAndBuckets(t *
require.Equal(t, notes, *result.BankBalance[0].Notes) require.Equal(t, notes, *result.BankBalance[0].Notes)
require.Equal(t, 6, stub.exclusiveSummaryCalls) 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)
}

View File

@ -11,8 +11,8 @@ import (
) )
type PurchaseOrderProcessor interface { type PurchaseOrderProcessor interface {
CreatePurchaseOrder(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, req *models.CreatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error) CreatePurchaseOrder(ctx context.Context, organizationID uuid.UUID, req *models.CreatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error)
UpdatePurchaseOrder(ctx context.Context, id, organizationID uuid.UUID, outletID *uuid.UUID, req *models.UpdatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error) UpdatePurchaseOrder(ctx context.Context, id, organizationID uuid.UUID, req *models.UpdatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error)
DeletePurchaseOrder(ctx context.Context, id, organizationID uuid.UUID) error DeletePurchaseOrder(ctx context.Context, id, organizationID uuid.UUID) error
GetPurchaseOrderByID(ctx context.Context, id, organizationID uuid.UUID) (*models.PurchaseOrderResponse, error) GetPurchaseOrderByID(ctx context.Context, id, organizationID uuid.UUID) (*models.PurchaseOrderResponse, error)
ListPurchaseOrders(ctx context.Context, organizationID uuid.UUID, filters map[string]interface{}, page, limit int) ([]*models.PurchaseOrderResponse, int, error) ListPurchaseOrders(ctx context.Context, organizationID uuid.UUID, filters map[string]interface{}, page, limit int) ([]*models.PurchaseOrderResponse, int, error)
@ -54,13 +54,11 @@ func NewPurchaseOrderProcessorImpl(
} }
} }
func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, req *models.CreatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error) { func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, organizationID uuid.UUID, req *models.CreatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error) {
// Check if vendor exists and belongs to organization when provided. // Check if vendor exists and belongs to organization
if req.VendorID != nil { _, err := p.vendorRepo.GetByIDAndOrganizationID(ctx, req.VendorID, organizationID)
_, err := p.vendorRepo.GetByIDAndOrganizationID(ctx, *req.VendorID, organizationID) if err != nil {
if err != nil { return nil, fmt.Errorf("vendor not found: %w", err)
return nil, fmt.Errorf("vendor not found: %w", err)
}
} }
// Check if PO number already exists in organization // Check if PO number already exists in organization
@ -109,13 +107,12 @@ func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, or
// Calculate total amount // Calculate total amount
totalAmount := 0.0 totalAmount := 0.0
for _, item := range req.Items { for _, item := range req.Items {
totalAmount += calculatePurchaseOrderItemTotal(item.Quantity, item.Amount) totalAmount += item.Amount
} }
// Create purchase order entity // Create purchase order entity
poEntity := &entities.PurchaseOrder{ poEntity := &entities.PurchaseOrder{
OrganizationID: organizationID, OrganizationID: organizationID,
OutletID: outletID,
VendorID: req.VendorID, VendorID: req.VendorID,
PONumber: req.PONumber, PONumber: req.PONumber,
TransactionDate: req.TransactionDate, TransactionDate: req.TransactionDate,
@ -176,15 +173,12 @@ func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, or
return mappers.PurchaseOrderEntityToResponse(createdPO), nil return mappers.PurchaseOrderEntityToResponse(createdPO), nil
} }
func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id, organizationID uuid.UUID, outletID *uuid.UUID, req *models.UpdatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error) { func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id, organizationID uuid.UUID, req *models.UpdatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error) {
// Get existing purchase order // Get existing purchase order
poEntity, err := p.purchaseOrderRepo.GetByIDAndOrganizationID(ctx, id, organizationID) poEntity, err := p.purchaseOrderRepo.GetByIDAndOrganizationID(ctx, id, organizationID)
if err != nil { if err != nil {
return nil, fmt.Errorf("purchase order not found: %w", err) return nil, fmt.Errorf("purchase order not found: %w", err)
} }
if poEntity.OutletID == nil && outletID != nil {
poEntity.OutletID = outletID
}
// Check if vendor exists and belongs to organization (if vendor is being updated) // Check if vendor exists and belongs to organization (if vendor is being updated)
if req.VendorID != nil { if req.VendorID != nil {
@ -192,7 +186,7 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id
if err != nil { if err != nil {
return nil, fmt.Errorf("vendor not found: %w", err) return nil, fmt.Errorf("vendor not found: %w", err)
} }
poEntity.VendorID = req.VendorID poEntity.VendorID = *req.VendorID
} }
// Check if PO number already exists (if PO number is being updated) // Check if PO number already exists (if PO number is being updated)
@ -283,7 +277,7 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id
UnitID: unitID, UnitID: unitID,
Amount: amount, Amount: amount,
} }
totalAmount += calculatePurchaseOrderItemTotal(quantity, amount) totalAmount += amount
} }
// Delete and recreate only after all replacement items are valid. // Delete and recreate only after all replacement items are valid.
@ -453,7 +447,7 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte
// Calculate unit cost in ingredient's base unit // Calculate unit cost in ingredient's base unit
unitCost := 0.0 unitCost := 0.0
if quantityToAdd > 0 { if quantityToAdd > 0 {
unitCost = calculatePurchaseOrderItemTotal(item.Quantity, item.Amount) / quantityToAdd unitCost = item.Amount / quantityToAdd
} }
// Create inventory movement for ingredient purchase // Create inventory movement for ingredient purchase
@ -482,12 +476,7 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte
} }
// Update the purchase order status // Update the purchase order status
statusOutletID := po.OutletID err = p.purchaseOrderRepo.UpdateStatus(ctx, id, status)
if statusOutletID == nil && outletID != uuid.Nil {
statusOutletID = &outletID
}
err = p.purchaseOrderRepo.UpdateStatusAndOutlet(ctx, id, status, statusOutletID)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to update purchase order status: %w", err) return nil, fmt.Errorf("failed to update purchase order status: %w", err)
} }
@ -517,11 +506,3 @@ func (p *PurchaseOrderProcessorImpl) validatePurchaseCategory(ctx context.Contex
return category, nil return category, nil
} }
func calculatePurchaseOrderItemTotal(quantity *float64, amount float64) float64 {
if quantity == nil {
return amount
}
return *quantity * amount
}

View File

@ -19,7 +19,6 @@ type PurchaseOrderRepository interface {
GetByStatus(ctx context.Context, organizationID uuid.UUID, status string) ([]*entities.PurchaseOrder, error) GetByStatus(ctx context.Context, organizationID uuid.UUID, status string) ([]*entities.PurchaseOrder, error)
GetOverdue(ctx context.Context, organizationID uuid.UUID) ([]*entities.PurchaseOrder, error) GetOverdue(ctx context.Context, organizationID uuid.UUID) ([]*entities.PurchaseOrder, error)
UpdateStatus(ctx context.Context, id uuid.UUID, status string) error UpdateStatus(ctx context.Context, id uuid.UUID, status string) error
UpdateStatusAndOutlet(ctx context.Context, id uuid.UUID, status string, outletID *uuid.UUID) error
UpdateTotalAmount(ctx context.Context, id uuid.UUID, totalAmount float64) error UpdateTotalAmount(ctx context.Context, id uuid.UUID, totalAmount float64) error
CreateItem(ctx context.Context, item *entities.PurchaseOrderItem) error CreateItem(ctx context.Context, item *entities.PurchaseOrderItem) error
UpdateItem(ctx context.Context, item *entities.PurchaseOrderItem) error UpdateItem(ctx context.Context, item *entities.PurchaseOrderItem) error

View File

@ -2,7 +2,6 @@ package repository
import ( import (
"context" "context"
"sort"
"time" "time"
"apskel-pos-be/internal/entities" "apskel-pos-be/internal/entities"
@ -21,7 +20,6 @@ type AnalyticsRepository interface {
GetProfitLossAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) (*entities.ProfitLossAnalytics, error) GetProfitLossAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) (*entities.ProfitLossAnalytics, error)
GetExclusiveSummaryAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (*entities.ExclusiveSummaryAnalytics, error) GetExclusiveSummaryAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (*entities.ExclusiveSummaryAnalytics, error)
GetExclusiveSummaryBankBalances(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) ([]entities.ExclusiveSummaryBankBalance, error) GetExclusiveSummaryBankBalances(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) ([]entities.ExclusiveSummaryBankBalance, error)
GetOutletName(ctx context.Context, organizationID uuid.UUID, outletID uuid.UUID) (string, error)
} }
type AnalyticsRepositoryImpl struct { type AnalyticsRepositoryImpl struct {
@ -41,34 +39,6 @@ func (r *AnalyticsRepositoryImpl) resolveOutletID(query *gorm.DB, outletID *uuid
return query return query
} }
func (r *AnalyticsRepositoryImpl) GetOutletName(ctx context.Context, organizationID uuid.UUID, outletID uuid.UUID) (string, error) {
var outlet struct {
Name string
}
result := r.db.WithContext(ctx).
Table("outlets").
Select("name").
Where("id = ? AND organization_id = ?", outletID, organizationID).
Limit(1).
Scan(&outlet)
if result.Error != nil {
return "", result.Error
}
return outlet.Name, nil
}
func purchaseOrderItemTotalAmountSQL() string {
return "CASE WHEN pc.type = '" + string(entities.PurchaseCategoryTypeRawMaterial) + "' THEN COALESCE(poi.quantity, 0) * poi.amount ELSE poi.amount END"
}
func purchaseOrderRawMaterialAmountSQL() string {
return "CASE WHEN pc.type = '" + string(entities.PurchaseCategoryTypeRawMaterial) + "' THEN " + purchaseOrderItemTotalAmountSQL() + " ELSE 0 END"
}
func purchaseOrderExpenseAmountSQL() string {
return "CASE WHEN pc.type = '" + string(entities.PurchaseCategoryTypeExpense) + "' THEN " + purchaseOrderItemTotalAmountSQL() + " ELSE 0 END"
}
func (r *AnalyticsRepositoryImpl) GetPaymentMethodAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]*entities.PaymentMethodAnalytics, error) { func (r *AnalyticsRepositoryImpl) GetPaymentMethodAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]*entities.PaymentMethodAnalytics, error) {
var results []*entities.PaymentMethodAnalytics var results []*entities.PaymentMethodAnalytics
@ -183,23 +153,18 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex
summaryQuery := r.db.WithContext(ctx). summaryQuery := r.db.WithContext(ctx).
Table("purchase_orders po"). Table("purchase_orders po").
Select(` Select(`
COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as total_purchases, COALESCE(SUM(poi.amount), 0) as total_purchases,
COALESCE(SUM(`+purchaseOrderRawMaterialAmountSQL()+`), 0) as raw_material_purchases,
COALESCE(SUM(`+purchaseOrderExpenseAmountSQL()+`), 0) as expense_purchases,
COUNT(DISTINCT po.id) as total_purchase_orders, COUNT(DISTINCT po.id) as total_purchase_orders,
COUNT(DISTINCT CASE WHEN pc.type = '`+string(entities.PurchaseCategoryTypeRawMaterial)+`' THEN po.id END) as raw_material_purchase_orders,
COUNT(CASE WHEN pc.type = '`+string(entities.PurchaseCategoryTypeExpense)+`' THEN poi.id END) as expense_count,
COALESCE(SUM(poi.quantity), 0) as total_quantity, COALESCE(SUM(poi.quantity), 0) as total_quantity,
CASE CASE
WHEN COUNT(DISTINCT po.id) > 0 WHEN COUNT(DISTINCT po.id) > 0
THEN COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) / COUNT(DISTINCT po.id) THEN COALESCE(SUM(poi.amount), 0) / COUNT(DISTINCT po.id)
ELSE 0 ELSE 0
END as average_purchase_order_value, END as average_purchase_order_value,
COUNT(DISTINCT i.id) as total_ingredients, COUNT(DISTINCT i.id) as total_ingredients,
COUNT(DISTINCT COALESCE(po.vendor_id::text, 'no-vendor')) as total_vendors COUNT(DISTINCT po.vendor_id) as total_vendors
`). `).
Joins("LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id"). Joins("LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id").
Joins("LEFT JOIN purchase_categories pc ON poi.purchase_category_id = pc.id").
Joins("LEFT JOIN ingredients i ON poi.ingredient_id = i.id"). Joins("LEFT JOIN ingredients i ON poi.ingredient_id = i.id").
Joins("LEFT JOIN units u ON poi.unit_id = u.id"). Joins("LEFT JOIN units u ON poi.unit_id = u.id").
Where("po.organization_id = ?", organizationID). Where("po.organization_id = ?", organizationID).
@ -228,23 +193,17 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex
Table("purchase_orders po"). Table("purchase_orders po").
Select(` Select(`
`+dateFormat+` as date, `+dateFormat+` as date,
COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as purchases, COALESCE(SUM(poi.amount), 0) as purchases,
COALESCE(SUM(`+purchaseOrderRawMaterialAmountSQL()+`), 0) as raw_material_purchases,
COALESCE(SUM(`+purchaseOrderExpenseAmountSQL()+`), 0) as expense_purchases,
COUNT(DISTINCT po.id) as purchase_orders, COUNT(DISTINCT po.id) as purchase_orders,
COUNT(DISTINCT CASE WHEN pc.type = '`+string(entities.PurchaseCategoryTypeRawMaterial)+`' THEN po.id END) as raw_material_purchase_orders,
COUNT(CASE WHEN pc.type = '`+string(entities.PurchaseCategoryTypeExpense)+`' THEN poi.id END) as expense_count,
COALESCE(SUM(poi.quantity), 0) as quantity, COALESCE(SUM(poi.quantity), 0) as quantity,
COUNT(DISTINCT i.id) as ingredients, COUNT(DISTINCT i.id) as ingredients,
COUNT(DISTINCT COALESCE(po.vendor_id::text, 'no-vendor')) as vendors COUNT(DISTINCT po.vendor_id) as vendors
`). `).
Joins("LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id"). Joins("LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id").
Joins("LEFT JOIN purchase_categories pc ON poi.purchase_category_id = pc.id").
Joins("LEFT JOIN ingredients i ON poi.ingredient_id = i.id"). Joins("LEFT JOIN ingredients i ON poi.ingredient_id = i.id").
Joins("LEFT JOIN units u ON poi.unit_id = u.id"). Joins("LEFT JOIN units u ON poi.unit_id = u.id").
Where("po.organization_id = ?", organizationID). Where("po.organization_id = ?", organizationID).
Where("po.status != ?", "cancelled"). Where("po.status != ?", "cancelled").
Where("pc.type = ?", entities.PurchaseCategoryTypeRawMaterial).
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo). Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo).
Group(dateFormat). Group(dateFormat).
Order(dateFormat) Order(dateFormat)
@ -261,16 +220,15 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex
i.id as ingredient_id, i.id as ingredient_id,
i.name as ingredient_name, i.name as ingredient_name,
COALESCE(SUM(poi.quantity), 0) as quantity, COALESCE(SUM(poi.quantity), 0) as quantity,
COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as total_cost, COALESCE(SUM(poi.amount), 0) as total_cost,
CASE CASE
WHEN SUM(poi.quantity) > 0 WHEN SUM(poi.quantity) > 0
THEN COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) / SUM(poi.quantity) THEN COALESCE(SUM(poi.amount), 0) / SUM(poi.quantity)
ELSE 0 ELSE 0
END as average_unit_cost, END as average_unit_cost,
COUNT(DISTINCT po.id) as purchase_order_count COUNT(DISTINCT po.id) as purchase_order_count
`). `).
Joins("JOIN purchase_orders po ON poi.purchase_order_id = po.id"). Joins("JOIN purchase_orders po ON poi.purchase_order_id = po.id").
Joins("JOIN purchase_categories pc ON poi.purchase_category_id = pc.id").
Joins("JOIN ingredients i ON poi.ingredient_id = i.id"). Joins("JOIN ingredients i ON poi.ingredient_id = i.id").
Joins("LEFT JOIN units u ON poi.unit_id = u.id"). Joins("LEFT JOIN units u ON poi.unit_id = u.id").
Where("po.organization_id = ?", organizationID). Where("po.organization_id = ?", organizationID).
@ -289,21 +247,20 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex
Table("purchase_orders po"). Table("purchase_orders po").
Select(` Select(`
v.id as vendor_id, v.id as vendor_id,
COALESCE(v.name, 'No Vendor') as vendor_name, v.name as vendor_name,
COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as total_cost, COALESCE(SUM(poi.amount), 0) as total_cost,
COUNT(DISTINCT po.id) as purchase_order_count, COUNT(DISTINCT po.id) as purchase_order_count,
COUNT(DISTINCT i.id) as ingredient_count, COUNT(DISTINCT i.id) as ingredient_count,
COALESCE(SUM(poi.quantity), 0) as quantity COALESCE(SUM(poi.quantity), 0) as quantity
`). `).
Joins("LEFT JOIN vendors v ON po.vendor_id = v.id"). Joins("JOIN vendors v ON po.vendor_id = v.id").
Joins("LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id"). Joins("LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id").
Joins("LEFT JOIN purchase_categories pc ON poi.purchase_category_id = pc.id").
Joins("LEFT JOIN ingredients i ON poi.ingredient_id = i.id"). Joins("LEFT JOIN ingredients i ON poi.ingredient_id = i.id").
Joins("LEFT JOIN units u ON poi.unit_id = u.id"). Joins("LEFT JOIN units u ON poi.unit_id = u.id").
Where("po.organization_id = ?", organizationID). Where("po.organization_id = ?", organizationID).
Where("po.status != ?", "cancelled"). Where("po.status != ?", "cancelled").
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo). Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo).
Group("v.id, COALESCE(v.name, 'No Vendor')"). Group("v.id, v.name").
Order("total_cost DESC") Order("total_cost DESC")
vendorQuery = r.applyPurchaseOrderItemOutletFilter(vendorQuery, outletID) vendorQuery = r.applyPurchaseOrderItemOutletFilter(vendorQuery, outletID)
@ -324,7 +281,7 @@ func (r *AnalyticsRepositoryImpl) applyPurchaseOrderItemOutletFilter(query *gorm
if outletID == nil { if outletID == nil {
return query return query
} }
return query.Where("po.outlet_id = ?", *outletID) return query.Where("(i.outlet_id = ? OR u.outlet_id = ?)", *outletID, *outletID)
} }
func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, limit int) ([]*entities.ProductAnalytics, error) { func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, limit int) ([]*entities.ProductAnalytics, error) {
@ -335,13 +292,6 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organ
Select(` Select(`
p.id as product_id, p.id as product_id,
p.name as product_name, p.name as product_name,
p.sku as product_sku,
COALESCE(
NULLIF(pop.price, 0),
(SELECT price FROM product_outlet_prices WHERE product_id = p.id ORDER BY updated_at DESC LIMIT 1),
NULLIF(p.price, 0),
0
) as product_price,
c.id as category_id, c.id as category_id,
c.name as category_name, c.name as category_name,
c.order as category_order, c.order as category_order,
@ -375,7 +325,6 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organ
Joins("JOIN products p ON oi.product_id = p.id"). Joins("JOIN products p ON oi.product_id = p.id").
Joins("JOIN categories c ON p.category_id = c.id"). Joins("JOIN categories c ON p.category_id = c.id").
Joins("JOIN orders o ON oi.order_id = o.id"). Joins("JOIN orders o ON oi.order_id = o.id").
Joins("LEFT JOIN product_outlet_prices pop ON pop.product_id = p.id AND pop.outlet_id = o.outlet_id").
Joins("LEFT JOIN (?) mahpp ON mahpp.product_id = p.id", Joins("LEFT JOIN (?) mahpp ON mahpp.product_id = p.id",
r.db.Table("product_recipes pr2"). r.db.Table("product_recipes pr2").
Select("pr2.product_id, SUM(pr2.quantity * (1 + COALESCE(pr2.waste_percentage, 0)/100.0) * COALESCE(ma.moving_avg_cost, ing.cost)) as hpp_per_unit"). Select("pr2.product_id, SUM(pr2.quantity * (1 + COALESCE(pr2.waste_percentage, 0)/100.0) * COALESCE(ma.moving_avg_cost, ing.cost)) as hpp_per_unit").
@ -401,7 +350,7 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organ
query = r.resolveOutletID(query, outletID, "o.outlet_id") query = r.resolveOutletID(query, outletID, "o.outlet_id")
err := query. err := query.
Group("p.id, p.name, p.sku, p.price, p.cost, pop.price, c.id, c.name, c.order, mahpp.hpp_per_unit"). Group("p.id, p.name, p.cost, c.id, c.name, c.order, mahpp.hpp_per_unit").
Order("revenue DESC"). Order("revenue DESC").
Limit(limit). Limit(limit).
Scan(&results).Error Scan(&results).Error
@ -488,41 +437,6 @@ func (r *AnalyticsRepositoryImpl) GetDashboardOverview(ctx context.Context, orga
return nil, err return nil, err
} }
// Total item sold (sum of order_items quantity for completed orders in date range)
var totalItemSold int64
itemQuery := r.db.WithContext(ctx).
Table("order_items oi").
Select("COALESCE(SUM(oi.quantity), 0)").
Joins("JOIN orders o ON o.id = oi.order_id").
Where("o.organization_id = ?", organizationID).
Where("o.is_void = false AND o.is_refund = false AND o.payment_status = 'completed'").
Where("o.created_at >= ? AND o.created_at <= ?", dateFrom, dateTo)
itemQuery = r.resolveOutletID(itemQuery, outletID, "o.outlet_id")
itemQuery.Scan(&totalItemSold)
result.TotalItemSold = totalItemSold
// Total low stock (inventory where quantity <= reorder_level)
var totalLowStock int64
lowStockQuery := r.db.WithContext(ctx).
Table("inventory i").
Select("COUNT(i.id)").
Joins("JOIN products p ON p.id = i.product_id").
Where("p.organization_id = ?", organizationID).
Where("i.quantity <= i.reorder_level")
lowStockQuery = r.resolveOutletID(lowStockQuery, outletID, "i.outlet_id")
lowStockQuery.Scan(&totalLowStock)
result.TotalLowStock = totalLowStock
// Total active products
var totalProductActive int64
productQuery := r.db.WithContext(ctx).
Table("products p").
Select("COUNT(p.id)").
Where("p.organization_id = ?", organizationID).
Where("p.is_active = true")
productQuery.Scan(&totalProductActive)
result.TotalProductActive = totalProductActive
return &result, nil return &result, nil
} }
@ -572,11 +486,6 @@ func (r *AnalyticsRepositoryImpl) GetProfitLossAnalytics(ctx context.Context, or
if err := summaryQuery.Scan(&summary).Error; err != nil { if err := summaryQuery.Scan(&summary).Error; err != nil {
return nil, err return nil, err
} }
periodHPP, err := r.getPurchaseOrderRawMaterialTotal(ctx, organizationID, outletID, dateFrom, dateTo)
if err != nil {
return nil, err
}
applyProfitLossSummaryCost(&summary, periodHPP)
var timeFormat string var timeFormat string
switch groupBy { switch groupBy {
@ -624,11 +533,6 @@ func (r *AnalyticsRepositoryImpl) GetProfitLossAnalytics(ctx context.Context, or
if err := dataQuery.Scan(&data).Error; err != nil { if err := dataQuery.Scan(&data).Error; err != nil {
return nil, err return nil, err
} }
poCostData, err := r.getPurchaseOrderRawMaterialCostByPeriod(ctx, organizationID, outletID, dateFrom, dateTo, groupBy)
if err != nil {
return nil, err
}
data = mergeProfitLossDataWithPurchaseOrderCost(data, poCostData)
var productData []entities.ProductProfitData var productData []entities.ProductProfitData
productQuery := r.db.WithContext(ctx). productQuery := r.db.WithContext(ctx).
@ -689,11 +593,6 @@ func (r *AnalyticsRepositoryImpl) GetProfitLossAnalytics(ctx context.Context, or
if err := todayQuery.Scan(&todayRC).Error; err != nil { if err := todayQuery.Scan(&todayRC).Error; err != nil {
return nil, err return nil, err
} }
todayHPP, err := r.getPurchaseOrderRawMaterialTotal(ctx, organizationID, outletID, todayStart, todayEnd)
if err != nil {
return nil, err
}
todayRC.Cost = todayHPP
var mtdRC revenueCostResult var mtdRC revenueCostResult
mtdQuery := r.db.WithContext(ctx). mtdQuery := r.db.WithContext(ctx).
@ -711,333 +610,36 @@ 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,
TodayPurchasing: todayPurchasing.Total, TodayExpenseByCategory: todayExpenseByCategory,
MtdPurchasing: mtdPurchasing.Total, MtdExpenseByCategory: mtdExpenseByCategory,
TodayPurchasingRawMaterial: todayPurchasing.RawMaterial, OperationalExpenseItems: opsItems,
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
@ -1115,7 +717,7 @@ func (r *AnalyticsRepositoryImpl) GetExclusiveSummaryAnalytics(ctx context.Conte
return nil, err return nil, err
} }
operationalExpenseBreakdown, err := r.getExclusiveSummaryOperationalExpenseBreakdown(ctx, organizationID, outletID, dateFrom, dateTo) operationalExpenseBreakdown, err := r.getExclusiveSummaryOperationalExpenseBreakdown(ctx, organizationID, dateFrom, dateTo)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -1148,7 +750,7 @@ func (r *AnalyticsRepositoryImpl) getExclusiveSummaryHPPBreakdown(ctx context.Co
Select(` Select(`
pc.code as category_code, pc.code as category_code,
pc.name as category_name, pc.name as category_name,
COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as amount COALESCE(SUM(poi.amount), 0) as amount
`). `).
Joins("JOIN purchase_orders po ON poi.purchase_order_id = po.id"). Joins("JOIN purchase_orders po ON poi.purchase_order_id = po.id").
Joins("JOIN purchase_categories pc ON poi.purchase_category_id = pc.id"). Joins("JOIN purchase_categories pc ON poi.purchase_category_id = pc.id").
@ -1168,25 +770,22 @@ func (r *AnalyticsRepositoryImpl) getExclusiveSummaryHPPBreakdown(ctx context.Co
return results, err return results, err
} }
func (r *AnalyticsRepositoryImpl) getExclusiveSummaryOperationalExpenseBreakdown(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]entities.ExclusiveSummaryCategoryTotal, error) { func (r *AnalyticsRepositoryImpl) getExclusiveSummaryOperationalExpenseBreakdown(ctx context.Context, organizationID uuid.UUID, dateFrom, dateTo time.Time) ([]entities.ExclusiveSummaryCategoryTotal, error) {
var results []entities.ExclusiveSummaryCategoryTotal var results []entities.ExclusiveSummaryCategoryTotal
query := r.db.WithContext(ctx). err := r.db.WithContext(ctx).
Table("purchase_order_items poi"). Table("purchase_order_items poi").
Select(` Select(`
pc.code as category_code, pc.code as category_code,
pc.name as category_name, pc.name as category_name,
COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as amount COALESCE(SUM(poi.amount), 0) as amount
`). `).
Joins("JOIN purchase_orders po ON poi.purchase_order_id = po.id"). Joins("JOIN purchase_orders po ON poi.purchase_order_id = po.id").
Joins("JOIN purchase_categories pc ON poi.purchase_category_id = pc.id"). Joins("JOIN purchase_categories pc ON poi.purchase_category_id = pc.id").
Where("po.organization_id = ?", organizationID). Where("po.organization_id = ?", organizationID).
Where("pc.type = ?", entities.PurchaseCategoryTypeExpense). Where("pc.type = ?", entities.PurchaseCategoryTypeExpense).
Where("po.status = ?", "received"). Where("po.status = ?", "received").
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo) Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo).
query = r.applyPurchaseOrderItemOutletFilter(query, outletID)
err := query.
Group("pc.id, pc.code, pc.name, pc.sort_order"). Group("pc.id, pc.code, pc.name, pc.sort_order").
Order("pc.sort_order ASC, pc.name ASC"). Order("pc.sort_order ASC, pc.name ASC").
Scan(&results).Error Scan(&results).Error
@ -1231,8 +830,8 @@ func (r *AnalyticsRepositoryImpl) exclusiveSummaryPurchaseOrderItemQuery(organiz
} }
if outletID != nil { if outletID != nil {
outletFilter = "AND po.outlet_id = ?" outletFilter = "AND (pc.type = ? OR i.outlet_id = ? OR u.outlet_id = ?)"
args = append(args, *outletID) args = append(args, entities.PurchaseCategoryTypeExpense, *outletID, *outletID)
} }
query := ` query := `
@ -1241,7 +840,7 @@ func (r *AnalyticsRepositoryImpl) exclusiveSummaryPurchaseOrderItemQuery(organiz
pc.code as category_code, pc.code as category_code,
pc.name as category_name, pc.name as category_name,
COALESCE(NULLIF(poi.description, ''), i.name, pc.name) as description, COALESCE(NULLIF(poi.description, ''), i.name, pc.name) as description,
` + purchaseOrderItemTotalAmountSQL() + ` as amount, poi.amount as amount,
'purchase_order' as source 'purchase_order' as source
FROM purchase_order_items poi FROM purchase_order_items poi
JOIN purchase_orders po ON poi.purchase_order_id = po.id JOIN purchase_orders po ON poi.purchase_order_id = po.id

View File

@ -196,18 +196,6 @@ func (r *PurchaseOrderRepositoryImpl) UpdateStatus(ctx context.Context, id uuid.
Update("status", status).Error Update("status", status).Error
} }
func (r *PurchaseOrderRepositoryImpl) UpdateStatusAndOutlet(ctx context.Context, id uuid.UUID, status string, outletID *uuid.UUID) error {
updates := map[string]interface{}{"status": status}
if outletID != nil {
updates["outlet_id"] = *outletID
}
return r.db.WithContext(ctx).
Model(&entities.PurchaseOrder{}).
Where("id = ?", id).
Updates(updates).Error
}
func (r *PurchaseOrderRepositoryImpl) UpdateTotalAmount(ctx context.Context, id uuid.UUID, totalAmount float64) error { func (r *PurchaseOrderRepositoryImpl) UpdateTotalAmount(ctx context.Context, id uuid.UUID, totalAmount float64) error {
return r.db.WithContext(ctx). return r.db.WithContext(ctx).
Model(&entities.PurchaseOrder{}). Model(&entities.PurchaseOrder{}).

View File

@ -339,7 +339,6 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
analytics.GET("/profit-loss", r.analyticsHandler.GetProfitLossAnalytics) analytics.GET("/profit-loss", r.analyticsHandler.GetProfitLossAnalytics)
analytics.GET("/exclusive-summary/period", r.analyticsHandler.GetExclusiveSummaryPeriod) analytics.GET("/exclusive-summary/period", r.analyticsHandler.GetExclusiveSummaryPeriod)
analytics.GET("/exclusive-summary/monthly", r.analyticsHandler.GetExclusiveSummaryMonthly) analytics.GET("/exclusive-summary/monthly", r.analyticsHandler.GetExclusiveSummaryMonthly)
analytics.GET("/exclusive-summary/mtd", r.analyticsHandler.GetExclusiveSummaryMTD)
} }
tables := protected.Group("/tables") tables := protected.Group("/tables")
@ -356,7 +355,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
} }
ingredients := protected.Group("/ingredients") ingredients := protected.Group("/ingredients")
ingredients.Use(r.authMiddleware.RequireAdminOrManagerOrPurchasing()) ingredients.Use(r.authMiddleware.RequireAdminOrManager())
{ {
ingredients.POST("", r.ingredientHandler.Create) ingredients.POST("", r.ingredientHandler.Create)
ingredients.GET("", r.ingredientHandler.GetAll) ingredients.GET("", r.ingredientHandler.GetAll)
@ -369,7 +368,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
} }
vendors := protected.Group("/vendors") vendors := protected.Group("/vendors")
vendors.Use(r.authMiddleware.RequireAdminOrManagerOrPurchasing()) vendors.Use(r.authMiddleware.RequireAdminOrManager())
{ {
vendors.POST("", r.vendorHandler.CreateVendor) vendors.POST("", r.vendorHandler.CreateVendor)
vendors.GET("", r.vendorHandler.ListVendors) vendors.GET("", r.vendorHandler.ListVendors)
@ -380,7 +379,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
} }
purchaseOrders := protected.Group("/purchase-orders") purchaseOrders := protected.Group("/purchase-orders")
purchaseOrders.Use(r.authMiddleware.RequireAdminOrManagerOrPurchasing()) purchaseOrders.Use(r.authMiddleware.RequireAdminOrManager())
{ {
purchaseOrders.POST("", r.purchaseOrderHandler.CreatePurchaseOrder) purchaseOrders.POST("", r.purchaseOrderHandler.CreatePurchaseOrder)
purchaseOrders.GET("", r.purchaseOrderHandler.ListPurchaseOrders) purchaseOrders.GET("", r.purchaseOrderHandler.ListPurchaseOrders)
@ -393,7 +392,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
} }
purchaseCategories := protected.Group("/purchase-categories") purchaseCategories := protected.Group("/purchase-categories")
purchaseCategories.Use(r.authMiddleware.RequireAdminOrManagerOrPurchasing()) purchaseCategories.Use(r.authMiddleware.RequireAdminOrManager())
{ {
purchaseCategories.POST("", r.purchaseCategoryHandler.CreatePurchaseCategory) purchaseCategories.POST("", r.purchaseCategoryHandler.CreatePurchaseCategory)
purchaseCategories.GET("", r.purchaseCategoryHandler.ListPurchaseCategories) purchaseCategories.GET("", r.purchaseCategoryHandler.ListPurchaseCategories)
@ -403,7 +402,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
} }
unitConverters := protected.Group("/unit-converters") unitConverters := protected.Group("/unit-converters")
unitConverters.Use(r.authMiddleware.RequireAdminOrManagerOrPurchasing()) unitConverters.Use(r.authMiddleware.RequireAdminOrManager())
{ {
unitConverters.POST("", r.unitConverterHandler.CreateIngredientUnitConverter) unitConverters.POST("", r.unitConverterHandler.CreateIngredientUnitConverter)
unitConverters.GET("", r.unitConverterHandler.ListIngredientUnitConverters) unitConverters.GET("", r.unitConverterHandler.ListIngredientUnitConverters)
@ -465,7 +464,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
} }
expenses := protected.Group("/expenses") expenses := protected.Group("/expenses")
expenses.Use(r.authMiddleware.RequireAdminOrManagerOrPurchasing()) expenses.Use(r.authMiddleware.RequireAdminOrManager())
{ {
expenses.POST("", r.expenseHandler.CreateExpense) expenses.POST("", r.expenseHandler.CreateExpense)
expenses.GET("", r.expenseHandler.ListExpenses) expenses.GET("", r.expenseHandler.ListExpenses)
@ -620,7 +619,6 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
outlets.GET("/:outlet_id/tables/occupied", r.tableHandler.GetOccupiedTables) outlets.GET("/:outlet_id/tables/occupied", r.tableHandler.GetOccupiedTables)
// Reports // Reports
outlets.GET("/:outlet_id/reports/daily-transaction.pdf", r.reportHandler.GetDailyTransactionReportPDF) outlets.GET("/:outlet_id/reports/daily-transaction.pdf", r.reportHandler.GetDailyTransactionReportPDF)
outlets.GET("/:outlet_id/reports/profit-loss.pdf", r.reportHandler.GetProfitLossReportPDF)
} }
// User device routes - accessible by authenticated users for their own devices // User device routes - accessible by authenticated users for their own devices

View File

@ -20,7 +20,6 @@ type AnalyticsService interface {
GetProfitLossAnalytics(ctx context.Context, req *models.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsResponse, error) GetProfitLossAnalytics(ctx context.Context, req *models.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsResponse, error)
GetExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error) GetExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error)
GetExclusiveSummaryMonthly(ctx context.Context, req *models.ExclusiveSummaryMonthlyRequest) (*models.ExclusiveSummaryMonthlyResponse, error) GetExclusiveSummaryMonthly(ctx context.Context, req *models.ExclusiveSummaryMonthlyRequest) (*models.ExclusiveSummaryMonthlyResponse, error)
GetExclusiveSummaryMTD(ctx context.Context, req *models.ExclusiveSummaryMTDRequest) (*models.ExclusiveSummaryPeriodResponse, error)
} }
type AnalyticsServiceImpl struct { type AnalyticsServiceImpl struct {
@ -350,19 +349,6 @@ func (s *AnalyticsServiceImpl) GetExclusiveSummaryMonthly(ctx context.Context, r
return response, nil return response, nil
} }
func (s *AnalyticsServiceImpl) GetExclusiveSummaryMTD(ctx context.Context, req *models.ExclusiveSummaryMTDRequest) (*models.ExclusiveSummaryPeriodResponse, error) {
if err := s.validateExclusiveSummaryMTDRequest(req); err != nil {
return nil, fmt.Errorf("validation error: %w", err)
}
response, err := s.analyticsProcessor.GetExclusiveSummaryMTD(ctx, req)
if err != nil {
return nil, fmt.Errorf("failed to get exclusive summary mtd: %w", err)
}
return response, nil
}
func (s *AnalyticsServiceImpl) validateExclusiveSummaryPeriodRequest(req *models.ExclusiveSummaryPeriodRequest) error { func (s *AnalyticsServiceImpl) validateExclusiveSummaryPeriodRequest(req *models.ExclusiveSummaryPeriodRequest) error {
if req == nil { if req == nil {
return fmt.Errorf("request cannot be nil") return fmt.Errorf("request cannot be nil")
@ -387,22 +373,6 @@ func (s *AnalyticsServiceImpl) validateExclusiveSummaryPeriodRequest(req *models
return nil return nil
} }
func (s *AnalyticsServiceImpl) validateExclusiveSummaryMTDRequest(req *models.ExclusiveSummaryMTDRequest) error {
if req == nil {
return fmt.Errorf("request cannot be nil")
}
if req.OrganizationID == uuid.Nil {
return fmt.Errorf("organization_id is required")
}
if req.DateTo.IsZero() {
return fmt.Errorf("date_to is required")
}
return nil
}
func (s *AnalyticsServiceImpl) validateExclusiveSummaryMonthlyRequest(req *models.ExclusiveSummaryMonthlyRequest) error { func (s *AnalyticsServiceImpl) validateExclusiveSummaryMonthlyRequest(req *models.ExclusiveSummaryMonthlyRequest) error {
if req == nil { if req == nil {
return fmt.Errorf("request cannot be nil") return fmt.Errorf("request cannot be nil")

View File

@ -49,10 +49,6 @@ func (analyticsProcessorStub) GetExclusiveSummaryMonthly(context.Context, *model
return &models.ExclusiveSummaryMonthlyResponse{}, nil return &models.ExclusiveSummaryMonthlyResponse{}, nil
} }
func (analyticsProcessorStub) GetExclusiveSummaryMTD(context.Context, *models.ExclusiveSummaryMTDRequest) (*models.ExclusiveSummaryPeriodResponse, error) {
return &models.ExclusiveSummaryPeriodResponse{}, nil
}
func TestAnalyticsServiceGetPurchasingAnalyticsValidation(t *testing.T) { func TestAnalyticsServiceGetPurchasingAnalyticsValidation(t *testing.T) {
service := NewAnalyticsServiceImpl(analyticsProcessorStub{}) service := NewAnalyticsServiceImpl(analyticsProcessorStub{})
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
@ -258,44 +254,3 @@ func TestAnalyticsServiceGetExclusiveSummaryMonthlyValidation(t *testing.T) {
require.Error(t, err) require.Error(t, err)
require.Contains(t, err.Error(), "month is required") 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)
})
}
}

View File

@ -40,12 +40,7 @@ func (s *PurchaseOrderServiceImpl) CreatePurchaseOrder(ctx context.Context, apct
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp}) return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
} }
var outletID *uuid.UUID poResponse, err := s.purchaseOrderProcessor.CreatePurchaseOrder(ctx, apctx.OrganizationID, modelReq)
if apctx.OutletID != uuid.Nil {
outletID = &apctx.OutletID
}
poResponse, err := s.purchaseOrderProcessor.CreatePurchaseOrder(ctx, apctx.OrganizationID, outletID, modelReq)
if err != nil { if err != nil {
errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.PurchaseOrderServiceEntity, err.Error()) errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.PurchaseOrderServiceEntity, err.Error())
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp}) return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
@ -62,12 +57,7 @@ func (s *PurchaseOrderServiceImpl) UpdatePurchaseOrder(ctx context.Context, apct
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp}) return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
} }
var outletID *uuid.UUID poResponse, err := s.purchaseOrderProcessor.UpdatePurchaseOrder(ctx, id, apctx.OrganizationID, modelReq)
if apctx.OutletID != uuid.Nil {
outletID = &apctx.OutletID
}
poResponse, err := s.purchaseOrderProcessor.UpdatePurchaseOrder(ctx, id, apctx.OrganizationID, outletID, modelReq)
if err != nil { if err != nil {
errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.PurchaseOrderServiceEntity, err.Error()) errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.PurchaseOrderServiceEntity, err.Error())
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp}) return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})

View File

@ -17,7 +17,6 @@ import (
type ReportService interface { type ReportService interface {
// Returns (publicURL, fileName, error) // Returns (publicURL, fileName, error)
GenerateDailyTransactionPDF(ctx context.Context, organizationID string, outletID string, reportDate *time.Time, generatedBy string) (string, string, error) GenerateDailyTransactionPDF(ctx context.Context, organizationID string, outletID string, reportDate *time.Time, generatedBy string) (string, string, error)
GenerateProfitLossPDF(ctx context.Context, organizationID string, outletID string, reportDate *time.Time, generatedBy string) (string, string, error)
} }
type ReportServiceImpl struct { type ReportServiceImpl struct {
@ -219,296 +218,3 @@ func getPLPctByID(rows []models.ProfitLossSummaryRow, id string) float64 {
} }
return 0 return 0
} }
// profitLossReportData holds data for the profit/loss PDF template
type profitLossReportData struct {
OrganizationName string
MonthName string
ReportDate string
ReportDateUpper string
TotalPenjualan string
TotalBiaya string
LabaRugi string
LabaRugiClass string
LabaRugiValueClass string
LabaRugiMtd string
LabaRugiMtdClass string
LabaRugiMtdValueClass string
MainSummary []profitLossSummaryRowView
PurchasingItems []profitLossPurchasingItem
PurchasingTotal string
GeneratedBy string
PrintTime string
}
type profitLossSummaryRowView struct {
Number string
Label string
TodayNominal string
TodayPct string
MtdNominal string
MtdPct string
RowClass string
SubItems []profitLossSummaryRowView
}
type profitLossPurchasingItem struct {
Name string
Amount string
}
func (s *ReportServiceImpl) GenerateProfitLossPDF(ctx context.Context, organizationID string, outletID string, reportDate *time.Time, generatedBy string) (string, string, error) {
orgID, err := uuid.Parse(organizationID)
if err != nil {
return "", "", fmt.Errorf("invalid organization id: %w", err)
}
var outID *uuid.UUID
if outletID != "" {
parsed, err := uuid.Parse(outletID)
if err != nil {
return "", "", fmt.Errorf("invalid outlet id: %w", err)
}
outID = &parsed
}
org, err := s.organizationRepo.GetByID(ctx, orgID)
if err != nil {
return "", "", fmt.Errorf("organization not found: %w", err)
}
var tzName string
if outID != nil {
outlet, err := s.outletRepo.GetByID(ctx, *outID)
if err == nil && outlet.Timezone != nil && *outlet.Timezone != "" {
tzName = *outlet.Timezone
}
}
if tzName == "" {
tzName = "Asia/Jakarta"
}
loc, locErr := time.LoadLocation(tzName)
if locErr != nil || loc == nil {
loc = time.Local
}
var day time.Time
if reportDate != nil {
t := reportDate.UTC()
day = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc)
} else {
now := time.Now().In(loc)
day = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
}
dayStart := day
dayEnd := day.Add(24*time.Hour - time.Nanosecond)
// MTD: from 1st of month to end of the report day
mtdStart := time.Date(day.Year(), day.Month(), 1, 0, 0, 0, 0, loc)
mtdEnd := dayEnd
// Get profit/loss analytics for the day
plReq := &models.ProfitLossAnalyticsRequest{
OrganizationID: orgID,
OutletID: outID,
DateFrom: dayStart,
DateTo: mtdEnd,
GroupBy: "day",
}
pl, err := s.analyticsService.GetProfitLossAnalytics(ctx, plReq)
if err != nil {
return "", "", fmt.Errorf("get profit/loss analytics: %w", err)
}
// Get purchasing analytics for the day (Rincian Biaya / Catatan)
purchReq := &models.PurchasingAnalyticsRequest{
OrganizationID: orgID,
OutletID: outID,
DateFrom: dayStart,
DateTo: dayEnd,
GroupBy: "day",
}
purch, err := s.analyticsService.GetPurchasingAnalytics(ctx, purchReq)
if err != nil {
return "", "", fmt.Errorf("get purchasing analytics: %w", err)
}
// Build summary values
totalOmset := getPLNominalByID(pl.MainSummary, "total_omset")
hpp := getPLNominalByID(pl.MainSummary, "hpp")
_ = mtdStart // used above
// Total biaya = HPP + operational expenses for the day
totalBiayaToday := hpp + pl.OperationalExpensesTotal
// Laba/Rugi today
labaRugiToday := totalOmset - totalBiayaToday
// MTD values
mtdOmset := getMtdNominalByID(pl.MainSummary, "total_omset")
mtdCost := getMtdNominalByID(pl.MainSummary, "hpp")
mtdOps := getMtdNominalByID(pl.MainSummary, "biaya_ops")
mtdGaji := getMtdNominalByID(pl.MainSummary, "biaya_gaji")
labaRugiMtd := mtdOmset - mtdCost - mtdOps - mtdGaji
// Month name in Indonesian
monthNames := []string{"", "Januari", "Februari", "Maret", "April", "Mei", "Juni", "Juli", "Agustus", "September", "Oktober", "November", "Desember"}
monthName := fmt.Sprintf("%s %d", monthNames[day.Month()], day.Year())
reportDateStr := fmt.Sprintf("%d %s %d", day.Day(), monthNames[day.Month()], day.Year())
reportDateUpper := fmt.Sprintf("%d %s %d", day.Day(), strings.ToUpper(monthNames[day.Month()]), day.Year())
// Build main summary rows
mainSummaryRows := buildProfitLossSummaryRows(pl.MainSummary)
// Build purchasing items from ingredient data
purchItems := make([]profitLossPurchasingItem, 0)
var purchTotal float64
for _, item := range purch.IngredientData {
purchItems = append(purchItems, profitLossPurchasingItem{
Name: item.IngredientName,
Amount: formatCurrency(item.TotalCost),
})
purchTotal += item.TotalCost
}
// Determine highlight classes
labaRugiClass := ""
labaRugiValueClass := ""
if labaRugiToday < 0 {
labaRugiClass = "highlight-red"
labaRugiValueClass = "negative"
} else {
labaRugiClass = "highlight-green"
labaRugiValueClass = "positive"
}
labaRugiMtdClass := ""
labaRugiMtdValueClass := ""
if labaRugiMtd < 0 {
labaRugiMtdClass = "highlight-red"
labaRugiMtdValueClass = "negative"
} else {
labaRugiMtdClass = "highlight-green"
labaRugiMtdValueClass = "positive"
}
data := profitLossReportData{
OrganizationName: org.Name,
MonthName: monthName,
ReportDate: reportDateStr,
ReportDateUpper: reportDateUpper,
TotalPenjualan: formatCurrency(totalOmset),
TotalBiaya: formatCurrency(totalBiayaToday),
LabaRugi: formatCurrencySigned(labaRugiToday),
LabaRugiClass: labaRugiClass,
LabaRugiValueClass: labaRugiValueClass,
LabaRugiMtd: formatCurrencySigned(labaRugiMtd),
LabaRugiMtdClass: labaRugiMtdClass,
LabaRugiMtdValueClass: labaRugiMtdValueClass,
MainSummary: mainSummaryRows,
PurchasingItems: purchItems,
PurchasingTotal: formatCurrency(purchTotal),
GeneratedBy: generatedBy,
PrintTime: time.Now().In(loc).Format("02/01/2006 15:04:05"),
}
templatePath := filepath.Join("templates", "profit_loss_report.html")
pdfBytes, err := renderTemplateToPDF(templatePath, data)
if err != nil {
return "", "", fmt.Errorf("render pdf: %w", err)
}
safeOrg := orgID.String()
safeOutlet := "all"
if outID != nil {
safeOutlet = outID.String()
}
fileName := fmt.Sprintf("laporan-laba-rugi-%s-%s.pdf", day.Format("2006-01-02"), time.Now().Format("20060102-150405"))
objectKey := fmt.Sprintf("/reports/%s/%s/%s", safeOrg, safeOutlet, fileName)
publicURL, err := s.fileClient.UploadFile(ctx, objectKey, pdfBytes)
if err != nil {
return "", "", fmt.Errorf("upload pdf: %w", err)
}
return publicURL, fileName, nil
}
func getMtdNominalByID(rows []models.ProfitLossSummaryRow, id string) float64 {
for _, row := range rows {
if row.ID == id {
return row.MtdNominal
}
}
return 0
}
func buildProfitLossSummaryRows(rows []models.ProfitLossSummaryRow) []profitLossSummaryRowView {
result := make([]profitLossSummaryRowView, 0, len(rows))
for i, row := range rows {
rowClass := ""
if row.IsBold {
rowClass = "highlight-green-row"
}
// Highlight laba kotor row
if row.ID == "laba_kotor" {
rowClass = "highlight-row"
}
number := ""
if row.ID != "" {
number = fmt.Sprintf("%d", i+1)
}
subItems := make([]profitLossSummaryRowView, 0)
for _, sub := range row.SubItems {
subItems = append(subItems, profitLossSummaryRowView{
Label: sub.Label,
TodayNominal: formatCurrencyOrDash(sub.TodayNominal),
TodayPct: formatPct(sub.TodayPct),
MtdNominal: formatCurrencyOrDash(sub.MtdNominal),
MtdPct: formatPct(sub.MtdPct),
RowClass: "",
})
}
result = append(result, profitLossSummaryRowView{
Number: number,
Label: row.Label,
TodayNominal: formatCurrencyOrDash(row.TodayNominal),
TodayPct: formatPct(row.TodayPct),
MtdNominal: formatCurrencyOrDash(row.MtdNominal),
MtdPct: formatPct(row.MtdPct),
RowClass: rowClass,
SubItems: subItems,
})
}
return result
}
func formatCurrencyOrDash(amount float64) string {
if amount == 0 {
return "-"
}
if amount < 0 {
return formatCurrencySigned(amount)
}
return formatCurrency(amount)
}
func formatCurrencySigned(amount float64) string {
if amount < 0 {
return "(Rp " + addThousandsSep(fmt.Sprintf("%.0f", -amount)) + ")"
}
return "Rp " + addThousandsSep(fmt.Sprintf("%.0f", amount))
}
func formatPct(pct float64) string {
if pct == 0 {
return "0%"
}
return fmt.Sprintf("%.0f%%", pct)
}

View File

@ -66,7 +66,6 @@ func PaymentMethodAnalyticsModelToContract(resp *models.PaymentMethodAnalyticsRe
return &contract.PaymentMethodAnalyticsResponse{ return &contract.PaymentMethodAnalyticsResponse{
OrganizationID: resp.OrganizationID, OrganizationID: resp.OrganizationID,
OutletID: resp.OutletID, OutletID: resp.OutletID,
OutletName: resp.OutletName,
DateFrom: resp.DateFrom, DateFrom: resp.DateFrom,
DateTo: resp.DateTo, DateTo: resp.DateTo,
GroupBy: resp.GroupBy, GroupBy: resp.GroupBy,
@ -123,7 +122,6 @@ func SalesAnalyticsModelToContract(resp *models.SalesAnalyticsResponse) *contrac
return &contract.SalesAnalyticsResponse{ return &contract.SalesAnalyticsResponse{
OrganizationID: resp.OrganizationID, OrganizationID: resp.OrganizationID,
OutletID: resp.OutletID, OutletID: resp.OutletID,
OutletName: resp.OutletName,
DateFrom: resp.DateFrom, DateFrom: resp.DateFrom,
DateTo: resp.DateTo, DateTo: resp.DateTo,
GroupBy: resp.GroupBy, GroupBy: resp.GroupBy,
@ -287,7 +285,6 @@ func ProductAnalyticsModelToContract(resp *models.ProductAnalyticsResponse) *con
return &contract.ProductAnalyticsResponse{ return &contract.ProductAnalyticsResponse{
OrganizationID: resp.OrganizationID, OrganizationID: resp.OrganizationID,
OutletID: resp.OutletID, OutletID: resp.OutletID,
OutletName: resp.OutletName,
DateFrom: resp.DateFrom, DateFrom: resp.DateFrom,
DateTo: resp.DateTo, DateTo: resp.DateTo,
Data: data, Data: data,
@ -340,7 +337,6 @@ func ProductAnalyticsPerCategoryModelToContract(resp *models.ProductAnalyticsPer
return &contract.ProductAnalyticsPerCategoryResponse{ return &contract.ProductAnalyticsPerCategoryResponse{
OrganizationID: resp.OrganizationID, OrganizationID: resp.OrganizationID,
OutletID: resp.OutletID, OutletID: resp.OutletID,
OutletName: resp.OutletName,
DateFrom: resp.DateFrom, DateFrom: resp.DateFrom,
DateTo: resp.DateTo, DateTo: resp.DateTo,
Data: data, Data: data,
@ -425,19 +421,15 @@ 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,
@ -524,20 +516,9 @@ func ProfitLossAnalyticsModelToContract(resp *models.ProfitLossAnalyticsResponse
} }
} }
purchasingItems := make([]contract.ProfitLossPurchasingItem, len(resp.Purchasing.Items))
for i, item := range resp.Purchasing.Items {
purchasingItems[i] = contract.ProfitLossPurchasingItem{
Date: item.Date,
Item: item.Item,
Quantity: item.Quantity,
Nominal: item.Nominal,
}
}
return &contract.ProfitLossAnalyticsResponse{ return &contract.ProfitLossAnalyticsResponse{
OrganizationID: resp.OrganizationID, OrganizationID: resp.OrganizationID,
OutletID: resp.OutletID, OutletID: resp.OutletID,
OutletName: resp.OutletName,
DateFrom: resp.DateFrom, DateFrom: resp.DateFrom,
DateTo: resp.DateTo, DateTo: resp.DateTo,
GroupBy: resp.GroupBy, GroupBy: resp.GroupBy,
@ -554,18 +535,9 @@ 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,
} }
@ -632,27 +604,6 @@ func ExclusiveSummaryMonthlyContractToModel(req *contract.ExclusiveSummaryMonthl
}, nil }, nil
} }
func ExclusiveSummaryMTDContractToModel(req *contract.ExclusiveSummaryMTDRequest) (*models.ExclusiveSummaryMTDRequest, error) {
if req == nil {
return nil, fmt.Errorf("request cannot be nil")
}
dateTo, err := parseFlexibleDateToJakartaTime(req.DateTo, true)
if err != nil {
return nil, fmt.Errorf("invalid date_to: %w", err)
}
if dateTo == nil {
return nil, fmt.Errorf("date_to is required")
}
return &models.ExclusiveSummaryMTDRequest{
OrganizationID: req.OrganizationID,
OutletID: parseOutletID(req.OutletID),
DateTo: *dateTo,
ExcludeGajiStaffFromReimburse: req.ExcludeGajiStaffFromReimburse,
}, nil
}
func ExclusiveSummaryPeriodModelToContract(resp *models.ExclusiveSummaryPeriodResponse) *contract.ExclusiveSummaryPeriodResponse { func ExclusiveSummaryPeriodModelToContract(resp *models.ExclusiveSummaryPeriodResponse) *contract.ExclusiveSummaryPeriodResponse {
if resp == nil { if resp == nil {
return nil return nil
@ -692,7 +643,6 @@ func ExclusiveSummaryPeriodModelToContract(resp *models.ExclusiveSummaryPeriodRe
return &contract.ExclusiveSummaryPeriodResponse{ return &contract.ExclusiveSummaryPeriodResponse{
OrganizationID: resp.OrganizationID, OrganizationID: resp.OrganizationID,
OutletID: resp.OutletID, OutletID: resp.OutletID,
OutletName: resp.OutletName,
Period: contract.ExclusiveSummaryPeriodRange{ Period: contract.ExclusiveSummaryPeriodRange{
DateFrom: resp.Period.DateFrom, DateFrom: resp.Period.DateFrom,
DateTo: resp.Period.DateTo, DateTo: resp.Period.DateTo,
@ -755,7 +705,6 @@ func ExclusiveSummaryMonthlyModelToContract(resp *models.ExclusiveSummaryMonthly
return &contract.ExclusiveSummaryMonthlyResponse{ return &contract.ExclusiveSummaryMonthlyResponse{
OrganizationID: resp.OrganizationID, OrganizationID: resp.OrganizationID,
OutletID: resp.OutletID, OutletID: resp.OutletID,
OutletName: resp.OutletName,
Month: resp.Month, Month: resp.Month,
Summary: contract.ExclusiveSummaryMonthlySummary{ Summary: contract.ExclusiveSummaryMonthlySummary{
TotalSales: resp.Summary.TotalSales, TotalSales: resp.Summary.TotalSales,
@ -823,22 +772,6 @@ func parseISODateToJakartaTime(dateStr string, endOfDay bool) (*time.Time, error
return &result, nil return &result, nil
} }
func parseFlexibleDateToJakartaTime(dateStr string, endOfDay bool) (*time.Time, error) {
if dateStr == "" {
return nil, nil
}
fromTime, toTime, err := util.ParseDateRangeToJakartaTime(dateStr, dateStr)
if err == nil {
if endOfDay {
return toTime, nil
}
return fromTime, nil
}
return parseISODateToJakartaTime(dateStr, endOfDay)
}
func parseMonthToJakartaTime(month string) (time.Time, error) { func parseMonthToJakartaTime(month string) (time.Time, error) {
location, err := time.LoadLocation("Asia/Jakarta") location, err := time.LoadLocation("Asia/Jakarta")
if err != nil { if err != nil {

View File

@ -120,7 +120,6 @@ func PurchaseOrderModelResponseToResponse(po *models.PurchaseOrderResponse) *con
response := &contract.PurchaseOrderResponse{ response := &contract.PurchaseOrderResponse{
ID: po.ID, ID: po.ID,
OrganizationID: po.OrganizationID, OrganizationID: po.OrganizationID,
OutletID: po.OutletID,
VendorID: po.VendorID, VendorID: po.VendorID,
PONumber: po.PONumber, PONumber: po.PONumber,
TransactionDate: po.TransactionDate, TransactionDate: po.TransactionDate,

View File

@ -12,13 +12,12 @@ import (
) )
func TestCreatePurchaseOrderRequestToModelAllowsMissingDueDate(t *testing.T) { func TestCreatePurchaseOrderRequestToModelAllowsMissingDueDate(t *testing.T) {
vendorID := uuid.New()
ingredientID := uuid.New() ingredientID := uuid.New()
quantity := 1.0 quantity := 1.0
unitID := uuid.New() unitID := uuid.New()
result, err := CreatePurchaseOrderRequestToModel(&contract.CreatePurchaseOrderRequest{ result, err := CreatePurchaseOrderRequestToModel(&contract.CreatePurchaseOrderRequest{
VendorID: &vendorID, VendorID: uuid.New(),
PONumber: "PO-001", PONumber: "PO-001",
TransactionDate: "2026-05-29", TransactionDate: "2026-05-29",
Items: []contract.CreatePurchaseOrderItemRequest{ Items: []contract.CreatePurchaseOrderItemRequest{
@ -36,10 +35,9 @@ func TestCreatePurchaseOrderRequestToModelAllowsMissingDueDate(t *testing.T) {
} }
func TestPurchaseOrderModelResponseToResponseIncludesNullDueDate(t *testing.T) { func TestPurchaseOrderModelResponseToResponseIncludesNullDueDate(t *testing.T) {
vendorID := uuid.New()
result := PurchaseOrderModelResponseToResponse(&models.PurchaseOrderResponse{ result := PurchaseOrderModelResponseToResponse(&models.PurchaseOrderResponse{
ID: uuid.New(), ID: uuid.New(),
VendorID: &vendorID, VendorID: uuid.New(),
PONumber: "PO-001", PONumber: "PO-001",
}) })
@ -47,19 +45,3 @@ func TestPurchaseOrderModelResponseToResponseIncludesNullDueDate(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.Contains(t, string(payload), `"due_date":null`) require.Contains(t, string(payload), `"due_date":null`)
} }
func TestCreatePurchaseOrderRequestToModelAllowsMissingVendor(t *testing.T) {
result, err := CreatePurchaseOrderRequestToModel(&contract.CreatePurchaseOrderRequest{
PONumber: "PO-001",
TransactionDate: "2026-05-29",
Items: []contract.CreatePurchaseOrderItemRequest{
{
PurchaseCategoryID: uuid.New(),
Amount: 1000,
},
},
})
require.NoError(t, err)
require.Nil(t, result.VendorID)
}

View File

@ -29,8 +29,8 @@ func (v *PurchaseOrderValidatorImpl) ValidateCreatePurchaseOrderRequest(req *con
return errors.New("request body is required"), constants.MissingFieldErrorCode return errors.New("request body is required"), constants.MissingFieldErrorCode
} }
if req.VendorID != nil && *req.VendorID == uuid.Nil { if req.VendorID == uuid.Nil {
return errors.New("vendor_id cannot be empty"), constants.MalformedFieldErrorCode return errors.New("vendor_id is required"), constants.MissingFieldErrorCode
} }
if strings.TrimSpace(req.PONumber) == "" { if strings.TrimSpace(req.PONumber) == "" {

View File

@ -11,13 +11,12 @@ import (
) )
func validCreatePurchaseOrderRequest() *contract.CreatePurchaseOrderRequest { func validCreatePurchaseOrderRequest() *contract.CreatePurchaseOrderRequest {
vendorID := uuid.New()
ingredientID := uuid.New() ingredientID := uuid.New()
quantity := 1.0 quantity := 1.0
unitID := uuid.New() unitID := uuid.New()
return &contract.CreatePurchaseOrderRequest{ return &contract.CreatePurchaseOrderRequest{
VendorID: &vendorID, VendorID: uuid.New(),
PONumber: "PO-001", PONumber: "PO-001",
TransactionDate: "2026-05-29", TransactionDate: "2026-05-29",
Items: []contract.CreatePurchaseOrderItemRequest{ Items: []contract.CreatePurchaseOrderItemRequest{
@ -32,30 +31,6 @@ func validCreatePurchaseOrderRequest() *contract.CreatePurchaseOrderRequest {
} }
} }
func TestPurchaseOrderValidatorCreateAllowsMissingVendor(t *testing.T) {
validator := NewPurchaseOrderValidator()
req := validCreatePurchaseOrderRequest()
req.VendorID = nil
err, code := validator.ValidateCreatePurchaseOrderRequest(req)
require.NoError(t, err)
require.Empty(t, code)
}
func TestPurchaseOrderValidatorCreateRejectsEmptyVendor(t *testing.T) {
validator := NewPurchaseOrderValidator()
req := validCreatePurchaseOrderRequest()
vendorID := uuid.Nil
req.VendorID = &vendorID
err, code := validator.ValidateCreatePurchaseOrderRequest(req)
require.Error(t, err)
require.Equal(t, constants.MalformedFieldErrorCode, code)
require.Contains(t, err.Error(), "vendor_id cannot be empty")
}
func TestPurchaseOrderValidatorCreateAllowsMissingDueDate(t *testing.T) { func TestPurchaseOrderValidatorCreateAllowsMissingDueDate(t *testing.T) {
validator := NewPurchaseOrderValidator() validator := NewPurchaseOrderValidator()

View File

@ -140,12 +140,10 @@ 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]
} }

View File

@ -1,2 +0,0 @@
ALTER TABLE purchase_orders
ALTER COLUMN vendor_id SET NOT NULL;

View File

@ -1,2 +0,0 @@
ALTER TABLE purchase_orders
ALTER COLUMN vendor_id DROP NOT NULL;

View File

@ -1,4 +0,0 @@
DROP INDEX IF EXISTS idx_purchase_orders_outlet_id;
ALTER TABLE purchase_orders
DROP COLUMN IF EXISTS outlet_id;

View File

@ -1,66 +0,0 @@
ALTER TABLE purchase_orders
ADD COLUMN IF NOT EXISTS outlet_id UUID REFERENCES outlets(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_purchase_orders_outlet_id
ON purchase_orders(outlet_id);
WITH movement_outlets AS (
SELECT
poi.purchase_order_id,
MIN(im.outlet_id::text)::uuid AS outlet_id
FROM inventory_movements im
JOIN purchase_order_items poi ON im.purchase_order_item_id = poi.id
WHERE im.outlet_id IS NOT NULL
AND im.purchase_order_item_id IS NOT NULL
GROUP BY poi.purchase_order_id
HAVING COUNT(DISTINCT im.outlet_id) = 1
)
UPDATE purchase_orders po
SET outlet_id = movement_outlets.outlet_id
FROM movement_outlets
WHERE po.id = movement_outlets.purchase_order_id
AND po.outlet_id IS NULL;
WITH candidate_item_outlets AS (
SELECT
poi.purchase_order_id,
i.outlet_id
FROM purchase_order_items poi
JOIN ingredients i ON poi.ingredient_id = i.id
WHERE i.outlet_id IS NOT NULL
UNION ALL
SELECT
poi.purchase_order_id,
u.outlet_id
FROM purchase_order_items poi
JOIN units u ON poi.unit_id = u.id
WHERE u.outlet_id IS NOT NULL
), item_outlets AS (
SELECT
purchase_order_id,
MIN(outlet_id::text)::uuid AS outlet_id
FROM candidate_item_outlets
GROUP BY purchase_order_id
HAVING COUNT(DISTINCT outlet_id) = 1
)
UPDATE purchase_orders po
SET outlet_id = item_outlets.outlet_id
FROM item_outlets
WHERE po.id = item_outlets.purchase_order_id
AND po.outlet_id IS NULL;
WITH single_outlet_organizations AS (
SELECT
organization_id,
MIN(id::text)::uuid AS outlet_id
FROM outlets
GROUP BY organization_id
HAVING COUNT(*) = 1
)
UPDATE purchase_orders po
SET outlet_id = single_outlet_organizations.outlet_id
FROM single_outlet_organizations
WHERE po.organization_id = single_outlet_organizations.organization_id
AND po.outlet_id IS NULL;

View File

@ -1,4 +0,0 @@
-- Revert to original roles
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_role_check;
UPDATE users SET role = 'admin' WHERE role NOT IN ('admin', 'manager', 'cashier', 'waiter');
ALTER TABLE users ADD CONSTRAINT users_role_check CHECK (role IN ('admin', 'manager', 'cashier', 'waiter'));

View File

@ -1,4 +0,0 @@
-- Add 'owner' and 'purchasing' roles to users table
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_role_check;
UPDATE users SET role = 'admin' WHERE role NOT IN ('admin', 'manager', 'cashier', 'waiter', 'owner', 'purchasing');
ALTER TABLE users ADD CONSTRAINT users_role_check CHECK (role IN ('admin', 'manager', 'cashier', 'waiter', 'owner', 'purchasing'));

View File

@ -1,394 +0,0 @@
<!doctype html>
<html lang="id">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Laporan Penjualan Harian</title>
<style>
@page {
size: A4;
margin: 10mm 12mm;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
background: #ffffff;
color: #2d3748;
line-height: 1.4;
font-size: 11px;
}
.report-container {
max-width: 210mm;
margin: 0 auto;
padding: 0;
}
/* Header */
.report-header {
text-align: center;
margin-bottom: 20px;
}
.header-logo {
display: inline-block;
width: 60px;
height: 60px;
border-radius: 10px;
overflow: hidden;
margin-bottom: 8px;
background: #dc2626;
padding: 10px;
}
.header-logo svg {
width: 40px;
height: 40px;
}
.header-title {
font-size: 22px;
font-weight: 800;
color: #1a1a1a;
margin-bottom: 4px;
}
.header-org {
font-size: 14px;
font-weight: 700;
color: #dc2626;
margin-bottom: 2px;
}
.header-period {
font-size: 11px;
color: #666;
font-style: italic;
}
/* Summary Boxes */
.summary-boxes {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0;
margin-bottom: 20px;
border: 2px solid #1a1a1a;
}
.summary-box {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 14px;
border-bottom: 1px solid #1a1a1a;
}
.summary-box:nth-child(odd) {
border-right: 1px solid #1a1a1a;
}
.summary-box:nth-child(n + 3) {
border-bottom: none;
}
.summary-box.highlight-green {
background: #dcfce7;
}
.summary-box.highlight-red {
background: #fee2e2;
}
.summary-box-label {
font-size: 10px;
font-weight: 700;
color: #1a1a1a;
text-transform: uppercase;
}
.summary-box-value {
font-size: 13px;
font-weight: 800;
color: #1a1a1a;
font-family: "Courier New", monospace;
}
.summary-box-value.negative {
color: #dc2626;
}
.summary-box-value.positive {
color: #16a34a;
}
/* Section Title */
.section-title {
font-size: 14px;
font-weight: 800;
color: #dc2626;
margin-bottom: 10px;
margin-top: 20px;
}
/* Main Summary Table */
.summary-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 20px;
font-size: 11px;
}
.summary-table thead th {
background: #dc2626;
color: #ffffff;
padding: 8px 10px;
text-align: center;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
border: 1px solid #dc2626;
}
.summary-table thead th:first-child {
width: 30px;
}
.summary-table thead th:nth-child(2) {
text-align: left;
width: 35%;
}
.summary-table tbody td {
padding: 7px 10px;
border: 1px solid #e5e7eb;
font-size: 11px;
}
.summary-table tbody td:first-child {
text-align: center;
font-weight: 600;
}
.summary-table tbody td.nominal {
text-align: right;
font-family: "Courier New", monospace;
font-weight: 600;
}
.summary-table tbody td.pct {
text-align: center;
font-weight: 600;
}
.summary-table tbody tr.bold-row td {
font-weight: 800;
background: #f9fafb;
}
.summary-table tbody tr.highlight-row td {
font-weight: 800;
background: #fef3c7;
}
.summary-table tbody tr.highlight-green-row td {
font-weight: 800;
background: #dcfce7;
}
.summary-table tbody tr.sub-item td {
padding-left: 30px;
font-size: 10px;
color: #4b5563;
}
/* Rincian Biaya Table */
.detail-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 20px;
font-size: 11px;
}
.detail-table thead th {
background: #dc2626;
color: #ffffff;
padding: 8px 10px;
text-align: center;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
border: 1px solid #dc2626;
}
.detail-table thead th:first-child {
width: 30px;
}
.detail-table thead th:nth-child(2) {
text-align: left;
}
.detail-table thead th:last-child {
text-align: right;
width: 25%;
}
.detail-table tbody td {
padding: 7px 10px;
border: 1px solid #e5e7eb;
font-size: 11px;
}
.detail-table tbody td:first-child {
text-align: center;
font-weight: 600;
}
.detail-table tbody td:last-child {
text-align: right;
font-family: "Courier New", monospace;
font-weight: 600;
}
.detail-table tbody tr.total-row td {
font-weight: 800;
background: #fef3c7;
border-top: 2px solid #1a1a1a;
}
/* Footer */
.report-footer {
margin-top: 20px;
padding-top: 10px;
border-top: 1px solid #e5e7eb;
font-size: 9px;
color: #9ca3af;
text-align: center;
}
</style>
</head>
<body>
<div class="report-container">
<!-- HEADER -->
<div class="report-header">
<div class="header-logo">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 500 500"
width="40"
height="40"
>
<rect fill="#dc2626" x="0" y="0" width="500" height="500" rx="60" />
<g transform="translate(250,250) scale(3.2)">
<rect
x="-40"
y="-50"
width="80"
height="10"
rx="3"
fill="white"
/>
<rect
x="-30"
y="-35"
width="60"
height="60"
rx="5"
fill="none"
stroke="white"
stroke-width="5"
/>
<circle cx="0" cy="-55" r="8" fill="white" />
<line
x1="-10"
y1="-60"
x2="10"
y2="-50"
stroke="white"
stroke-width="3"
/>
<line
x1="10"
y1="-60"
x2="-10"
y2="-50"
stroke="white"
stroke-width="3"
/>
</g>
</svg>
</div>
<div class="header-title">LAPORAN PENJUALAN HARIAN</div>
<div class="header-org">{{.OrganizationName}}</div>
<div class="header-period">
Bulan: {{.MonthName}} / Tanggal Report: {{.ReportDate}}
</div>
</div>
<!-- SUMMARY BOXES -->
<div class="summary-boxes">
<div class="summary-box">
<span class="summary-box-label"
>TOTAL PENJUALAN {{.ReportDateUpper}}</span
>
<span class="summary-box-value">{{.TotalPenjualan}}</span>
</div>
<div class="summary-box">
<span class="summary-box-label"
>TOTAL BIAYA {{.ReportDateUpper}}</span
>
<span class="summary-box-value">{{.TotalBiaya}}</span>
</div>
<div class="summary-box {{.LabaRugiClass}}">
<span class="summary-box-label">LABA/RUGI {{.ReportDateUpper}}</span>
<span class="summary-box-value {{.LabaRugiValueClass}}"
>{{.LabaRugi}}</span
>
</div>
<div class="summary-box {{.LabaRugiMtdClass}}">
<span class="summary-box-label">LABA/RUGI MTD</span>
<span class="summary-box-value {{.LabaRugiMtdValueClass}}"
>{{.LabaRugiMtd}}</span
>
</div>
</div>
<!-- 1. RINGKASAN LAPORAN -->
<div class="section-title">1. Ringkasan Laporan</div>
<table class="summary-table">
<thead>
<tr>
<th>NO</th>
<th>KETERANGAN</th>
<th>TANGGAL REPORT<br />Nominal</th>
<th>%</th>
<th>MTD<br />Nominal</th>
<th>%</th>
</tr>
</thead>
<tbody>
{{range $i, $row := .MainSummary}}
<tr class="{{$row.RowClass}}">
<td>{{if $row.Number}}{{$row.Number}}{{end}}</td>
<td>{{$row.Label}}</td>
<td class="nominal">{{$row.TodayNominal}}</td>
<td class="pct">{{$row.TodayPct}}</td>
<td class="nominal">{{$row.MtdNominal}}</td>
<td class="pct">{{$row.MtdPct}}</td>
</tr>
{{range $j, $sub := $row.SubItems}}
<tr class="sub-item">
<td></td>
<td>{{$sub.Label}}</td>
<td class="nominal">{{$sub.TodayNominal}}</td>
<td class="pct">{{$sub.TodayPct}}</td>
<td class="nominal">{{$sub.MtdNominal}}</td>
<td class="pct">{{$sub.MtdPct}}</td>
</tr>
{{end}} {{end}}
</tbody>
</table>
<!-- 2. RINCIAN BIAYA / CATATAN -->
<div class="section-title">2. Rincian Biaya / Catatan</div>
<table class="detail-table">
<thead>
<tr>
<th>NO</th>
<th>KETERANGAN</th>
<th>JUMLAH</th>
</tr>
</thead>
<tbody>
{{range $i, $item := .PurchasingItems}}
<tr>
<td>{{add $i 1}}</td>
<td>{{$item.Name}}</td>
<td>{{$item.Amount}}</td>
</tr>
{{end}}
<tr class="total-row">
<td></td>
<td>TOTAL</td>
<td>{{.PurchasingTotal}}</td>
</tr>
</tbody>
</table>
<!-- FOOTER -->
<div class="report-footer">
Dicetak oleh: {{.GeneratedBy}} | {{.PrintTime}} | Powered by APSKEL
</div>
</div>
</body>
</html>