Compare commits
13 Commits
feature/ex
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b0fc9a63b | ||
|
|
793919cf10 | ||
| 25024c210a | |||
|
|
37bcb90ab0 | ||
|
|
e345aeee97 | ||
|
|
486d94335b | ||
| 7d5acb33e8 | |||
|
|
503fb5734f | ||
| ac06a4bbe9 | |||
| 67a5c076e7 | |||
| 7a2060efdc | |||
| a8d62bc5e8 | |||
| 9e0ba0ce56 |
1
.vscode/settings.json
vendored
Normal file
1
.vscode/settings.json
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
@ -8,6 +8,7 @@ const (
|
|||||||
RoleCashier UserRole = "cashier"
|
RoleCashier UserRole = "cashier"
|
||||||
RoleWaiter UserRole = "waiter"
|
RoleWaiter UserRole = "waiter"
|
||||||
RoleOwner UserRole = "owner"
|
RoleOwner UserRole = "owner"
|
||||||
|
RolePurchasing UserRole = "purchasing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func GetAllUserRoles() []UserRole {
|
func GetAllUserRoles() []UserRole {
|
||||||
@ -17,6 +18,7 @@ func GetAllUserRoles() []UserRole {
|
|||||||
RoleCashier,
|
RoleCashier,
|
||||||
RoleWaiter,
|
RoleWaiter,
|
||||||
RoleOwner,
|
RoleOwner,
|
||||||
|
RolePurchasing,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -18,6 +18,7 @@ type PaymentMethodAnalyticsRequest struct {
|
|||||||
type PaymentMethodAnalyticsResponse struct {
|
type PaymentMethodAnalyticsResponse struct {
|
||||||
OrganizationID uuid.UUID `json:"organization_id"`
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
|
OutletName *string `json:"outlet_name,omitempty"`
|
||||||
DateFrom time.Time `json:"date_from"`
|
DateFrom time.Time `json:"date_from"`
|
||||||
DateTo time.Time `json:"date_to"`
|
DateTo time.Time `json:"date_to"`
|
||||||
GroupBy string `json:"group_by"`
|
GroupBy string `json:"group_by"`
|
||||||
@ -54,6 +55,7 @@ type SalesAnalyticsRequest struct {
|
|||||||
type SalesAnalyticsResponse struct {
|
type SalesAnalyticsResponse struct {
|
||||||
OrganizationID uuid.UUID `json:"organization_id"`
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
|
OutletName *string `json:"outlet_name,omitempty"`
|
||||||
DateFrom time.Time `json:"date_from"`
|
DateFrom time.Time `json:"date_from"`
|
||||||
DateTo time.Time `json:"date_to"`
|
DateTo time.Time `json:"date_to"`
|
||||||
GroupBy string `json:"group_by"`
|
GroupBy string `json:"group_by"`
|
||||||
@ -161,6 +163,7 @@ type ProductAnalyticsRequest struct {
|
|||||||
type ProductAnalyticsResponse struct {
|
type ProductAnalyticsResponse struct {
|
||||||
OrganizationID uuid.UUID `json:"organization_id"`
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
|
OutletName *string `json:"outlet_name,omitempty"`
|
||||||
DateFrom time.Time `json:"date_from"`
|
DateFrom time.Time `json:"date_from"`
|
||||||
DateTo time.Time `json:"date_to"`
|
DateTo time.Time `json:"date_to"`
|
||||||
Data []ProductAnalyticsData `json:"data"`
|
Data []ProductAnalyticsData `json:"data"`
|
||||||
@ -198,6 +201,7 @@ type ProductAnalyticsPerCategoryRequest struct {
|
|||||||
type ProductAnalyticsPerCategoryResponse struct {
|
type ProductAnalyticsPerCategoryResponse struct {
|
||||||
OrganizationID uuid.UUID `json:"organization_id"`
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
|
OutletName *string `json:"outlet_name,omitempty"`
|
||||||
DateFrom time.Time `json:"date_from"`
|
DateFrom time.Time `json:"date_from"`
|
||||||
DateTo time.Time `json:"date_to"`
|
DateTo time.Time `json:"date_to"`
|
||||||
Data []ProductAnalyticsPerCategoryData `json:"data"`
|
Data []ProductAnalyticsPerCategoryData `json:"data"`
|
||||||
@ -227,6 +231,7 @@ type DashboardAnalyticsRequest struct {
|
|||||||
type DashboardAnalyticsResponse struct {
|
type DashboardAnalyticsResponse struct {
|
||||||
OrganizationID uuid.UUID `json:"organization_id"`
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
|
OutletName *string `json:"outlet_name,omitempty"`
|
||||||
DateFrom time.Time `json:"date_from"`
|
DateFrom time.Time `json:"date_from"`
|
||||||
DateTo time.Time `json:"date_to"`
|
DateTo time.Time `json:"date_to"`
|
||||||
Overview DashboardOverview `json:"overview"`
|
Overview DashboardOverview `json:"overview"`
|
||||||
@ -243,6 +248,9 @@ type DashboardOverview struct {
|
|||||||
TotalCustomers int64 `json:"total_customers"`
|
TotalCustomers int64 `json:"total_customers"`
|
||||||
VoidedOrders int64 `json:"voided_orders"`
|
VoidedOrders int64 `json:"voided_orders"`
|
||||||
RefundedOrders int64 `json:"refunded_orders"`
|
RefundedOrders int64 `json:"refunded_orders"`
|
||||||
|
TotalItemSold int64 `json:"total_item_sold"`
|
||||||
|
TotalLowStock int64 `json:"total_low_stock"`
|
||||||
|
TotalProductActive int64 `json:"total_product_active"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProfitLossAnalyticsRequest struct {
|
type ProfitLossAnalyticsRequest struct {
|
||||||
@ -256,6 +264,7 @@ type ProfitLossAnalyticsRequest struct {
|
|||||||
type ProfitLossAnalyticsResponse struct {
|
type ProfitLossAnalyticsResponse struct {
|
||||||
OrganizationID uuid.UUID `json:"organization_id"`
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
|
OutletName *string `json:"outlet_name,omitempty"`
|
||||||
DateFrom time.Time `json:"date_from"`
|
DateFrom time.Time `json:"date_from"`
|
||||||
DateTo time.Time `json:"date_to"`
|
DateTo time.Time `json:"date_to"`
|
||||||
GroupBy string `json:"group_by"`
|
GroupBy string `json:"group_by"`
|
||||||
@ -263,10 +272,28 @@ type ProfitLossAnalyticsResponse struct {
|
|||||||
Data []ProfitLossData `json:"data"`
|
Data []ProfitLossData `json:"data"`
|
||||||
ProductData []ProductProfitData `json:"product_data"`
|
ProductData []ProductProfitData `json:"product_data"`
|
||||||
MainSummary []ProfitLossSummaryRow `json:"main_summary"`
|
MainSummary []ProfitLossSummaryRow `json:"main_summary"`
|
||||||
|
Purchasing ProfitLossPurchasing `json:"purchasing"`
|
||||||
OperationalExpenses []OperationalExpenseItem `json:"operational_expenses"`
|
OperationalExpenses []OperationalExpenseItem `json:"operational_expenses"`
|
||||||
OperationalExpensesTotal float64 `json:"operational_expenses_total"`
|
OperationalExpensesTotal float64 `json:"operational_expenses_total"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ProfitLossPurchasing struct {
|
||||||
|
TodayTotal float64 `json:"today_total"`
|
||||||
|
MtdTotal float64 `json:"mtd_total"`
|
||||||
|
TodayRawMaterial float64 `json:"today_raw_material"`
|
||||||
|
MtdRawMaterial float64 `json:"mtd_raw_material"`
|
||||||
|
TodayExpense float64 `json:"today_expense"`
|
||||||
|
MtdExpense float64 `json:"mtd_expense"`
|
||||||
|
Items []ProfitLossPurchasingItem `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProfitLossPurchasingItem struct {
|
||||||
|
Date time.Time `json:"date"`
|
||||||
|
Item string `json:"item"`
|
||||||
|
Quantity float64 `json:"quantity"`
|
||||||
|
Nominal float64 `json:"nominal"`
|
||||||
|
}
|
||||||
|
|
||||||
type ProfitLossSummary struct {
|
type ProfitLossSummary struct {
|
||||||
TotalRevenue float64 `json:"total_revenue"`
|
TotalRevenue float64 `json:"total_revenue"`
|
||||||
TotalCost float64 `json:"total_cost"`
|
TotalCost float64 `json:"total_cost"`
|
||||||
@ -349,6 +376,7 @@ type ExclusiveSummaryMTDRequest struct {
|
|||||||
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"`
|
||||||
@ -408,6 +436,7 @@ 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"`
|
||||||
|
|||||||
@ -12,14 +12,14 @@ type CreateUserRequest struct {
|
|||||||
Name string `json:"name" validate:"required,min=1,max=255"`
|
Name string `json:"name" validate:"required,min=1,max=255"`
|
||||||
Email string `json:"email" validate:"required,email"`
|
Email string `json:"email" validate:"required,email"`
|
||||||
Password string `json:"password" validate:"required,min=6"`
|
Password string `json:"password" validate:"required,min=6"`
|
||||||
Role string `json:"role" validate:"required,oneof=admin manager cashier waiter"`
|
Role string `json:"role" validate:"required,oneof=admin manager cashier waiter owner purchasing"`
|
||||||
Permissions map[string]interface{} `json:"permissions,omitempty"`
|
Permissions map[string]interface{} `json:"permissions,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateUserRequest struct {
|
type UpdateUserRequest struct {
|
||||||
Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"`
|
Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"`
|
||||||
Email *string `json:"email,omitempty" validate:"omitempty,email"`
|
Email *string `json:"email,omitempty" validate:"omitempty,email"`
|
||||||
Role *string `json:"role,omitempty" validate:"omitempty,oneof=admin manager cashier waiter"`
|
Role *string `json:"role,omitempty" validate:"omitempty,oneof=admin manager cashier waiter owner purchasing"`
|
||||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
IsActive *bool `json:"is_active,omitempty"`
|
IsActive *bool `json:"is_active,omitempty"`
|
||||||
Permissions *map[string]interface{} `json:"permissions,omitempty"`
|
Permissions *map[string]interface{} `json:"permissions,omitempty"`
|
||||||
|
|||||||
@ -120,6 +120,9 @@ 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 {
|
||||||
@ -130,11 +133,25 @@ type ProfitLossAnalytics struct {
|
|||||||
TodayCost float64
|
TodayCost float64
|
||||||
MtdRevenue float64
|
MtdRevenue float64
|
||||||
MtdCost float64
|
MtdCost float64
|
||||||
|
TodayPurchasing float64
|
||||||
|
MtdPurchasing float64
|
||||||
|
TodayPurchasingRawMaterial float64
|
||||||
|
MtdPurchasingRawMaterial float64
|
||||||
|
TodayPurchasingExpense float64
|
||||||
|
MtdPurchasingExpense float64
|
||||||
|
PurchasingItems []PurchasingItemDetail
|
||||||
TodayExpenseByCategory []ExpenseCategoryTotal
|
TodayExpenseByCategory []ExpenseCategoryTotal
|
||||||
MtdExpenseByCategory []ExpenseCategoryTotal
|
MtdExpenseByCategory []ExpenseCategoryTotal
|
||||||
OperationalExpenseItems []OperationalExpenseItem
|
OperationalExpenseItems []OperationalExpenseItem
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PurchasingItemDetail struct {
|
||||||
|
Date time.Time
|
||||||
|
Item string
|
||||||
|
Quantity float64
|
||||||
|
Amount float64
|
||||||
|
}
|
||||||
|
|
||||||
type ProfitLossSummary struct {
|
type ProfitLossSummary struct {
|
||||||
TotalRevenue float64
|
TotalRevenue float64
|
||||||
TotalCost float64
|
TotalCost float64
|
||||||
|
|||||||
@ -17,6 +17,8 @@ const (
|
|||||||
RoleManager UserRole = "manager"
|
RoleManager UserRole = "manager"
|
||||||
RoleCashier UserRole = "cashier"
|
RoleCashier UserRole = "cashier"
|
||||||
RoleWaiter UserRole = "waiter"
|
RoleWaiter UserRole = "waiter"
|
||||||
|
RoleOwner UserRole = "owner"
|
||||||
|
RolePurchasing UserRole = "purchasing"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Permissions map[string]interface{}
|
type Permissions map[string]interface{}
|
||||||
@ -46,7 +48,7 @@ type User struct {
|
|||||||
Name string `gorm:"not null;size:255" json:"name" validate:"required,min=1,max=255"`
|
Name string `gorm:"not null;size:255" json:"name" validate:"required,min=1,max=255"`
|
||||||
Email string `gorm:"uniqueIndex;not null;size:255" json:"email" validate:"required,email"`
|
Email string `gorm:"uniqueIndex;not null;size:255" json:"email" validate:"required,email"`
|
||||||
PasswordHash string `gorm:"not null;size:255" json:"-"`
|
PasswordHash string `gorm:"not null;size:255" json:"-"`
|
||||||
Role UserRole `gorm:"not null;size:50" json:"role" validate:"required,oneof=admin manager cashier waiter"`
|
Role UserRole `gorm:"not null;size:50" json:"role" validate:"required,oneof=admin manager cashier waiter owner purchasing"`
|
||||||
Permissions Permissions `gorm:"type:jsonb;default:'{}'" json:"permissions"`
|
Permissions Permissions `gorm:"type:jsonb;default:'{}'" json:"permissions"`
|
||||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||||
|
|||||||
@ -66,3 +66,35 @@ func (h *ReportHandler) GetDailyTransactionReportPDF(c *gin.Context) {
|
|||||||
"file_name": fileName,
|
"file_name": fileName,
|
||||||
}), "ReportHandler::GetDailyTransactionReportPDF")
|
}), "ReportHandler::GetDailyTransactionReportPDF")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *ReportHandler) GetProfitLossReportPDF(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
ci := appcontext.FromGinContext(ctx)
|
||||||
|
|
||||||
|
outletID := h.resolveOutletID(c, ci.OutletID)
|
||||||
|
var dayPtr *time.Time
|
||||||
|
if d := c.Query("date"); d != "" {
|
||||||
|
if t, err := time.Parse("2006-01-02", d); err == nil {
|
||||||
|
dayPtr = &t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := h.userService.GetUserByID(ctx, ci.UserID)
|
||||||
|
var genBy string
|
||||||
|
if err != nil {
|
||||||
|
genBy = ci.UserID.String()
|
||||||
|
} else {
|
||||||
|
genBy = user.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
publicURL, fileName, err := h.reportService.GenerateProfitLossPDF(ctx, ci.OrganizationID.String(), outletID, dayPtr, genBy)
|
||||||
|
if err != nil {
|
||||||
|
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "ReportHandler::GetProfitLossReportPDF", err.Error())}), "ReportHandler::GetProfitLossReportPDF")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(map[string]string{
|
||||||
|
"url": publicURL,
|
||||||
|
"file_name": fileName,
|
||||||
|
}), "ReportHandler::GetProfitLossReportPDF")
|
||||||
|
}
|
||||||
|
|||||||
@ -82,7 +82,11 @@ func (m *AuthMiddleware) RequireRole(allowedRoles ...string) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *AuthMiddleware) RequireAdminOrManager() gin.HandlerFunc {
|
func (m *AuthMiddleware) RequireAdminOrManager() gin.HandlerFunc {
|
||||||
return m.RequireRole("superadmin", "admin", "manager")
|
return m.RequireRole("superadmin", "admin", "manager", "owner", "purchasing")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *AuthMiddleware) RequireAdminOrManagerOrPurchasing() gin.HandlerFunc {
|
||||||
|
return m.RequireRole("superadmin", "admin", "manager", "owner", "purchasing")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *AuthMiddleware) RequireAdmin() gin.HandlerFunc {
|
func (m *AuthMiddleware) RequireAdmin() gin.HandlerFunc {
|
||||||
|
|||||||
@ -19,6 +19,7 @@ type PaymentMethodAnalyticsRequest struct {
|
|||||||
type PaymentMethodAnalyticsResponse struct {
|
type PaymentMethodAnalyticsResponse struct {
|
||||||
OrganizationID uuid.UUID `json:"organization_id"`
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
|
OutletName *string `json:"outlet_name,omitempty"`
|
||||||
DateFrom time.Time `json:"date_from"`
|
DateFrom time.Time `json:"date_from"`
|
||||||
DateTo time.Time `json:"date_to"`
|
DateTo time.Time `json:"date_to"`
|
||||||
GroupBy string `json:"group_by"`
|
GroupBy string `json:"group_by"`
|
||||||
@ -58,6 +59,7 @@ type SalesAnalyticsRequest struct {
|
|||||||
type SalesAnalyticsResponse struct {
|
type SalesAnalyticsResponse struct {
|
||||||
OrganizationID uuid.UUID `json:"organization_id"`
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
|
OutletName *string `json:"outlet_name,omitempty"`
|
||||||
DateFrom time.Time `json:"date_from"`
|
DateFrom time.Time `json:"date_from"`
|
||||||
DateTo time.Time `json:"date_to"`
|
DateTo time.Time `json:"date_to"`
|
||||||
GroupBy string `json:"group_by"`
|
GroupBy string `json:"group_by"`
|
||||||
@ -171,6 +173,7 @@ type ProductAnalyticsRequest struct {
|
|||||||
type ProductAnalyticsResponse struct {
|
type ProductAnalyticsResponse struct {
|
||||||
OrganizationID uuid.UUID `json:"organization_id"`
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
|
OutletName *string `json:"outlet_name,omitempty"`
|
||||||
DateFrom time.Time `json:"date_from"`
|
DateFrom time.Time `json:"date_from"`
|
||||||
DateTo time.Time `json:"date_to"`
|
DateTo time.Time `json:"date_to"`
|
||||||
Data []ProductAnalyticsData `json:"data"`
|
Data []ProductAnalyticsData `json:"data"`
|
||||||
@ -208,6 +211,7 @@ type ProductAnalyticsPerCategoryRequest struct {
|
|||||||
type ProductAnalyticsPerCategoryResponse struct {
|
type ProductAnalyticsPerCategoryResponse struct {
|
||||||
OrganizationID uuid.UUID `json:"organization_id"`
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
|
OutletName *string `json:"outlet_name,omitempty"`
|
||||||
DateFrom time.Time `json:"date_from"`
|
DateFrom time.Time `json:"date_from"`
|
||||||
DateTo time.Time `json:"date_to"`
|
DateTo time.Time `json:"date_to"`
|
||||||
Data []ProductAnalyticsPerCategoryData `json:"data"`
|
Data []ProductAnalyticsPerCategoryData `json:"data"`
|
||||||
@ -237,6 +241,7 @@ type DashboardAnalyticsRequest struct {
|
|||||||
type DashboardAnalyticsResponse struct {
|
type DashboardAnalyticsResponse struct {
|
||||||
OrganizationID uuid.UUID `json:"organization_id"`
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
|
OutletName *string `json:"outlet_name,omitempty"`
|
||||||
DateFrom time.Time `json:"date_from"`
|
DateFrom time.Time `json:"date_from"`
|
||||||
DateTo time.Time `json:"date_to"`
|
DateTo time.Time `json:"date_to"`
|
||||||
Overview DashboardOverview `json:"overview"`
|
Overview DashboardOverview `json:"overview"`
|
||||||
@ -253,6 +258,9 @@ type DashboardOverview struct {
|
|||||||
TotalCustomers int64 `json:"total_customers"`
|
TotalCustomers int64 `json:"total_customers"`
|
||||||
VoidedOrders int64 `json:"voided_orders"`
|
VoidedOrders int64 `json:"voided_orders"`
|
||||||
RefundedOrders int64 `json:"refunded_orders"`
|
RefundedOrders int64 `json:"refunded_orders"`
|
||||||
|
TotalItemSold int64 `json:"total_item_sold"`
|
||||||
|
TotalLowStock int64 `json:"total_low_stock"`
|
||||||
|
TotalProductActive int64 `json:"total_product_active"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProfitLossAnalyticsRequest struct {
|
type ProfitLossAnalyticsRequest struct {
|
||||||
@ -266,6 +274,7 @@ type ProfitLossAnalyticsRequest struct {
|
|||||||
type ProfitLossAnalyticsResponse struct {
|
type ProfitLossAnalyticsResponse struct {
|
||||||
OrganizationID uuid.UUID `json:"organization_id"`
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
|
OutletName *string `json:"outlet_name,omitempty"`
|
||||||
DateFrom time.Time `json:"date_from"`
|
DateFrom time.Time `json:"date_from"`
|
||||||
DateTo time.Time `json:"date_to"`
|
DateTo time.Time `json:"date_to"`
|
||||||
GroupBy string `json:"group_by"`
|
GroupBy string `json:"group_by"`
|
||||||
@ -273,10 +282,28 @@ type ProfitLossAnalyticsResponse struct {
|
|||||||
Data []ProfitLossData `json:"data"`
|
Data []ProfitLossData `json:"data"`
|
||||||
ProductData []ProductProfitData `json:"product_data"`
|
ProductData []ProductProfitData `json:"product_data"`
|
||||||
MainSummary []ProfitLossSummaryRow `json:"main_summary"`
|
MainSummary []ProfitLossSummaryRow `json:"main_summary"`
|
||||||
|
Purchasing ProfitLossPurchasing `json:"purchasing"`
|
||||||
OperationalExpenses []OperationalExpenseItem `json:"operational_expenses"`
|
OperationalExpenses []OperationalExpenseItem `json:"operational_expenses"`
|
||||||
OperationalExpensesTotal float64 `json:"operational_expenses_total"`
|
OperationalExpensesTotal float64 `json:"operational_expenses_total"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ProfitLossPurchasing struct {
|
||||||
|
TodayTotal float64 `json:"today_total"`
|
||||||
|
MtdTotal float64 `json:"mtd_total"`
|
||||||
|
TodayRawMaterial float64 `json:"today_raw_material"`
|
||||||
|
MtdRawMaterial float64 `json:"mtd_raw_material"`
|
||||||
|
TodayExpense float64 `json:"today_expense"`
|
||||||
|
MtdExpense float64 `json:"mtd_expense"`
|
||||||
|
Items []ProfitLossPurchasingItem `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProfitLossPurchasingItem struct {
|
||||||
|
Date time.Time `json:"date"`
|
||||||
|
Item string `json:"item"`
|
||||||
|
Quantity float64 `json:"quantity"`
|
||||||
|
Nominal float64 `json:"nominal"`
|
||||||
|
}
|
||||||
|
|
||||||
type ProfitLossSummary struct {
|
type ProfitLossSummary struct {
|
||||||
TotalRevenue float64 `json:"total_revenue"`
|
TotalRevenue float64 `json:"total_revenue"`
|
||||||
TotalCost float64 `json:"total_cost"`
|
TotalCost float64 `json:"total_cost"`
|
||||||
@ -359,6 +386,7 @@ type ExclusiveSummaryMTDRequest struct {
|
|||||||
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"`
|
||||||
@ -418,6 +446,7 @@ 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"`
|
||||||
|
|||||||
@ -65,8 +65,10 @@ func (u *User) HasPermission(requiredRole constants.UserRole) bool {
|
|||||||
roleHierarchy := map[constants.UserRole]int{
|
roleHierarchy := map[constants.UserRole]int{
|
||||||
constants.RoleWaiter: 1,
|
constants.RoleWaiter: 1,
|
||||||
constants.RoleCashier: 2,
|
constants.RoleCashier: 2,
|
||||||
constants.RoleManager: 3,
|
constants.RolePurchasing: 3,
|
||||||
constants.RoleAdmin: 4,
|
constants.RoleManager: 4,
|
||||||
|
constants.RoleAdmin: 5,
|
||||||
|
constants.RoleOwner: 6,
|
||||||
}
|
}
|
||||||
|
|
||||||
userLevel := roleHierarchy[u.Role]
|
userLevel := roleHierarchy[u.Role]
|
||||||
|
|||||||
@ -9,6 +9,8 @@ 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 {
|
||||||
@ -36,6 +38,18 @@ func NewAnalyticsProcessorImpl(analyticsRepo repository.AnalyticsRepository, exp
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveOutletName fetches the outlet name from the database if outletID is provided
|
||||||
|
func (p *AnalyticsProcessorImpl) resolveOutletName(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) *string {
|
||||||
|
if outletID == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
name, err := p.analyticsRepo.GetOutletName(ctx, organizationID, *outletID)
|
||||||
|
if err != nil || name == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &name
|
||||||
|
}
|
||||||
|
|
||||||
func (p *AnalyticsProcessorImpl) GetPaymentMethodAnalytics(ctx context.Context, req *models.PaymentMethodAnalyticsRequest) (*models.PaymentMethodAnalyticsResponse, error) {
|
func (p *AnalyticsProcessorImpl) GetPaymentMethodAnalytics(ctx context.Context, req *models.PaymentMethodAnalyticsRequest) (*models.PaymentMethodAnalyticsResponse, error) {
|
||||||
if req.DateFrom.After(req.DateTo) {
|
if req.DateFrom.After(req.DateTo) {
|
||||||
return nil, fmt.Errorf("date_from cannot be after date_to")
|
return nil, fmt.Errorf("date_from cannot be after date_to")
|
||||||
@ -90,6 +104,7 @@ func (p *AnalyticsProcessorImpl) GetPaymentMethodAnalytics(ctx context.Context,
|
|||||||
return &models.PaymentMethodAnalyticsResponse{
|
return &models.PaymentMethodAnalyticsResponse{
|
||||||
OrganizationID: req.OrganizationID,
|
OrganizationID: req.OrganizationID,
|
||||||
OutletID: req.OutletID,
|
OutletID: req.OutletID,
|
||||||
|
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
|
||||||
DateFrom: req.DateFrom,
|
DateFrom: req.DateFrom,
|
||||||
DateTo: req.DateTo,
|
DateTo: req.DateTo,
|
||||||
GroupBy: req.GroupBy,
|
GroupBy: req.GroupBy,
|
||||||
@ -164,6 +179,7 @@ func (p *AnalyticsProcessorImpl) GetSalesAnalytics(ctx context.Context, req *mod
|
|||||||
return &models.SalesAnalyticsResponse{
|
return &models.SalesAnalyticsResponse{
|
||||||
OrganizationID: req.OrganizationID,
|
OrganizationID: req.OrganizationID,
|
||||||
OutletID: req.OutletID,
|
OutletID: req.OutletID,
|
||||||
|
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
|
||||||
DateFrom: req.DateFrom,
|
DateFrom: req.DateFrom,
|
||||||
DateTo: req.DateTo,
|
DateTo: req.DateTo,
|
||||||
GroupBy: req.GroupBy,
|
GroupBy: req.GroupBy,
|
||||||
@ -295,6 +311,7 @@ func (p *AnalyticsProcessorImpl) GetProductAnalytics(ctx context.Context, req *m
|
|||||||
return &models.ProductAnalyticsResponse{
|
return &models.ProductAnalyticsResponse{
|
||||||
OrganizationID: req.OrganizationID,
|
OrganizationID: req.OrganizationID,
|
||||||
OutletID: req.OutletID,
|
OutletID: req.OutletID,
|
||||||
|
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
|
||||||
DateFrom: req.DateFrom,
|
DateFrom: req.DateFrom,
|
||||||
DateTo: req.DateTo,
|
DateTo: req.DateTo,
|
||||||
Data: resultData,
|
Data: resultData,
|
||||||
@ -332,6 +349,7 @@ func (p *AnalyticsProcessorImpl) GetProductAnalyticsPerCategory(ctx context.Cont
|
|||||||
return &models.ProductAnalyticsPerCategoryResponse{
|
return &models.ProductAnalyticsPerCategoryResponse{
|
||||||
OrganizationID: req.OrganizationID,
|
OrganizationID: req.OrganizationID,
|
||||||
OutletID: req.OutletID,
|
OutletID: req.OutletID,
|
||||||
|
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
|
||||||
DateFrom: req.DateFrom,
|
DateFrom: req.DateFrom,
|
||||||
DateTo: req.DateTo,
|
DateTo: req.DateTo,
|
||||||
Data: resultData,
|
Data: resultData,
|
||||||
@ -393,6 +411,7 @@ 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{
|
||||||
@ -402,6 +421,9 @@ func (p *AnalyticsProcessorImpl) GetDashboardAnalytics(ctx context.Context, req
|
|||||||
TotalCustomers: overview.TotalCustomers,
|
TotalCustomers: overview.TotalCustomers,
|
||||||
VoidedOrders: overview.VoidedOrders,
|
VoidedOrders: overview.VoidedOrders,
|
||||||
RefundedOrders: overview.RefundedOrders,
|
RefundedOrders: overview.RefundedOrders,
|
||||||
|
TotalItemSold: overview.TotalItemSold,
|
||||||
|
TotalLowStock: overview.TotalLowStock,
|
||||||
|
TotalProductActive: overview.TotalProductActive,
|
||||||
},
|
},
|
||||||
TopProducts: topProducts.Data,
|
TopProducts: topProducts.Data,
|
||||||
PaymentMethods: paymentMethods.Data,
|
PaymentMethods: paymentMethods.Data,
|
||||||
@ -604,9 +626,20 @@ func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req
|
|||||||
opsTotal += item.Amount
|
opsTotal += item.Amount
|
||||||
}
|
}
|
||||||
|
|
||||||
|
purchasingItems := make([]models.ProfitLossPurchasingItem, len(result.PurchasingItems))
|
||||||
|
for i, item := range result.PurchasingItems {
|
||||||
|
purchasingItems[i] = models.ProfitLossPurchasingItem{
|
||||||
|
Date: item.Date,
|
||||||
|
Item: item.Item,
|
||||||
|
Quantity: item.Quantity,
|
||||||
|
Nominal: item.Amount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return &models.ProfitLossAnalyticsResponse{
|
return &models.ProfitLossAnalyticsResponse{
|
||||||
OrganizationID: req.OrganizationID,
|
OrganizationID: req.OrganizationID,
|
||||||
OutletID: req.OutletID,
|
OutletID: req.OutletID,
|
||||||
|
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
|
||||||
DateFrom: req.DateFrom,
|
DateFrom: req.DateFrom,
|
||||||
DateTo: req.DateTo,
|
DateTo: req.DateTo,
|
||||||
GroupBy: req.GroupBy,
|
GroupBy: req.GroupBy,
|
||||||
@ -626,6 +659,15 @@ func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req
|
|||||||
Data: data,
|
Data: data,
|
||||||
ProductData: productData,
|
ProductData: productData,
|
||||||
MainSummary: mainSummary,
|
MainSummary: mainSummary,
|
||||||
|
Purchasing: models.ProfitLossPurchasing{
|
||||||
|
TodayTotal: result.TodayPurchasing,
|
||||||
|
MtdTotal: result.MtdPurchasing,
|
||||||
|
TodayRawMaterial: result.TodayPurchasingRawMaterial,
|
||||||
|
MtdRawMaterial: result.MtdPurchasingRawMaterial,
|
||||||
|
TodayExpense: result.TodayPurchasingExpense,
|
||||||
|
MtdExpense: result.MtdPurchasingExpense,
|
||||||
|
Items: purchasingItems,
|
||||||
|
},
|
||||||
OperationalExpenses: opsItems,
|
OperationalExpenses: opsItems,
|
||||||
OperationalExpensesTotal: opsTotal,
|
OperationalExpensesTotal: opsTotal,
|
||||||
}, nil
|
}, nil
|
||||||
@ -721,6 +763,7 @@ 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,
|
||||||
@ -795,6 +838,7 @@ 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,
|
||||||
|
|||||||
@ -68,6 +68,10 @@ 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 }
|
||||||
|
|||||||
@ -21,6 +21,7 @@ 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 {
|
||||||
@ -40,6 +41,22 @@ 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 {
|
func purchaseOrderItemTotalAmountSQL() string {
|
||||||
return "CASE WHEN pc.type = '" + string(entities.PurchaseCategoryTypeRawMaterial) + "' THEN COALESCE(poi.quantity, 0) * poi.amount ELSE poi.amount END"
|
return "CASE WHEN pc.type = '" + string(entities.PurchaseCategoryTypeRawMaterial) + "' THEN COALESCE(poi.quantity, 0) * poi.amount ELSE poi.amount END"
|
||||||
}
|
}
|
||||||
@ -471,6 +488,41 @@ func (r *AnalyticsRepositoryImpl) GetDashboardOverview(ctx context.Context, orga
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Total item sold (sum of order_items quantity for completed orders in date range)
|
||||||
|
var totalItemSold int64
|
||||||
|
itemQuery := r.db.WithContext(ctx).
|
||||||
|
Table("order_items oi").
|
||||||
|
Select("COALESCE(SUM(oi.quantity), 0)").
|
||||||
|
Joins("JOIN orders o ON o.id = oi.order_id").
|
||||||
|
Where("o.organization_id = ?", organizationID).
|
||||||
|
Where("o.is_void = false AND o.is_refund = false AND o.payment_status = 'completed'").
|
||||||
|
Where("o.created_at >= ? AND o.created_at <= ?", dateFrom, dateTo)
|
||||||
|
itemQuery = r.resolveOutletID(itemQuery, outletID, "o.outlet_id")
|
||||||
|
itemQuery.Scan(&totalItemSold)
|
||||||
|
result.TotalItemSold = totalItemSold
|
||||||
|
|
||||||
|
// Total low stock (inventory where quantity <= reorder_level)
|
||||||
|
var totalLowStock int64
|
||||||
|
lowStockQuery := r.db.WithContext(ctx).
|
||||||
|
Table("inventory i").
|
||||||
|
Select("COUNT(i.id)").
|
||||||
|
Joins("JOIN products p ON p.id = i.product_id").
|
||||||
|
Where("p.organization_id = ?", organizationID).
|
||||||
|
Where("i.quantity <= i.reorder_level")
|
||||||
|
lowStockQuery = r.resolveOutletID(lowStockQuery, outletID, "i.outlet_id")
|
||||||
|
lowStockQuery.Scan(&totalLowStock)
|
||||||
|
result.TotalLowStock = totalLowStock
|
||||||
|
|
||||||
|
// Total active products
|
||||||
|
var totalProductActive int64
|
||||||
|
productQuery := r.db.WithContext(ctx).
|
||||||
|
Table("products p").
|
||||||
|
Select("COUNT(p.id)").
|
||||||
|
Where("p.organization_id = ?", organizationID).
|
||||||
|
Where("p.is_active = true")
|
||||||
|
productQuery.Scan(&totalProductActive)
|
||||||
|
result.TotalProductActive = totalProductActive
|
||||||
|
|
||||||
return &result, nil
|
return &result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -695,6 +747,20 @@ func (r *AnalyticsRepositoryImpl) GetProfitLossAnalytics(ctx context.Context, or
|
|||||||
}
|
}
|
||||||
opsItems = mergeOperationalExpenseItems(opsItems, poOpsItems)
|
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,
|
||||||
@ -703,6 +769,13 @@ func (r *AnalyticsRepositoryImpl) GetProfitLossAnalytics(ctx context.Context, or
|
|||||||
TodayCost: todayRC.Cost,
|
TodayCost: todayRC.Cost,
|
||||||
MtdRevenue: mtdRC.Revenue,
|
MtdRevenue: mtdRC.Revenue,
|
||||||
MtdCost: mtdRC.Cost,
|
MtdCost: mtdRC.Cost,
|
||||||
|
TodayPurchasing: todayPurchasing.Total,
|
||||||
|
MtdPurchasing: mtdPurchasing.Total,
|
||||||
|
TodayPurchasingRawMaterial: todayPurchasing.RawMaterial,
|
||||||
|
MtdPurchasingRawMaterial: mtdPurchasing.RawMaterial,
|
||||||
|
TodayPurchasingExpense: todayPurchasing.Expense,
|
||||||
|
MtdPurchasingExpense: mtdPurchasing.Expense,
|
||||||
|
PurchasingItems: purchasingItems,
|
||||||
TodayExpenseByCategory: todayExpenseByCategory,
|
TodayExpenseByCategory: todayExpenseByCategory,
|
||||||
MtdExpenseByCategory: mtdExpenseByCategory,
|
MtdExpenseByCategory: mtdExpenseByCategory,
|
||||||
OperationalExpenseItems: opsItems,
|
OperationalExpenseItems: opsItems,
|
||||||
@ -732,6 +805,68 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderRawMaterialTotal(ctx context.C
|
|||||||
return result.Total, nil
|
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) {
|
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
|
var dateFormat string
|
||||||
switch groupBy {
|
switch groupBy {
|
||||||
|
|||||||
@ -356,7 +356,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ingredients := protected.Group("/ingredients")
|
ingredients := protected.Group("/ingredients")
|
||||||
ingredients.Use(r.authMiddleware.RequireAdminOrManager())
|
ingredients.Use(r.authMiddleware.RequireAdminOrManagerOrPurchasing())
|
||||||
{
|
{
|
||||||
ingredients.POST("", r.ingredientHandler.Create)
|
ingredients.POST("", r.ingredientHandler.Create)
|
||||||
ingredients.GET("", r.ingredientHandler.GetAll)
|
ingredients.GET("", r.ingredientHandler.GetAll)
|
||||||
@ -369,7 +369,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
vendors := protected.Group("/vendors")
|
vendors := protected.Group("/vendors")
|
||||||
vendors.Use(r.authMiddleware.RequireAdminOrManager())
|
vendors.Use(r.authMiddleware.RequireAdminOrManagerOrPurchasing())
|
||||||
{
|
{
|
||||||
vendors.POST("", r.vendorHandler.CreateVendor)
|
vendors.POST("", r.vendorHandler.CreateVendor)
|
||||||
vendors.GET("", r.vendorHandler.ListVendors)
|
vendors.GET("", r.vendorHandler.ListVendors)
|
||||||
@ -380,7 +380,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
purchaseOrders := protected.Group("/purchase-orders")
|
purchaseOrders := protected.Group("/purchase-orders")
|
||||||
purchaseOrders.Use(r.authMiddleware.RequireAdminOrManager())
|
purchaseOrders.Use(r.authMiddleware.RequireAdminOrManagerOrPurchasing())
|
||||||
{
|
{
|
||||||
purchaseOrders.POST("", r.purchaseOrderHandler.CreatePurchaseOrder)
|
purchaseOrders.POST("", r.purchaseOrderHandler.CreatePurchaseOrder)
|
||||||
purchaseOrders.GET("", r.purchaseOrderHandler.ListPurchaseOrders)
|
purchaseOrders.GET("", r.purchaseOrderHandler.ListPurchaseOrders)
|
||||||
@ -393,7 +393,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
purchaseCategories := protected.Group("/purchase-categories")
|
purchaseCategories := protected.Group("/purchase-categories")
|
||||||
purchaseCategories.Use(r.authMiddleware.RequireAdminOrManager())
|
purchaseCategories.Use(r.authMiddleware.RequireAdminOrManagerOrPurchasing())
|
||||||
{
|
{
|
||||||
purchaseCategories.POST("", r.purchaseCategoryHandler.CreatePurchaseCategory)
|
purchaseCategories.POST("", r.purchaseCategoryHandler.CreatePurchaseCategory)
|
||||||
purchaseCategories.GET("", r.purchaseCategoryHandler.ListPurchaseCategories)
|
purchaseCategories.GET("", r.purchaseCategoryHandler.ListPurchaseCategories)
|
||||||
@ -403,7 +403,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
unitConverters := protected.Group("/unit-converters")
|
unitConverters := protected.Group("/unit-converters")
|
||||||
unitConverters.Use(r.authMiddleware.RequireAdminOrManager())
|
unitConverters.Use(r.authMiddleware.RequireAdminOrManagerOrPurchasing())
|
||||||
{
|
{
|
||||||
unitConverters.POST("", r.unitConverterHandler.CreateIngredientUnitConverter)
|
unitConverters.POST("", r.unitConverterHandler.CreateIngredientUnitConverter)
|
||||||
unitConverters.GET("", r.unitConverterHandler.ListIngredientUnitConverters)
|
unitConverters.GET("", r.unitConverterHandler.ListIngredientUnitConverters)
|
||||||
@ -465,7 +465,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
expenses := protected.Group("/expenses")
|
expenses := protected.Group("/expenses")
|
||||||
expenses.Use(r.authMiddleware.RequireAdminOrManager())
|
expenses.Use(r.authMiddleware.RequireAdminOrManagerOrPurchasing())
|
||||||
{
|
{
|
||||||
expenses.POST("", r.expenseHandler.CreateExpense)
|
expenses.POST("", r.expenseHandler.CreateExpense)
|
||||||
expenses.GET("", r.expenseHandler.ListExpenses)
|
expenses.GET("", r.expenseHandler.ListExpenses)
|
||||||
@ -620,6 +620,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
|||||||
outlets.GET("/:outlet_id/tables/occupied", r.tableHandler.GetOccupiedTables)
|
outlets.GET("/:outlet_id/tables/occupied", r.tableHandler.GetOccupiedTables)
|
||||||
// Reports
|
// Reports
|
||||||
outlets.GET("/:outlet_id/reports/daily-transaction.pdf", r.reportHandler.GetDailyTransactionReportPDF)
|
outlets.GET("/:outlet_id/reports/daily-transaction.pdf", r.reportHandler.GetDailyTransactionReportPDF)
|
||||||
|
outlets.GET("/:outlet_id/reports/profit-loss.pdf", r.reportHandler.GetProfitLossReportPDF)
|
||||||
}
|
}
|
||||||
|
|
||||||
// User device routes - accessible by authenticated users for their own devices
|
// User device routes - accessible by authenticated users for their own devices
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import (
|
|||||||
type ReportService interface {
|
type ReportService interface {
|
||||||
// Returns (publicURL, fileName, error)
|
// Returns (publicURL, fileName, error)
|
||||||
GenerateDailyTransactionPDF(ctx context.Context, organizationID string, outletID string, reportDate *time.Time, generatedBy string) (string, string, error)
|
GenerateDailyTransactionPDF(ctx context.Context, organizationID string, outletID string, reportDate *time.Time, generatedBy string) (string, string, error)
|
||||||
|
GenerateProfitLossPDF(ctx context.Context, organizationID string, outletID string, reportDate *time.Time, generatedBy string) (string, string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type ReportServiceImpl struct {
|
type ReportServiceImpl struct {
|
||||||
@ -218,3 +219,296 @@ func getPLPctByID(rows []models.ProfitLossSummaryRow, id string) float64 {
|
|||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// profitLossReportData holds data for the profit/loss PDF template
|
||||||
|
type profitLossReportData struct {
|
||||||
|
OrganizationName string
|
||||||
|
MonthName string
|
||||||
|
ReportDate string
|
||||||
|
ReportDateUpper string
|
||||||
|
TotalPenjualan string
|
||||||
|
TotalBiaya string
|
||||||
|
LabaRugi string
|
||||||
|
LabaRugiClass string
|
||||||
|
LabaRugiValueClass string
|
||||||
|
LabaRugiMtd string
|
||||||
|
LabaRugiMtdClass string
|
||||||
|
LabaRugiMtdValueClass string
|
||||||
|
MainSummary []profitLossSummaryRowView
|
||||||
|
PurchasingItems []profitLossPurchasingItem
|
||||||
|
PurchasingTotal string
|
||||||
|
GeneratedBy string
|
||||||
|
PrintTime string
|
||||||
|
}
|
||||||
|
|
||||||
|
type profitLossSummaryRowView struct {
|
||||||
|
Number string
|
||||||
|
Label string
|
||||||
|
TodayNominal string
|
||||||
|
TodayPct string
|
||||||
|
MtdNominal string
|
||||||
|
MtdPct string
|
||||||
|
RowClass string
|
||||||
|
SubItems []profitLossSummaryRowView
|
||||||
|
}
|
||||||
|
|
||||||
|
type profitLossPurchasingItem struct {
|
||||||
|
Name string
|
||||||
|
Amount string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ReportServiceImpl) GenerateProfitLossPDF(ctx context.Context, organizationID string, outletID string, reportDate *time.Time, generatedBy string) (string, string, error) {
|
||||||
|
orgID, err := uuid.Parse(organizationID)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("invalid organization id: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var outID *uuid.UUID
|
||||||
|
if outletID != "" {
|
||||||
|
parsed, err := uuid.Parse(outletID)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("invalid outlet id: %w", err)
|
||||||
|
}
|
||||||
|
outID = &parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
org, err := s.organizationRepo.GetByID(ctx, orgID)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("organization not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var tzName string
|
||||||
|
if outID != nil {
|
||||||
|
outlet, err := s.outletRepo.GetByID(ctx, *outID)
|
||||||
|
if err == nil && outlet.Timezone != nil && *outlet.Timezone != "" {
|
||||||
|
tzName = *outlet.Timezone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tzName == "" {
|
||||||
|
tzName = "Asia/Jakarta"
|
||||||
|
}
|
||||||
|
|
||||||
|
loc, locErr := time.LoadLocation(tzName)
|
||||||
|
if locErr != nil || loc == nil {
|
||||||
|
loc = time.Local
|
||||||
|
}
|
||||||
|
|
||||||
|
var day time.Time
|
||||||
|
if reportDate != nil {
|
||||||
|
t := reportDate.UTC()
|
||||||
|
day = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc)
|
||||||
|
} else {
|
||||||
|
now := time.Now().In(loc)
|
||||||
|
day = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
|
||||||
|
}
|
||||||
|
|
||||||
|
dayStart := day
|
||||||
|
dayEnd := day.Add(24*time.Hour - time.Nanosecond)
|
||||||
|
|
||||||
|
// MTD: from 1st of month to end of the report day
|
||||||
|
mtdStart := time.Date(day.Year(), day.Month(), 1, 0, 0, 0, 0, loc)
|
||||||
|
mtdEnd := dayEnd
|
||||||
|
|
||||||
|
// Get profit/loss analytics for the day
|
||||||
|
plReq := &models.ProfitLossAnalyticsRequest{
|
||||||
|
OrganizationID: orgID,
|
||||||
|
OutletID: outID,
|
||||||
|
DateFrom: dayStart,
|
||||||
|
DateTo: mtdEnd,
|
||||||
|
GroupBy: "day",
|
||||||
|
}
|
||||||
|
pl, err := s.analyticsService.GetProfitLossAnalytics(ctx, plReq)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("get profit/loss analytics: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get purchasing analytics for the day (Rincian Biaya / Catatan)
|
||||||
|
purchReq := &models.PurchasingAnalyticsRequest{
|
||||||
|
OrganizationID: orgID,
|
||||||
|
OutletID: outID,
|
||||||
|
DateFrom: dayStart,
|
||||||
|
DateTo: dayEnd,
|
||||||
|
GroupBy: "day",
|
||||||
|
}
|
||||||
|
purch, err := s.analyticsService.GetPurchasingAnalytics(ctx, purchReq)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("get purchasing analytics: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build summary values
|
||||||
|
totalOmset := getPLNominalByID(pl.MainSummary, "total_omset")
|
||||||
|
hpp := getPLNominalByID(pl.MainSummary, "hpp")
|
||||||
|
_ = mtdStart // used above
|
||||||
|
|
||||||
|
// Total biaya = HPP + operational expenses for the day
|
||||||
|
totalBiayaToday := hpp + pl.OperationalExpensesTotal
|
||||||
|
|
||||||
|
// Laba/Rugi today
|
||||||
|
labaRugiToday := totalOmset - totalBiayaToday
|
||||||
|
|
||||||
|
// MTD values
|
||||||
|
mtdOmset := getMtdNominalByID(pl.MainSummary, "total_omset")
|
||||||
|
mtdCost := getMtdNominalByID(pl.MainSummary, "hpp")
|
||||||
|
mtdOps := getMtdNominalByID(pl.MainSummary, "biaya_ops")
|
||||||
|
mtdGaji := getMtdNominalByID(pl.MainSummary, "biaya_gaji")
|
||||||
|
labaRugiMtd := mtdOmset - mtdCost - mtdOps - mtdGaji
|
||||||
|
|
||||||
|
// Month name in Indonesian
|
||||||
|
monthNames := []string{"", "Januari", "Februari", "Maret", "April", "Mei", "Juni", "Juli", "Agustus", "September", "Oktober", "November", "Desember"}
|
||||||
|
monthName := fmt.Sprintf("%s %d", monthNames[day.Month()], day.Year())
|
||||||
|
|
||||||
|
reportDateStr := fmt.Sprintf("%d %s %d", day.Day(), monthNames[day.Month()], day.Year())
|
||||||
|
reportDateUpper := fmt.Sprintf("%d %s %d", day.Day(), strings.ToUpper(monthNames[day.Month()]), day.Year())
|
||||||
|
|
||||||
|
// Build main summary rows
|
||||||
|
mainSummaryRows := buildProfitLossSummaryRows(pl.MainSummary)
|
||||||
|
|
||||||
|
// Build purchasing items from ingredient data
|
||||||
|
purchItems := make([]profitLossPurchasingItem, 0)
|
||||||
|
var purchTotal float64
|
||||||
|
for _, item := range purch.IngredientData {
|
||||||
|
purchItems = append(purchItems, profitLossPurchasingItem{
|
||||||
|
Name: item.IngredientName,
|
||||||
|
Amount: formatCurrency(item.TotalCost),
|
||||||
|
})
|
||||||
|
purchTotal += item.TotalCost
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine highlight classes
|
||||||
|
labaRugiClass := ""
|
||||||
|
labaRugiValueClass := ""
|
||||||
|
if labaRugiToday < 0 {
|
||||||
|
labaRugiClass = "highlight-red"
|
||||||
|
labaRugiValueClass = "negative"
|
||||||
|
} else {
|
||||||
|
labaRugiClass = "highlight-green"
|
||||||
|
labaRugiValueClass = "positive"
|
||||||
|
}
|
||||||
|
|
||||||
|
labaRugiMtdClass := ""
|
||||||
|
labaRugiMtdValueClass := ""
|
||||||
|
if labaRugiMtd < 0 {
|
||||||
|
labaRugiMtdClass = "highlight-red"
|
||||||
|
labaRugiMtdValueClass = "negative"
|
||||||
|
} else {
|
||||||
|
labaRugiMtdClass = "highlight-green"
|
||||||
|
labaRugiMtdValueClass = "positive"
|
||||||
|
}
|
||||||
|
|
||||||
|
data := profitLossReportData{
|
||||||
|
OrganizationName: org.Name,
|
||||||
|
MonthName: monthName,
|
||||||
|
ReportDate: reportDateStr,
|
||||||
|
ReportDateUpper: reportDateUpper,
|
||||||
|
TotalPenjualan: formatCurrency(totalOmset),
|
||||||
|
TotalBiaya: formatCurrency(totalBiayaToday),
|
||||||
|
LabaRugi: formatCurrencySigned(labaRugiToday),
|
||||||
|
LabaRugiClass: labaRugiClass,
|
||||||
|
LabaRugiValueClass: labaRugiValueClass,
|
||||||
|
LabaRugiMtd: formatCurrencySigned(labaRugiMtd),
|
||||||
|
LabaRugiMtdClass: labaRugiMtdClass,
|
||||||
|
LabaRugiMtdValueClass: labaRugiMtdValueClass,
|
||||||
|
MainSummary: mainSummaryRows,
|
||||||
|
PurchasingItems: purchItems,
|
||||||
|
PurchasingTotal: formatCurrency(purchTotal),
|
||||||
|
GeneratedBy: generatedBy,
|
||||||
|
PrintTime: time.Now().In(loc).Format("02/01/2006 15:04:05"),
|
||||||
|
}
|
||||||
|
|
||||||
|
templatePath := filepath.Join("templates", "profit_loss_report.html")
|
||||||
|
pdfBytes, err := renderTemplateToPDF(templatePath, data)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("render pdf: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
safeOrg := orgID.String()
|
||||||
|
safeOutlet := "all"
|
||||||
|
if outID != nil {
|
||||||
|
safeOutlet = outID.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
fileName := fmt.Sprintf("laporan-laba-rugi-%s-%s.pdf", day.Format("2006-01-02"), time.Now().Format("20060102-150405"))
|
||||||
|
objectKey := fmt.Sprintf("/reports/%s/%s/%s", safeOrg, safeOutlet, fileName)
|
||||||
|
publicURL, err := s.fileClient.UploadFile(ctx, objectKey, pdfBytes)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("upload pdf: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return publicURL, fileName, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getMtdNominalByID(rows []models.ProfitLossSummaryRow, id string) float64 {
|
||||||
|
for _, row := range rows {
|
||||||
|
if row.ID == id {
|
||||||
|
return row.MtdNominal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildProfitLossSummaryRows(rows []models.ProfitLossSummaryRow) []profitLossSummaryRowView {
|
||||||
|
result := make([]profitLossSummaryRowView, 0, len(rows))
|
||||||
|
for i, row := range rows {
|
||||||
|
rowClass := ""
|
||||||
|
if row.IsBold {
|
||||||
|
rowClass = "highlight-green-row"
|
||||||
|
}
|
||||||
|
// Highlight laba kotor row
|
||||||
|
if row.ID == "laba_kotor" {
|
||||||
|
rowClass = "highlight-row"
|
||||||
|
}
|
||||||
|
|
||||||
|
number := ""
|
||||||
|
if row.ID != "" {
|
||||||
|
number = fmt.Sprintf("%d", i+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
subItems := make([]profitLossSummaryRowView, 0)
|
||||||
|
for _, sub := range row.SubItems {
|
||||||
|
subItems = append(subItems, profitLossSummaryRowView{
|
||||||
|
Label: sub.Label,
|
||||||
|
TodayNominal: formatCurrencyOrDash(sub.TodayNominal),
|
||||||
|
TodayPct: formatPct(sub.TodayPct),
|
||||||
|
MtdNominal: formatCurrencyOrDash(sub.MtdNominal),
|
||||||
|
MtdPct: formatPct(sub.MtdPct),
|
||||||
|
RowClass: "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
result = append(result, profitLossSummaryRowView{
|
||||||
|
Number: number,
|
||||||
|
Label: row.Label,
|
||||||
|
TodayNominal: formatCurrencyOrDash(row.TodayNominal),
|
||||||
|
TodayPct: formatPct(row.TodayPct),
|
||||||
|
MtdNominal: formatCurrencyOrDash(row.MtdNominal),
|
||||||
|
MtdPct: formatPct(row.MtdPct),
|
||||||
|
RowClass: rowClass,
|
||||||
|
SubItems: subItems,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatCurrencyOrDash(amount float64) string {
|
||||||
|
if amount == 0 {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
if amount < 0 {
|
||||||
|
return formatCurrencySigned(amount)
|
||||||
|
}
|
||||||
|
return formatCurrency(amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatCurrencySigned(amount float64) string {
|
||||||
|
if amount < 0 {
|
||||||
|
return "(Rp " + addThousandsSep(fmt.Sprintf("%.0f", -amount)) + ")"
|
||||||
|
}
|
||||||
|
return "Rp " + addThousandsSep(fmt.Sprintf("%.0f", amount))
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatPct(pct float64) string {
|
||||||
|
if pct == 0 {
|
||||||
|
return "0%"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.0f%%", pct)
|
||||||
|
}
|
||||||
|
|||||||
@ -66,6 +66,7 @@ func PaymentMethodAnalyticsModelToContract(resp *models.PaymentMethodAnalyticsRe
|
|||||||
return &contract.PaymentMethodAnalyticsResponse{
|
return &contract.PaymentMethodAnalyticsResponse{
|
||||||
OrganizationID: resp.OrganizationID,
|
OrganizationID: resp.OrganizationID,
|
||||||
OutletID: resp.OutletID,
|
OutletID: resp.OutletID,
|
||||||
|
OutletName: resp.OutletName,
|
||||||
DateFrom: resp.DateFrom,
|
DateFrom: resp.DateFrom,
|
||||||
DateTo: resp.DateTo,
|
DateTo: resp.DateTo,
|
||||||
GroupBy: resp.GroupBy,
|
GroupBy: resp.GroupBy,
|
||||||
@ -122,6 +123,7 @@ func SalesAnalyticsModelToContract(resp *models.SalesAnalyticsResponse) *contrac
|
|||||||
return &contract.SalesAnalyticsResponse{
|
return &contract.SalesAnalyticsResponse{
|
||||||
OrganizationID: resp.OrganizationID,
|
OrganizationID: resp.OrganizationID,
|
||||||
OutletID: resp.OutletID,
|
OutletID: resp.OutletID,
|
||||||
|
OutletName: resp.OutletName,
|
||||||
DateFrom: resp.DateFrom,
|
DateFrom: resp.DateFrom,
|
||||||
DateTo: resp.DateTo,
|
DateTo: resp.DateTo,
|
||||||
GroupBy: resp.GroupBy,
|
GroupBy: resp.GroupBy,
|
||||||
@ -285,6 +287,7 @@ func ProductAnalyticsModelToContract(resp *models.ProductAnalyticsResponse) *con
|
|||||||
return &contract.ProductAnalyticsResponse{
|
return &contract.ProductAnalyticsResponse{
|
||||||
OrganizationID: resp.OrganizationID,
|
OrganizationID: resp.OrganizationID,
|
||||||
OutletID: resp.OutletID,
|
OutletID: resp.OutletID,
|
||||||
|
OutletName: resp.OutletName,
|
||||||
DateFrom: resp.DateFrom,
|
DateFrom: resp.DateFrom,
|
||||||
DateTo: resp.DateTo,
|
DateTo: resp.DateTo,
|
||||||
Data: data,
|
Data: data,
|
||||||
@ -337,6 +340,7 @@ func ProductAnalyticsPerCategoryModelToContract(resp *models.ProductAnalyticsPer
|
|||||||
return &contract.ProductAnalyticsPerCategoryResponse{
|
return &contract.ProductAnalyticsPerCategoryResponse{
|
||||||
OrganizationID: resp.OrganizationID,
|
OrganizationID: resp.OrganizationID,
|
||||||
OutletID: resp.OutletID,
|
OutletID: resp.OutletID,
|
||||||
|
OutletName: resp.OutletName,
|
||||||
DateFrom: resp.DateFrom,
|
DateFrom: resp.DateFrom,
|
||||||
DateTo: resp.DateTo,
|
DateTo: resp.DateTo,
|
||||||
Data: data,
|
Data: data,
|
||||||
@ -421,6 +425,7 @@ 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{
|
||||||
@ -430,6 +435,9 @@ func DashboardAnalyticsModelToContract(resp *models.DashboardAnalyticsResponse)
|
|||||||
TotalCustomers: resp.Overview.TotalCustomers,
|
TotalCustomers: resp.Overview.TotalCustomers,
|
||||||
VoidedOrders: resp.Overview.VoidedOrders,
|
VoidedOrders: resp.Overview.VoidedOrders,
|
||||||
RefundedOrders: resp.Overview.RefundedOrders,
|
RefundedOrders: resp.Overview.RefundedOrders,
|
||||||
|
TotalItemSold: resp.Overview.TotalItemSold,
|
||||||
|
TotalLowStock: resp.Overview.TotalLowStock,
|
||||||
|
TotalProductActive: resp.Overview.TotalProductActive,
|
||||||
},
|
},
|
||||||
TopProducts: topProducts,
|
TopProducts: topProducts,
|
||||||
PaymentMethods: paymentMethods,
|
PaymentMethods: paymentMethods,
|
||||||
@ -516,9 +524,20 @@ func ProfitLossAnalyticsModelToContract(resp *models.ProfitLossAnalyticsResponse
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
purchasingItems := make([]contract.ProfitLossPurchasingItem, len(resp.Purchasing.Items))
|
||||||
|
for i, item := range resp.Purchasing.Items {
|
||||||
|
purchasingItems[i] = contract.ProfitLossPurchasingItem{
|
||||||
|
Date: item.Date,
|
||||||
|
Item: item.Item,
|
||||||
|
Quantity: item.Quantity,
|
||||||
|
Nominal: item.Nominal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return &contract.ProfitLossAnalyticsResponse{
|
return &contract.ProfitLossAnalyticsResponse{
|
||||||
OrganizationID: resp.OrganizationID,
|
OrganizationID: resp.OrganizationID,
|
||||||
OutletID: resp.OutletID,
|
OutletID: resp.OutletID,
|
||||||
|
OutletName: resp.OutletName,
|
||||||
DateFrom: resp.DateFrom,
|
DateFrom: resp.DateFrom,
|
||||||
DateTo: resp.DateTo,
|
DateTo: resp.DateTo,
|
||||||
GroupBy: resp.GroupBy,
|
GroupBy: resp.GroupBy,
|
||||||
@ -538,6 +557,15 @@ func ProfitLossAnalyticsModelToContract(resp *models.ProfitLossAnalyticsResponse
|
|||||||
Data: data,
|
Data: data,
|
||||||
ProductData: productData,
|
ProductData: productData,
|
||||||
MainSummary: mainSummary,
|
MainSummary: mainSummary,
|
||||||
|
Purchasing: contract.ProfitLossPurchasing{
|
||||||
|
TodayTotal: resp.Purchasing.TodayTotal,
|
||||||
|
MtdTotal: resp.Purchasing.MtdTotal,
|
||||||
|
TodayRawMaterial: resp.Purchasing.TodayRawMaterial,
|
||||||
|
MtdRawMaterial: resp.Purchasing.MtdRawMaterial,
|
||||||
|
TodayExpense: resp.Purchasing.TodayExpense,
|
||||||
|
MtdExpense: resp.Purchasing.MtdExpense,
|
||||||
|
Items: purchasingItems,
|
||||||
|
},
|
||||||
OperationalExpenses: opsItems,
|
OperationalExpenses: opsItems,
|
||||||
OperationalExpensesTotal: resp.OperationalExpensesTotal,
|
OperationalExpensesTotal: resp.OperationalExpensesTotal,
|
||||||
}
|
}
|
||||||
@ -664,6 +692,7 @@ 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,
|
||||||
@ -726,6 +755,7 @@ 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,
|
||||||
|
|||||||
@ -144,6 +144,8 @@ func isValidUserRole(role string) bool {
|
|||||||
string(constants.RoleManager): true,
|
string(constants.RoleManager): true,
|
||||||
string(constants.RoleCashier): true,
|
string(constants.RoleCashier): true,
|
||||||
string(constants.RoleWaiter): true,
|
string(constants.RoleWaiter): true,
|
||||||
|
string(constants.RoleOwner): true,
|
||||||
|
string(constants.RolePurchasing): true,
|
||||||
}
|
}
|
||||||
return validRoles[role]
|
return validRoles[role]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,7 +7,7 @@ ON purchase_orders(outlet_id);
|
|||||||
WITH movement_outlets AS (
|
WITH movement_outlets AS (
|
||||||
SELECT
|
SELECT
|
||||||
poi.purchase_order_id,
|
poi.purchase_order_id,
|
||||||
MIN(im.outlet_id) AS outlet_id
|
MIN(im.outlet_id::text)::uuid AS outlet_id
|
||||||
FROM inventory_movements im
|
FROM inventory_movements im
|
||||||
JOIN purchase_order_items poi ON im.purchase_order_item_id = poi.id
|
JOIN purchase_order_items poi ON im.purchase_order_item_id = poi.id
|
||||||
WHERE im.outlet_id IS NOT NULL
|
WHERE im.outlet_id IS NOT NULL
|
||||||
@ -40,7 +40,7 @@ WITH candidate_item_outlets AS (
|
|||||||
), item_outlets AS (
|
), item_outlets AS (
|
||||||
SELECT
|
SELECT
|
||||||
purchase_order_id,
|
purchase_order_id,
|
||||||
MIN(outlet_id) AS outlet_id
|
MIN(outlet_id::text)::uuid AS outlet_id
|
||||||
FROM candidate_item_outlets
|
FROM candidate_item_outlets
|
||||||
GROUP BY purchase_order_id
|
GROUP BY purchase_order_id
|
||||||
HAVING COUNT(DISTINCT outlet_id) = 1
|
HAVING COUNT(DISTINCT outlet_id) = 1
|
||||||
@ -54,7 +54,7 @@ WHERE po.id = item_outlets.purchase_order_id
|
|||||||
WITH single_outlet_organizations AS (
|
WITH single_outlet_organizations AS (
|
||||||
SELECT
|
SELECT
|
||||||
organization_id,
|
organization_id,
|
||||||
MIN(id) AS outlet_id
|
MIN(id::text)::uuid AS outlet_id
|
||||||
FROM outlets
|
FROM outlets
|
||||||
GROUP BY organization_id
|
GROUP BY organization_id
|
||||||
HAVING COUNT(*) = 1
|
HAVING COUNT(*) = 1
|
||||||
|
|||||||
4
migrations/000083_add_owner_purchasing_roles.down.sql
Normal file
4
migrations/000083_add_owner_purchasing_roles.down.sql
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
-- Revert to original roles
|
||||||
|
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_role_check;
|
||||||
|
UPDATE users SET role = 'admin' WHERE role NOT IN ('admin', 'manager', 'cashier', 'waiter');
|
||||||
|
ALTER TABLE users ADD CONSTRAINT users_role_check CHECK (role IN ('admin', 'manager', 'cashier', 'waiter'));
|
||||||
4
migrations/000083_add_owner_purchasing_roles.up.sql
Normal file
4
migrations/000083_add_owner_purchasing_roles.up.sql
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
-- Add 'owner' and 'purchasing' roles to users table
|
||||||
|
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_role_check;
|
||||||
|
UPDATE users SET role = 'admin' WHERE role NOT IN ('admin', 'manager', 'cashier', 'waiter', 'owner', 'purchasing');
|
||||||
|
ALTER TABLE users ADD CONSTRAINT users_role_check CHECK (role IN ('admin', 'manager', 'cashier', 'waiter', 'owner', 'purchasing'));
|
||||||
394
templates/profit_loss_report.html
Normal file
394
templates/profit_loss_report.html
Normal file
@ -0,0 +1,394 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="id">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Laporan Penjualan Harian</title>
|
||||||
|
<style>
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 10mm 12mm;
|
||||||
|
}
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #2d3748;
|
||||||
|
line-height: 1.4;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.report-container {
|
||||||
|
max-width: 210mm;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.report-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.header-logo {
|
||||||
|
display: inline-block;
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
background: #dc2626;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
.header-logo svg {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
.header-title {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #1a1a1a;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.header-org {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #dc2626;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.header-period {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #666;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Summary Boxes */
|
||||||
|
.summary-boxes {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border: 2px solid #1a1a1a;
|
||||||
|
}
|
||||||
|
.summary-box {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid #1a1a1a;
|
||||||
|
}
|
||||||
|
.summary-box:nth-child(odd) {
|
||||||
|
border-right: 1px solid #1a1a1a;
|
||||||
|
}
|
||||||
|
.summary-box:nth-child(n + 3) {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.summary-box.highlight-green {
|
||||||
|
background: #dcfce7;
|
||||||
|
}
|
||||||
|
.summary-box.highlight-red {
|
||||||
|
background: #fee2e2;
|
||||||
|
}
|
||||||
|
.summary-box-label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1a1a1a;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.summary-box-value {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #1a1a1a;
|
||||||
|
font-family: "Courier New", monospace;
|
||||||
|
}
|
||||||
|
.summary-box-value.negative {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
.summary-box-value.positive {
|
||||||
|
color: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section Title */
|
||||||
|
.section-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #dc2626;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main Summary Table */
|
||||||
|
.summary-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.summary-table thead th {
|
||||||
|
background: #dc2626;
|
||||||
|
color: #ffffff;
|
||||||
|
padding: 8px 10px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
border: 1px solid #dc2626;
|
||||||
|
}
|
||||||
|
.summary-table thead th:first-child {
|
||||||
|
width: 30px;
|
||||||
|
}
|
||||||
|
.summary-table thead th:nth-child(2) {
|
||||||
|
text-align: left;
|
||||||
|
width: 35%;
|
||||||
|
}
|
||||||
|
.summary-table tbody td {
|
||||||
|
padding: 7px 10px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.summary-table tbody td:first-child {
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.summary-table tbody td.nominal {
|
||||||
|
text-align: right;
|
||||||
|
font-family: "Courier New", monospace;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.summary-table tbody td.pct {
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.summary-table tbody tr.bold-row td {
|
||||||
|
font-weight: 800;
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
.summary-table tbody tr.highlight-row td {
|
||||||
|
font-weight: 800;
|
||||||
|
background: #fef3c7;
|
||||||
|
}
|
||||||
|
.summary-table tbody tr.highlight-green-row td {
|
||||||
|
font-weight: 800;
|
||||||
|
background: #dcfce7;
|
||||||
|
}
|
||||||
|
.summary-table tbody tr.sub-item td {
|
||||||
|
padding-left: 30px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rincian Biaya Table */
|
||||||
|
.detail-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.detail-table thead th {
|
||||||
|
background: #dc2626;
|
||||||
|
color: #ffffff;
|
||||||
|
padding: 8px 10px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
border: 1px solid #dc2626;
|
||||||
|
}
|
||||||
|
.detail-table thead th:first-child {
|
||||||
|
width: 30px;
|
||||||
|
}
|
||||||
|
.detail-table thead th:nth-child(2) {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.detail-table thead th:last-child {
|
||||||
|
text-align: right;
|
||||||
|
width: 25%;
|
||||||
|
}
|
||||||
|
.detail-table tbody td {
|
||||||
|
padding: 7px 10px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.detail-table tbody td:first-child {
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.detail-table tbody td:last-child {
|
||||||
|
text-align: right;
|
||||||
|
font-family: "Courier New", monospace;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.detail-table tbody tr.total-row td {
|
||||||
|
font-weight: 800;
|
||||||
|
background: #fef3c7;
|
||||||
|
border-top: 2px solid #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.report-footer {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
font-size: 9px;
|
||||||
|
color: #9ca3af;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="report-container">
|
||||||
|
<!-- HEADER -->
|
||||||
|
<div class="report-header">
|
||||||
|
<div class="header-logo">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 500 500"
|
||||||
|
width="40"
|
||||||
|
height="40"
|
||||||
|
>
|
||||||
|
<rect fill="#dc2626" x="0" y="0" width="500" height="500" rx="60" />
|
||||||
|
<g transform="translate(250,250) scale(3.2)">
|
||||||
|
<rect
|
||||||
|
x="-40"
|
||||||
|
y="-50"
|
||||||
|
width="80"
|
||||||
|
height="10"
|
||||||
|
rx="3"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x="-30"
|
||||||
|
y="-35"
|
||||||
|
width="60"
|
||||||
|
height="60"
|
||||||
|
rx="5"
|
||||||
|
fill="none"
|
||||||
|
stroke="white"
|
||||||
|
stroke-width="5"
|
||||||
|
/>
|
||||||
|
<circle cx="0" cy="-55" r="8" fill="white" />
|
||||||
|
<line
|
||||||
|
x1="-10"
|
||||||
|
y1="-60"
|
||||||
|
x2="10"
|
||||||
|
y2="-50"
|
||||||
|
stroke="white"
|
||||||
|
stroke-width="3"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1="10"
|
||||||
|
y1="-60"
|
||||||
|
x2="-10"
|
||||||
|
y2="-50"
|
||||||
|
stroke="white"
|
||||||
|
stroke-width="3"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="header-title">LAPORAN PENJUALAN HARIAN</div>
|
||||||
|
<div class="header-org">{{.OrganizationName}}</div>
|
||||||
|
<div class="header-period">
|
||||||
|
Bulan: {{.MonthName}} / Tanggal Report: {{.ReportDate}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SUMMARY BOXES -->
|
||||||
|
<div class="summary-boxes">
|
||||||
|
<div class="summary-box">
|
||||||
|
<span class="summary-box-label"
|
||||||
|
>TOTAL PENJUALAN {{.ReportDateUpper}}</span
|
||||||
|
>
|
||||||
|
<span class="summary-box-value">{{.TotalPenjualan}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-box">
|
||||||
|
<span class="summary-box-label"
|
||||||
|
>TOTAL BIAYA {{.ReportDateUpper}}</span
|
||||||
|
>
|
||||||
|
<span class="summary-box-value">{{.TotalBiaya}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-box {{.LabaRugiClass}}">
|
||||||
|
<span class="summary-box-label">LABA/RUGI {{.ReportDateUpper}}</span>
|
||||||
|
<span class="summary-box-value {{.LabaRugiValueClass}}"
|
||||||
|
>{{.LabaRugi}}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="summary-box {{.LabaRugiMtdClass}}">
|
||||||
|
<span class="summary-box-label">LABA/RUGI MTD</span>
|
||||||
|
<span class="summary-box-value {{.LabaRugiMtdValueClass}}"
|
||||||
|
>{{.LabaRugiMtd}}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 1. RINGKASAN LAPORAN -->
|
||||||
|
<div class="section-title">1. Ringkasan Laporan</div>
|
||||||
|
<table class="summary-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>NO</th>
|
||||||
|
<th>KETERANGAN</th>
|
||||||
|
<th>TANGGAL REPORT<br />Nominal</th>
|
||||||
|
<th>%</th>
|
||||||
|
<th>MTD<br />Nominal</th>
|
||||||
|
<th>%</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range $i, $row := .MainSummary}}
|
||||||
|
<tr class="{{$row.RowClass}}">
|
||||||
|
<td>{{if $row.Number}}{{$row.Number}}{{end}}</td>
|
||||||
|
<td>{{$row.Label}}</td>
|
||||||
|
<td class="nominal">{{$row.TodayNominal}}</td>
|
||||||
|
<td class="pct">{{$row.TodayPct}}</td>
|
||||||
|
<td class="nominal">{{$row.MtdNominal}}</td>
|
||||||
|
<td class="pct">{{$row.MtdPct}}</td>
|
||||||
|
</tr>
|
||||||
|
{{range $j, $sub := $row.SubItems}}
|
||||||
|
<tr class="sub-item">
|
||||||
|
<td></td>
|
||||||
|
<td>{{$sub.Label}}</td>
|
||||||
|
<td class="nominal">{{$sub.TodayNominal}}</td>
|
||||||
|
<td class="pct">{{$sub.TodayPct}}</td>
|
||||||
|
<td class="nominal">{{$sub.MtdNominal}}</td>
|
||||||
|
<td class="pct">{{$sub.MtdPct}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}} {{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- 2. RINCIAN BIAYA / CATATAN -->
|
||||||
|
<div class="section-title">2. Rincian Biaya / Catatan</div>
|
||||||
|
<table class="detail-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>NO</th>
|
||||||
|
<th>KETERANGAN</th>
|
||||||
|
<th>JUMLAH</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range $i, $item := .PurchasingItems}}
|
||||||
|
<tr>
|
||||||
|
<td>{{add $i 1}}</td>
|
||||||
|
<td>{{$item.Name}}</td>
|
||||||
|
<td>{{$item.Amount}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
<tr class="total-row">
|
||||||
|
<td></td>
|
||||||
|
<td>TOTAL</td>
|
||||||
|
<td>{{.PurchasingTotal}}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- FOOTER -->
|
||||||
|
<div class="report-footer">
|
||||||
|
Dicetak oleh: {{.GeneratedBy}} | {{.PrintTime}} | Powered by APSKEL
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
x
Reference in New Issue
Block a user