Add exclusive-summary
This commit is contained in:
parent
7a7ac25dcf
commit
4b6cbb69c1
@ -324,3 +324,114 @@ type OperationalExpenseItem struct {
|
|||||||
Item string `json:"item"`
|
Item string `json:"item"`
|
||||||
Nominal float64 `json:"nominal"`
|
Nominal float64 `json:"nominal"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryPeriodRequest struct {
|
||||||
|
OrganizationID uuid.UUID
|
||||||
|
OutletID *string `form:"outlet_id,omitempty"`
|
||||||
|
DateFrom string `form:"date_from" validate:"required"`
|
||||||
|
DateTo string `form:"date_to" validate:"required"`
|
||||||
|
ExcludeGajiStaffFromReimburse bool `form:"exclude_gaji_staff_from_reimburse"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryMonthlyRequest struct {
|
||||||
|
OrganizationID uuid.UUID
|
||||||
|
OutletID *string `form:"outlet_id,omitempty"`
|
||||||
|
Month string `form:"month" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryPeriodResponse struct {
|
||||||
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
|
Period ExclusiveSummaryPeriodRange `json:"period"`
|
||||||
|
Summary ExclusiveSummaryPeriodSummary `json:"summary"`
|
||||||
|
Reimburse ExclusiveSummaryReimburse `json:"reimburse"`
|
||||||
|
HPPBreakdown []ExclusiveSummaryCategoryBreakdown `json:"hpp_breakdown"`
|
||||||
|
OperationalExpenseBreakdown []ExclusiveSummaryCategoryBreakdown `json:"operational_expense_breakdown"`
|
||||||
|
DailySummary []ExclusiveSummaryDailySummary `json:"daily_summary"`
|
||||||
|
DailyTransactions []ExclusiveSummaryDailyTransaction `json:"daily_transactions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryPeriodRange struct {
|
||||||
|
DateFrom time.Time `json:"date_from"`
|
||||||
|
DateTo time.Time `json:"date_to"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryPeriodSummary struct {
|
||||||
|
Sales float64 `json:"sales"`
|
||||||
|
HPP float64 `json:"hpp"`
|
||||||
|
GrossProfit float64 `json:"gross_profit"`
|
||||||
|
SalaryTotal float64 `json:"salary_total"`
|
||||||
|
SalaryDW float64 `json:"salary_dw"`
|
||||||
|
SalaryStaff float64 `json:"salary_staff"`
|
||||||
|
SalaryOther float64 `json:"salary_other"`
|
||||||
|
OtherOperationalExpenses float64 `json:"other_operational_expenses"`
|
||||||
|
OperationalExpensesTotal float64 `json:"operational_expenses_total"`
|
||||||
|
TotalCost float64 `json:"total_cost"`
|
||||||
|
NetProfit float64 `json:"net_profit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryReimburse struct {
|
||||||
|
TotalCost float64 `json:"total_cost"`
|
||||||
|
ExcludedSalaryStaff float64 `json:"excluded_salary_staff"`
|
||||||
|
TotalReimburse float64 `json:"total_reimburse"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryCategoryBreakdown struct {
|
||||||
|
CategoryCode string `json:"category_code"`
|
||||||
|
CategoryName string `json:"category_name"`
|
||||||
|
Amount float64 `json:"amount"`
|
||||||
|
Percentage float64 `json:"percentage"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryDailySummary struct {
|
||||||
|
Date time.Time `json:"date"`
|
||||||
|
TransactionCount int64 `json:"transaction_count"`
|
||||||
|
TotalCost float64 `json:"total_cost"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryDailyTransaction struct {
|
||||||
|
Date time.Time `json:"date"`
|
||||||
|
CategoryCode string `json:"category_code"`
|
||||||
|
CategoryName string `json:"category_name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Amount float64 `json:"amount"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryMonthlyResponse struct {
|
||||||
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
|
Month string `json:"month"`
|
||||||
|
Summary ExclusiveSummaryMonthlySummary `json:"summary"`
|
||||||
|
Periods []ExclusiveSummaryMonthlyPeriod `json:"periods"`
|
||||||
|
BankBalance []ExclusiveSummaryBankBalance `json:"bank_balance"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryMonthlySummary struct {
|
||||||
|
TotalSales float64 `json:"total_sales"`
|
||||||
|
HPP float64 `json:"hpp"`
|
||||||
|
GrossProfit float64 `json:"gross_profit"`
|
||||||
|
OperationalExpensesTotal float64 `json:"operational_expenses_total"`
|
||||||
|
TotalCost float64 `json:"total_cost"`
|
||||||
|
NetProfit float64 `json:"net_profit"`
|
||||||
|
NetProfitMargin float64 `json:"net_profit_margin"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryMonthlyPeriod struct {
|
||||||
|
Label string `json:"label"`
|
||||||
|
DateFrom time.Time `json:"date_from"`
|
||||||
|
DateTo time.Time `json:"date_to"`
|
||||||
|
Sales float64 `json:"sales"`
|
||||||
|
HPP float64 `json:"hpp"`
|
||||||
|
GrossProfit float64 `json:"gross_profit"`
|
||||||
|
GrossMargin float64 `json:"gross_margin"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryBankBalance struct {
|
||||||
|
Bank string `json:"bank"`
|
||||||
|
OpeningBalance *float64 `json:"opening_balance"`
|
||||||
|
IncomingMutation *float64 `json:"incoming_mutation"`
|
||||||
|
OutgoingMutation *float64 `json:"outgoing_mutation"`
|
||||||
|
ClosingBalance *float64 `json:"closing_balance"`
|
||||||
|
Notes *string `json:"notes"`
|
||||||
|
}
|
||||||
|
|||||||
@ -186,3 +186,33 @@ type OperationalExpenseItem struct {
|
|||||||
Item string
|
Item string
|
||||||
Amount float64
|
Amount float64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryAnalytics struct {
|
||||||
|
SalesTotal float64
|
||||||
|
SalesCount int64
|
||||||
|
HPPBreakdown []ExclusiveSummaryCategoryTotal
|
||||||
|
OperationalExpenseBreakdown []ExclusiveSummaryCategoryTotal
|
||||||
|
DailySummary []ExclusiveSummaryDailySummary
|
||||||
|
DailyTransactions []ExclusiveSummaryDailyTransaction
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryCategoryTotal struct {
|
||||||
|
CategoryCode string
|
||||||
|
CategoryName string
|
||||||
|
Amount float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryDailySummary struct {
|
||||||
|
Date time.Time
|
||||||
|
TransactionCount int64
|
||||||
|
TotalCost float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryDailyTransaction struct {
|
||||||
|
Date time.Time
|
||||||
|
CategoryCode string
|
||||||
|
CategoryName string
|
||||||
|
Description string
|
||||||
|
Amount float64
|
||||||
|
Source string
|
||||||
|
}
|
||||||
|
|||||||
@ -210,3 +210,59 @@ func (h *AnalyticsHandler) GetProfitLossAnalytics(c *gin.Context) {
|
|||||||
contractResp := transformer.ProfitLossAnalyticsModelToContract(response)
|
contractResp := transformer.ProfitLossAnalyticsModelToContract(response)
|
||||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetProfitLossAnalytics")
|
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetProfitLossAnalytics")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *AnalyticsHandler) GetExclusiveSummaryPeriod(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
contextInfo := appcontext.FromGinContext(ctx)
|
||||||
|
|
||||||
|
var req contract.ExclusiveSummaryPeriodRequest
|
||||||
|
if err := c.ShouldBindQuery(&req); err != nil {
|
||||||
|
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetExclusiveSummaryPeriod", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryPeriod")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req.OrganizationID = contextInfo.OrganizationID
|
||||||
|
req.OutletID = h.resolveOutletID(c, contextInfo.OutletID)
|
||||||
|
modelReq, err := transformer.ExclusiveSummaryPeriodContractToModel(&req)
|
||||||
|
if err != nil {
|
||||||
|
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetExclusiveSummaryPeriod", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryPeriod")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := h.analyticsService.GetExclusiveSummaryPeriod(ctx, modelReq)
|
||||||
|
if err != nil {
|
||||||
|
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "AnalyticsHandler::GetExclusiveSummaryPeriod", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryPeriod")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
contractResp := transformer.ExclusiveSummaryPeriodModelToContract(response)
|
||||||
|
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetExclusiveSummaryPeriod")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AnalyticsHandler) GetExclusiveSummaryMonthly(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
contextInfo := appcontext.FromGinContext(ctx)
|
||||||
|
|
||||||
|
var req contract.ExclusiveSummaryMonthlyRequest
|
||||||
|
if err := c.ShouldBindQuery(&req); err != nil {
|
||||||
|
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetExclusiveSummaryMonthly", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryMonthly")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req.OrganizationID = contextInfo.OrganizationID
|
||||||
|
req.OutletID = h.resolveOutletID(c, contextInfo.OutletID)
|
||||||
|
modelReq, err := transformer.ExclusiveSummaryMonthlyContractToModel(&req)
|
||||||
|
if err != nil {
|
||||||
|
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetExclusiveSummaryMonthly", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryMonthly")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := h.analyticsService.GetExclusiveSummaryMonthly(ctx, modelReq)
|
||||||
|
if err != nil {
|
||||||
|
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "AnalyticsHandler::GetExclusiveSummaryMonthly", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryMonthly")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
contractResp := transformer.ExclusiveSummaryMonthlyModelToContract(response)
|
||||||
|
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetExclusiveSummaryMonthly")
|
||||||
|
}
|
||||||
|
|||||||
@ -334,3 +334,114 @@ type OperationalExpenseItem struct {
|
|||||||
Item string `json:"item"`
|
Item string `json:"item"`
|
||||||
Nominal float64 `json:"nominal"`
|
Nominal float64 `json:"nominal"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryPeriodRequest struct {
|
||||||
|
OrganizationID uuid.UUID `validate:"required"`
|
||||||
|
OutletID *uuid.UUID `validate:"omitempty"`
|
||||||
|
DateFrom time.Time `validate:"required"`
|
||||||
|
DateTo time.Time `validate:"required"`
|
||||||
|
ExcludeGajiStaffFromReimburse bool `validate:"omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryMonthlyRequest struct {
|
||||||
|
OrganizationID uuid.UUID `validate:"required"`
|
||||||
|
OutletID *uuid.UUID `validate:"omitempty"`
|
||||||
|
Month time.Time `validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryPeriodResponse struct {
|
||||||
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
|
Period ExclusiveSummaryPeriodRange `json:"period"`
|
||||||
|
Summary ExclusiveSummaryPeriodSummary `json:"summary"`
|
||||||
|
Reimburse ExclusiveSummaryReimburse `json:"reimburse"`
|
||||||
|
HPPBreakdown []ExclusiveSummaryCategoryBreakdown `json:"hpp_breakdown"`
|
||||||
|
OperationalExpenseBreakdown []ExclusiveSummaryCategoryBreakdown `json:"operational_expense_breakdown"`
|
||||||
|
DailySummary []ExclusiveSummaryDailySummary `json:"daily_summary"`
|
||||||
|
DailyTransactions []ExclusiveSummaryDailyTransaction `json:"daily_transactions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryPeriodRange struct {
|
||||||
|
DateFrom time.Time `json:"date_from"`
|
||||||
|
DateTo time.Time `json:"date_to"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryPeriodSummary struct {
|
||||||
|
Sales float64 `json:"sales"`
|
||||||
|
HPP float64 `json:"hpp"`
|
||||||
|
GrossProfit float64 `json:"gross_profit"`
|
||||||
|
SalaryTotal float64 `json:"salary_total"`
|
||||||
|
SalaryDW float64 `json:"salary_dw"`
|
||||||
|
SalaryStaff float64 `json:"salary_staff"`
|
||||||
|
SalaryOther float64 `json:"salary_other"`
|
||||||
|
OtherOperationalExpenses float64 `json:"other_operational_expenses"`
|
||||||
|
OperationalExpensesTotal float64 `json:"operational_expenses_total"`
|
||||||
|
TotalCost float64 `json:"total_cost"`
|
||||||
|
NetProfit float64 `json:"net_profit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryReimburse struct {
|
||||||
|
TotalCost float64 `json:"total_cost"`
|
||||||
|
ExcludedSalaryStaff float64 `json:"excluded_salary_staff"`
|
||||||
|
TotalReimburse float64 `json:"total_reimburse"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryCategoryBreakdown struct {
|
||||||
|
CategoryCode string `json:"category_code"`
|
||||||
|
CategoryName string `json:"category_name"`
|
||||||
|
Amount float64 `json:"amount"`
|
||||||
|
Percentage float64 `json:"percentage"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryDailySummary struct {
|
||||||
|
Date time.Time `json:"date"`
|
||||||
|
TransactionCount int64 `json:"transaction_count"`
|
||||||
|
TotalCost float64 `json:"total_cost"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryDailyTransaction struct {
|
||||||
|
Date time.Time `json:"date"`
|
||||||
|
CategoryCode string `json:"category_code"`
|
||||||
|
CategoryName string `json:"category_name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Amount float64 `json:"amount"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryMonthlyResponse struct {
|
||||||
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
|
Month string `json:"month"`
|
||||||
|
Summary ExclusiveSummaryMonthlySummary `json:"summary"`
|
||||||
|
Periods []ExclusiveSummaryMonthlyPeriod `json:"periods"`
|
||||||
|
BankBalance []ExclusiveSummaryBankBalance `json:"bank_balance"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryMonthlySummary struct {
|
||||||
|
TotalSales float64 `json:"total_sales"`
|
||||||
|
HPP float64 `json:"hpp"`
|
||||||
|
GrossProfit float64 `json:"gross_profit"`
|
||||||
|
OperationalExpensesTotal float64 `json:"operational_expenses_total"`
|
||||||
|
TotalCost float64 `json:"total_cost"`
|
||||||
|
NetProfit float64 `json:"net_profit"`
|
||||||
|
NetProfitMargin float64 `json:"net_profit_margin"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryMonthlyPeriod struct {
|
||||||
|
Label string `json:"label"`
|
||||||
|
DateFrom time.Time `json:"date_from"`
|
||||||
|
DateTo time.Time `json:"date_to"`
|
||||||
|
Sales float64 `json:"sales"`
|
||||||
|
HPP float64 `json:"hpp"`
|
||||||
|
GrossProfit float64 `json:"gross_profit"`
|
||||||
|
GrossMargin float64 `json:"gross_margin"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryBankBalance struct {
|
||||||
|
Bank string `json:"bank"`
|
||||||
|
OpeningBalance *float64 `json:"opening_balance"`
|
||||||
|
IncomingMutation *float64 `json:"incoming_mutation"`
|
||||||
|
OutgoingMutation *float64 `json:"outgoing_mutation"`
|
||||||
|
ClosingBalance *float64 `json:"closing_balance"`
|
||||||
|
Notes *string `json:"notes"`
|
||||||
|
}
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"apskel-pos-be/internal/entities"
|
||||||
"apskel-pos-be/internal/models"
|
"apskel-pos-be/internal/models"
|
||||||
"apskel-pos-be/internal/repository"
|
"apskel-pos-be/internal/repository"
|
||||||
)
|
)
|
||||||
@ -18,6 +19,8 @@ type AnalyticsProcessor interface {
|
|||||||
GetProductAnalyticsPerCategory(ctx context.Context, req *models.ProductAnalyticsPerCategoryRequest) (*models.ProductAnalyticsPerCategoryResponse, error)
|
GetProductAnalyticsPerCategory(ctx context.Context, req *models.ProductAnalyticsPerCategoryRequest) (*models.ProductAnalyticsPerCategoryResponse, error)
|
||||||
GetDashboardAnalytics(ctx context.Context, req *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error)
|
GetDashboardAnalytics(ctx context.Context, req *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error)
|
||||||
GetProfitLossAnalytics(ctx context.Context, req *models.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsResponse, error)
|
GetProfitLossAnalytics(ctx context.Context, req *models.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsResponse, error)
|
||||||
|
GetExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error)
|
||||||
|
GetExclusiveSummaryMonthly(ctx context.Context, req *models.ExclusiveSummaryMonthlyRequest) (*models.ExclusiveSummaryMonthlyResponse, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type AnalyticsProcessorImpl struct {
|
type AnalyticsProcessorImpl struct {
|
||||||
@ -651,3 +654,252 @@ func slugify(s string) string {
|
|||||||
}
|
}
|
||||||
return string(result)
|
return string(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *AnalyticsProcessorImpl) GetExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error) {
|
||||||
|
if req.DateFrom.After(req.DateTo) {
|
||||||
|
return nil, fmt.Errorf("date_from cannot be after date_to")
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.buildExclusiveSummaryPeriod(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AnalyticsProcessorImpl) GetExclusiveSummaryMonthly(ctx context.Context, req *models.ExclusiveSummaryMonthlyRequest) (*models.ExclusiveSummaryMonthlyResponse, error) {
|
||||||
|
monthStart := time.Date(req.Month.Year(), req.Month.Month(), 1, 0, 0, 0, 0, req.Month.Location())
|
||||||
|
monthEnd := monthStart.AddDate(0, 1, 0).Add(-time.Nanosecond)
|
||||||
|
|
||||||
|
fullPeriod, err := p.buildExclusiveSummaryPeriod(ctx, &models.ExclusiveSummaryPeriodRequest{
|
||||||
|
OrganizationID: req.OrganizationID,
|
||||||
|
OutletID: req.OutletID,
|
||||||
|
DateFrom: monthStart,
|
||||||
|
DateTo: monthEnd,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
periods := make([]models.ExclusiveSummaryMonthlyPeriod, 0)
|
||||||
|
for _, bucket := range buildExclusiveSummaryMonthlyBuckets(monthStart) {
|
||||||
|
period, err := p.buildExclusiveSummaryPeriod(ctx, &models.ExclusiveSummaryPeriodRequest{
|
||||||
|
OrganizationID: req.OrganizationID,
|
||||||
|
OutletID: req.OutletID,
|
||||||
|
DateFrom: bucket.DateFrom,
|
||||||
|
DateTo: bucket.DateTo,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
periods = append(periods, models.ExclusiveSummaryMonthlyPeriod{
|
||||||
|
Label: bucket.Label,
|
||||||
|
DateFrom: bucket.DateFrom,
|
||||||
|
DateTo: bucket.DateTo,
|
||||||
|
Sales: period.Summary.Sales,
|
||||||
|
HPP: period.Summary.HPP,
|
||||||
|
GrossProfit: period.Summary.GrossProfit,
|
||||||
|
GrossMargin: percentage(period.Summary.GrossProfit, period.Summary.Sales),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return &models.ExclusiveSummaryMonthlyResponse{
|
||||||
|
OrganizationID: req.OrganizationID,
|
||||||
|
OutletID: req.OutletID,
|
||||||
|
Month: monthStart.Format("2006-01"),
|
||||||
|
Summary: models.ExclusiveSummaryMonthlySummary{
|
||||||
|
TotalSales: fullPeriod.Summary.Sales,
|
||||||
|
HPP: fullPeriod.Summary.HPP,
|
||||||
|
GrossProfit: fullPeriod.Summary.GrossProfit,
|
||||||
|
OperationalExpensesTotal: fullPeriod.Summary.OperationalExpensesTotal,
|
||||||
|
TotalCost: fullPeriod.Summary.TotalCost,
|
||||||
|
NetProfit: fullPeriod.Summary.NetProfit,
|
||||||
|
NetProfitMargin: percentage(fullPeriod.Summary.NetProfit, fullPeriod.Summary.Sales),
|
||||||
|
},
|
||||||
|
Periods: periods,
|
||||||
|
BankBalance: []models.ExclusiveSummaryBankBalance{
|
||||||
|
{Bank: "BCA"},
|
||||||
|
{Bank: "BRI"},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AnalyticsProcessorImpl) buildExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error) {
|
||||||
|
result, err := p.analyticsRepo.GetExclusiveSummaryAnalytics(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get exclusive summary analytics: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hppBreakdown, hppTotal := exclusiveSummaryCategoryBreakdown(result.HPPBreakdown)
|
||||||
|
operationalBreakdown, operationalTotal := exclusiveSummaryCategoryBreakdown(result.OperationalExpenseBreakdown)
|
||||||
|
salaryDW, salaryStaff, salaryOther := exclusiveSummarySalaryBreakdown(result.DailyTransactions)
|
||||||
|
salaryTotal := salaryDW + salaryStaff + salaryOther
|
||||||
|
otherOperationalExpenses := operationalTotal - salaryTotal
|
||||||
|
if otherOperationalExpenses < 0 {
|
||||||
|
otherOperationalExpenses = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
grossProfit := result.SalesTotal - hppTotal
|
||||||
|
totalCost := hppTotal + operationalTotal
|
||||||
|
netProfit := result.SalesTotal - totalCost
|
||||||
|
excludedSalaryStaff := 0.0
|
||||||
|
if req.ExcludeGajiStaffFromReimburse {
|
||||||
|
excludedSalaryStaff = salaryStaff
|
||||||
|
}
|
||||||
|
|
||||||
|
dailySummary := make([]models.ExclusiveSummaryDailySummary, len(result.DailySummary))
|
||||||
|
for i, item := range result.DailySummary {
|
||||||
|
dailySummary[i] = models.ExclusiveSummaryDailySummary{
|
||||||
|
Date: item.Date,
|
||||||
|
TransactionCount: item.TransactionCount,
|
||||||
|
TotalCost: item.TotalCost,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dailyTransactions := make([]models.ExclusiveSummaryDailyTransaction, len(result.DailyTransactions))
|
||||||
|
for i, item := range result.DailyTransactions {
|
||||||
|
dailyTransactions[i] = models.ExclusiveSummaryDailyTransaction{
|
||||||
|
Date: item.Date,
|
||||||
|
CategoryCode: item.CategoryCode,
|
||||||
|
CategoryName: item.CategoryName,
|
||||||
|
Description: item.Description,
|
||||||
|
Amount: item.Amount,
|
||||||
|
Source: item.Source,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &models.ExclusiveSummaryPeriodResponse{
|
||||||
|
OrganizationID: req.OrganizationID,
|
||||||
|
OutletID: req.OutletID,
|
||||||
|
Period: models.ExclusiveSummaryPeriodRange{
|
||||||
|
DateFrom: req.DateFrom,
|
||||||
|
DateTo: req.DateTo,
|
||||||
|
},
|
||||||
|
Summary: models.ExclusiveSummaryPeriodSummary{
|
||||||
|
Sales: result.SalesTotal,
|
||||||
|
HPP: hppTotal,
|
||||||
|
GrossProfit: grossProfit,
|
||||||
|
SalaryTotal: salaryTotal,
|
||||||
|
SalaryDW: salaryDW,
|
||||||
|
SalaryStaff: salaryStaff,
|
||||||
|
SalaryOther: salaryOther,
|
||||||
|
OtherOperationalExpenses: otherOperationalExpenses,
|
||||||
|
OperationalExpensesTotal: operationalTotal,
|
||||||
|
TotalCost: totalCost,
|
||||||
|
NetProfit: netProfit,
|
||||||
|
},
|
||||||
|
Reimburse: models.ExclusiveSummaryReimburse{
|
||||||
|
TotalCost: totalCost,
|
||||||
|
ExcludedSalaryStaff: excludedSalaryStaff,
|
||||||
|
TotalReimburse: totalCost - excludedSalaryStaff,
|
||||||
|
},
|
||||||
|
HPPBreakdown: hppBreakdown,
|
||||||
|
OperationalExpenseBreakdown: operationalBreakdown,
|
||||||
|
DailySummary: dailySummary,
|
||||||
|
DailyTransactions: dailyTransactions,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func exclusiveSummaryCategoryBreakdown(items []entities.ExclusiveSummaryCategoryTotal) ([]models.ExclusiveSummaryCategoryBreakdown, float64) {
|
||||||
|
var total float64
|
||||||
|
for _, item := range items {
|
||||||
|
total += item.Amount
|
||||||
|
}
|
||||||
|
|
||||||
|
breakdown := make([]models.ExclusiveSummaryCategoryBreakdown, len(items))
|
||||||
|
for i, item := range items {
|
||||||
|
breakdown[i] = models.ExclusiveSummaryCategoryBreakdown{
|
||||||
|
CategoryCode: item.CategoryCode,
|
||||||
|
CategoryName: item.CategoryName,
|
||||||
|
Amount: item.Amount,
|
||||||
|
Percentage: percentage(item.Amount, total),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return breakdown, total
|
||||||
|
}
|
||||||
|
|
||||||
|
func exclusiveSummarySalaryBreakdown(transactions []entities.ExclusiveSummaryDailyTransaction) (float64, float64, float64) {
|
||||||
|
var salaryDW float64
|
||||||
|
var salaryStaff float64
|
||||||
|
var salaryOther float64
|
||||||
|
|
||||||
|
for _, transaction := range transactions {
|
||||||
|
if !isExclusiveSummarySalary(transaction.CategoryCode, transaction.CategoryName, transaction.Description) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
classification := strings.ToLower(transaction.CategoryCode + " " + transaction.CategoryName + " " + transaction.Description)
|
||||||
|
switch {
|
||||||
|
case strings.Contains(classification, "staff") || strings.Contains(classification, "kary") || strings.Contains(classification, "karyawan"):
|
||||||
|
salaryStaff += transaction.Amount
|
||||||
|
case strings.Contains(classification, "dw"):
|
||||||
|
salaryDW += transaction.Amount
|
||||||
|
default:
|
||||||
|
salaryOther += transaction.Amount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return salaryDW, salaryStaff, salaryOther
|
||||||
|
}
|
||||||
|
|
||||||
|
func isExclusiveSummarySalary(parts ...string) bool {
|
||||||
|
text := strings.ToLower(strings.Join(parts, " "))
|
||||||
|
return strings.Contains(text, "gaji") || strings.Contains(text, "salary")
|
||||||
|
}
|
||||||
|
|
||||||
|
func percentage(numerator, denominator float64) float64 {
|
||||||
|
if denominator == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return (numerator / denominator) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
type exclusiveSummaryMonthlyBucket struct {
|
||||||
|
Label string
|
||||||
|
DateFrom time.Time
|
||||||
|
DateTo time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildExclusiveSummaryMonthlyBuckets(monthStart time.Time) []exclusiveSummaryMonthlyBucket {
|
||||||
|
monthEnd := monthStart.AddDate(0, 1, 0).Add(-time.Nanosecond)
|
||||||
|
buckets := make([]exclusiveSummaryMonthlyBucket, 0, 6)
|
||||||
|
currentStart := monthStart
|
||||||
|
|
||||||
|
for !currentStart.After(monthEnd) {
|
||||||
|
currentEnd := currentStart
|
||||||
|
for currentEnd.Weekday() != time.Sunday && currentEnd.Day() < monthEnd.Day() {
|
||||||
|
currentEnd = currentEnd.AddDate(0, 0, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
bucketEnd := time.Date(currentEnd.Year(), currentEnd.Month(), currentEnd.Day(), 23, 59, 59, int(time.Second-time.Nanosecond), currentEnd.Location())
|
||||||
|
if bucketEnd.After(monthEnd) {
|
||||||
|
bucketEnd = monthEnd
|
||||||
|
}
|
||||||
|
|
||||||
|
buckets = append(buckets, exclusiveSummaryMonthlyBucket{
|
||||||
|
Label: fmt.Sprintf("%d - %d %s", currentStart.Day(), bucketEnd.Day(), indonesianMonthName(currentStart.Month())),
|
||||||
|
DateFrom: currentStart,
|
||||||
|
DateTo: bucketEnd,
|
||||||
|
})
|
||||||
|
|
||||||
|
currentStart = time.Date(bucketEnd.Year(), bucketEnd.Month(), bucketEnd.Day(), 0, 0, 0, 0, bucketEnd.Location()).AddDate(0, 0, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buckets
|
||||||
|
}
|
||||||
|
|
||||||
|
func indonesianMonthName(month time.Month) string {
|
||||||
|
names := map[time.Month]string{
|
||||||
|
time.January: "Januari",
|
||||||
|
time.February: "Februari",
|
||||||
|
time.March: "Maret",
|
||||||
|
time.April: "April",
|
||||||
|
time.May: "Mei",
|
||||||
|
time.June: "Juni",
|
||||||
|
time.July: "Juli",
|
||||||
|
time.August: "Agustus",
|
||||||
|
time.September: "September",
|
||||||
|
time.October: "Oktober",
|
||||||
|
time.November: "November",
|
||||||
|
time.December: "Desember",
|
||||||
|
}
|
||||||
|
return names[month]
|
||||||
|
}
|
||||||
|
|||||||
@ -15,7 +15,9 @@ import (
|
|||||||
type analyticsRepositoryStub struct {
|
type analyticsRepositoryStub struct {
|
||||||
purchasingResult *entities.PurchasingAnalytics
|
purchasingResult *entities.PurchasingAnalytics
|
||||||
profitLossResult *entities.ProfitLossAnalytics
|
profitLossResult *entities.ProfitLossAnalytics
|
||||||
|
exclusiveSummaryResults []*entities.ExclusiveSummaryAnalytics
|
||||||
profitLossGroup string
|
profitLossGroup string
|
||||||
|
exclusiveSummaryCalls int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (analyticsRepositoryStub) GetPaymentMethodAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.PaymentMethodAnalytics, error) {
|
func (analyticsRepositoryStub) GetPaymentMethodAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.PaymentMethodAnalytics, error) {
|
||||||
@ -47,6 +49,16 @@ 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, time.Time, time.Time) (*entities.ExclusiveSummaryAnalytics, error) {
|
||||||
|
if s.exclusiveSummaryCalls < len(s.exclusiveSummaryResults) {
|
||||||
|
result := s.exclusiveSummaryResults[s.exclusiveSummaryCalls]
|
||||||
|
s.exclusiveSummaryCalls++
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
s.exclusiveSummaryCalls++
|
||||||
|
return &entities.ExclusiveSummaryAnalytics{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
type expenseRepositoryStub struct{}
|
type expenseRepositoryStub struct{}
|
||||||
|
|
||||||
func (expenseRepositoryStub) Create(context.Context, *entities.Expense) error { return nil }
|
func (expenseRepositoryStub) Create(context.Context, *entities.Expense) error { return nil }
|
||||||
@ -71,7 +83,7 @@ func TestAnalyticsProcessorGetPurchasingAnalyticsPassesOutletName(t *testing.T)
|
|||||||
outletID := uuid.New()
|
outletID := uuid.New()
|
||||||
outletName := "Main Outlet"
|
outletName := "Main Outlet"
|
||||||
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
|
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
|
||||||
processor := NewAnalyticsProcessorImpl(analyticsRepositoryStub{
|
processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{
|
||||||
purchasingResult: &entities.PurchasingAnalytics{
|
purchasingResult: &entities.PurchasingAnalytics{
|
||||||
OutletName: &outletName,
|
OutletName: &outletName,
|
||||||
Summary: entities.PurchasingSummary{
|
Summary: entities.PurchasingSummary{
|
||||||
@ -124,7 +136,7 @@ func TestAnalyticsProcessorGetProfitLossAnalyticsMapsOverviewAndReportFields(t *
|
|||||||
productID := uuid.New()
|
productID := uuid.New()
|
||||||
categoryID := uuid.New()
|
categoryID := uuid.New()
|
||||||
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
|
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
|
||||||
processor := NewAnalyticsProcessorImpl(analyticsRepositoryStub{
|
processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{
|
||||||
profitLossResult: &entities.ProfitLossAnalytics{
|
profitLossResult: &entities.ProfitLossAnalytics{
|
||||||
Summary: entities.ProfitLossSummary{
|
Summary: entities.ProfitLossSummary{
|
||||||
TotalRevenue: 1000,
|
TotalRevenue: 1000,
|
||||||
@ -196,7 +208,7 @@ func TestAnalyticsProcessorGetProfitLossAnalyticsMapsOverviewAndReportFields(t *
|
|||||||
|
|
||||||
func TestAnalyticsProcessorGetProfitLossAnalyticsDynamicExpenseCategories(t *testing.T) {
|
func TestAnalyticsProcessorGetProfitLossAnalyticsDynamicExpenseCategories(t *testing.T) {
|
||||||
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
|
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
|
||||||
processor := NewAnalyticsProcessorImpl(analyticsRepositoryStub{
|
processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{
|
||||||
profitLossResult: &entities.ProfitLossAnalytics{
|
profitLossResult: &entities.ProfitLossAnalytics{
|
||||||
Summary: entities.ProfitLossSummary{
|
Summary: entities.ProfitLossSummary{
|
||||||
TotalRevenue: 10000,
|
TotalRevenue: 10000,
|
||||||
@ -273,3 +285,92 @@ func TestAnalyticsProcessorGetProfitLossAnalyticsDynamicExpenseCategories(t *tes
|
|||||||
require.Equal(t, float64(7400), result.MainSummary[6].MtdNominal)
|
require.Equal(t, float64(7400), result.MainSummary[6].MtdNominal)
|
||||||
require.True(t, result.MainSummary[6].IsBold)
|
require.True(t, result.MainSummary[6].IsBold)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAnalyticsProcessorGetExclusiveSummaryPeriodCalculatesTotalsAndReimburse(t *testing.T) {
|
||||||
|
now := time.Date(2026, 5, 26, 0, 0, 0, 0, time.UTC)
|
||||||
|
processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{
|
||||||
|
exclusiveSummaryResults: []*entities.ExclusiveSummaryAnalytics{
|
||||||
|
{
|
||||||
|
SalesTotal: 1000,
|
||||||
|
HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{
|
||||||
|
{CategoryCode: "RAW", CategoryName: "Raw", Amount: 400},
|
||||||
|
},
|
||||||
|
OperationalExpenseBreakdown: []entities.ExclusiveSummaryCategoryTotal{
|
||||||
|
{CategoryCode: "GAJI", CategoryName: "Gaji", Amount: 250},
|
||||||
|
{CategoryCode: "OPS", CategoryName: "Operasional", Amount: 100},
|
||||||
|
},
|
||||||
|
DailySummary: []entities.ExclusiveSummaryDailySummary{
|
||||||
|
{Date: now, TransactionCount: 3, TotalCost: 750},
|
||||||
|
},
|
||||||
|
DailyTransactions: []entities.ExclusiveSummaryDailyTransaction{
|
||||||
|
{Date: now, CategoryCode: "RAW", CategoryName: "Raw", Description: "beras", Amount: 400, Source: "purchase_order"},
|
||||||
|
{Date: now, CategoryCode: "GAJI", CategoryName: "Gaji", Description: "gaji karyawan", Amount: 200, Source: "purchase_order"},
|
||||||
|
{Date: now, CategoryCode: "GAJI", CategoryName: "Gaji", Description: "DW", Amount: 50, Source: "purchase_order"},
|
||||||
|
{Date: now, CategoryCode: "OPS", CategoryName: "Operasional", Description: "atk", Amount: 100, Source: "purchase_order"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, expenseRepositoryStub{})
|
||||||
|
|
||||||
|
result, err := processor.GetExclusiveSummaryPeriod(context.Background(), &models.ExclusiveSummaryPeriodRequest{
|
||||||
|
OrganizationID: uuid.New(),
|
||||||
|
DateFrom: now,
|
||||||
|
DateTo: now,
|
||||||
|
ExcludeGajiStaffFromReimburse: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, result)
|
||||||
|
require.Equal(t, float64(1000), result.Summary.Sales)
|
||||||
|
require.Equal(t, float64(400), result.Summary.HPP)
|
||||||
|
require.Equal(t, float64(600), result.Summary.GrossProfit)
|
||||||
|
require.Equal(t, float64(350), result.Summary.OperationalExpensesTotal)
|
||||||
|
require.Equal(t, float64(750), result.Summary.TotalCost)
|
||||||
|
require.Equal(t, float64(250), result.Summary.NetProfit)
|
||||||
|
require.Equal(t, float64(250), result.Summary.SalaryTotal)
|
||||||
|
require.Equal(t, float64(50), result.Summary.SalaryDW)
|
||||||
|
require.Equal(t, float64(200), result.Summary.SalaryStaff)
|
||||||
|
require.Equal(t, float64(100), result.Summary.OtherOperationalExpenses)
|
||||||
|
require.Equal(t, float64(200), result.Reimburse.ExcludedSalaryStaff)
|
||||||
|
require.Equal(t, float64(550), result.Reimburse.TotalReimburse)
|
||||||
|
require.Len(t, result.HPPBreakdown, 1)
|
||||||
|
require.Equal(t, float64(100), result.HPPBreakdown[0].Percentage)
|
||||||
|
require.Len(t, result.DailySummary, 1)
|
||||||
|
require.Len(t, result.DailyTransactions, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnalyticsProcessorGetExclusiveSummaryMonthlyBuildsSummaryAndBuckets(t *testing.T) {
|
||||||
|
location, err := time.LoadLocation("Asia/Jakarta")
|
||||||
|
require.NoError(t, err)
|
||||||
|
month := time.Date(2026, 5, 1, 0, 0, 0, 0, location)
|
||||||
|
stub := &analyticsRepositoryStub{
|
||||||
|
exclusiveSummaryResults: []*entities.ExclusiveSummaryAnalytics{
|
||||||
|
{SalesTotal: 1000, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 400}}, OperationalExpenseBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 100}}},
|
||||||
|
{SalesTotal: 100, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 40}}},
|
||||||
|
{SalesTotal: 200, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 80}}},
|
||||||
|
{SalesTotal: 300, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 120}}},
|
||||||
|
{SalesTotal: 400, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 160}}},
|
||||||
|
{SalesTotal: 500, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 200}}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
processor := NewAnalyticsProcessorImpl(stub, expenseRepositoryStub{})
|
||||||
|
|
||||||
|
result, err := processor.GetExclusiveSummaryMonthly(context.Background(), &models.ExclusiveSummaryMonthlyRequest{
|
||||||
|
OrganizationID: uuid.New(),
|
||||||
|
Month: month,
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, result)
|
||||||
|
require.Equal(t, "2026-05", result.Month)
|
||||||
|
require.Equal(t, float64(1000), result.Summary.TotalSales)
|
||||||
|
require.Equal(t, float64(400), result.Summary.HPP)
|
||||||
|
require.Equal(t, float64(500), result.Summary.NetProfit)
|
||||||
|
require.InDelta(t, float64(50), result.Summary.NetProfitMargin, 0.0001)
|
||||||
|
require.Len(t, result.Periods, 5)
|
||||||
|
require.Equal(t, "1 - 3 Mei", result.Periods[0].Label)
|
||||||
|
require.Equal(t, "25 - 31 Mei", result.Periods[4].Label)
|
||||||
|
require.Len(t, result.BankBalance, 2)
|
||||||
|
require.Equal(t, "BCA", result.BankBalance[0].Bank)
|
||||||
|
require.Equal(t, 6, stub.exclusiveSummaryCalls)
|
||||||
|
}
|
||||||
|
|||||||
@ -18,6 +18,7 @@ type AnalyticsRepository interface {
|
|||||||
GetProductAnalyticsPerCategory(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]*entities.ProductAnalyticsPerCategory, error)
|
GetProductAnalyticsPerCategory(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]*entities.ProductAnalyticsPerCategory, error)
|
||||||
GetDashboardOverview(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (*entities.DashboardOverview, error)
|
GetDashboardOverview(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (*entities.DashboardOverview, error)
|
||||||
GetProfitLossAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) (*entities.ProfitLossAnalytics, error)
|
GetProfitLossAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) (*entities.ProfitLossAnalytics, error)
|
||||||
|
GetExclusiveSummaryAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (*entities.ExclusiveSummaryAnalytics, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type AnalyticsRepositoryImpl struct {
|
type AnalyticsRepositoryImpl struct {
|
||||||
@ -686,3 +687,170 @@ func (r *AnalyticsRepositoryImpl) getOperationalExpenseItems(ctx context.Context
|
|||||||
|
|
||||||
return results, err
|
return results, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *AnalyticsRepositoryImpl) GetExclusiveSummaryAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (*entities.ExclusiveSummaryAnalytics, error) {
|
||||||
|
type salesResult struct {
|
||||||
|
SalesTotal float64
|
||||||
|
SalesCount int64
|
||||||
|
}
|
||||||
|
|
||||||
|
var sales salesResult
|
||||||
|
salesQuery := r.db.WithContext(ctx).
|
||||||
|
Table("orders o").
|
||||||
|
Select(`
|
||||||
|
COALESCE(SUM(o.total_amount), 0) as sales_total,
|
||||||
|
COUNT(o.id) as sales_count
|
||||||
|
`).
|
||||||
|
Where("o.organization_id = ?", organizationID).
|
||||||
|
Where("o.status = ?", entities.OrderStatusCompleted).
|
||||||
|
Where("o.payment_status = ?", entities.PaymentStatusCompleted).
|
||||||
|
Where("o.is_void = false AND o.is_refund = false").
|
||||||
|
Where("o.created_at >= ? AND o.created_at <= ?", dateFrom, dateTo)
|
||||||
|
salesQuery = r.resolveOutletID(salesQuery, outletID, "o.outlet_id")
|
||||||
|
if err := salesQuery.Scan(&sales).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
hppBreakdown, err := r.getExclusiveSummaryHPPBreakdown(ctx, organizationID, outletID, dateFrom, dateTo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
operationalExpenseBreakdown, err := r.getExclusiveSummaryOperationalExpenseBreakdown(ctx, organizationID, dateFrom, dateTo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dailySummary, err := r.getExclusiveSummaryDailySummary(ctx, organizationID, outletID, dateFrom, dateTo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dailyTransactions, err := r.getExclusiveSummaryDailyTransactions(ctx, organizationID, outletID, dateFrom, dateTo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &entities.ExclusiveSummaryAnalytics{
|
||||||
|
SalesTotal: sales.SalesTotal,
|
||||||
|
SalesCount: sales.SalesCount,
|
||||||
|
HPPBreakdown: hppBreakdown,
|
||||||
|
OperationalExpenseBreakdown: operationalExpenseBreakdown,
|
||||||
|
DailySummary: dailySummary,
|
||||||
|
DailyTransactions: dailyTransactions,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AnalyticsRepositoryImpl) getExclusiveSummaryHPPBreakdown(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]entities.ExclusiveSummaryCategoryTotal, error) {
|
||||||
|
var results []entities.ExclusiveSummaryCategoryTotal
|
||||||
|
|
||||||
|
query := r.db.WithContext(ctx).
|
||||||
|
Table("purchase_order_items poi").
|
||||||
|
Select(`
|
||||||
|
pc.code as category_code,
|
||||||
|
pc.name as category_name,
|
||||||
|
COALESCE(SUM(poi.amount), 0) as amount
|
||||||
|
`).
|
||||||
|
Joins("JOIN purchase_orders po ON poi.purchase_order_id = po.id").
|
||||||
|
Joins("JOIN purchase_categories pc ON poi.purchase_category_id = pc.id").
|
||||||
|
Joins("LEFT JOIN ingredients i ON poi.ingredient_id = i.id").
|
||||||
|
Joins("LEFT JOIN units u ON poi.unit_id = u.id").
|
||||||
|
Where("po.organization_id = ?", organizationID).
|
||||||
|
Where("pc.type = ?", entities.PurchaseCategoryTypeRawMaterial).
|
||||||
|
Where("po.status = ?", "received").
|
||||||
|
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo)
|
||||||
|
query = r.applyPurchaseOrderItemOutletFilter(query, outletID)
|
||||||
|
|
||||||
|
err := query.
|
||||||
|
Group("pc.id, pc.code, pc.name, pc.sort_order").
|
||||||
|
Order("pc.sort_order ASC, pc.name ASC").
|
||||||
|
Scan(&results).Error
|
||||||
|
|
||||||
|
return results, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AnalyticsRepositoryImpl) getExclusiveSummaryOperationalExpenseBreakdown(ctx context.Context, organizationID uuid.UUID, dateFrom, dateTo time.Time) ([]entities.ExclusiveSummaryCategoryTotal, error) {
|
||||||
|
var results []entities.ExclusiveSummaryCategoryTotal
|
||||||
|
|
||||||
|
err := r.db.WithContext(ctx).
|
||||||
|
Table("purchase_order_items poi").
|
||||||
|
Select(`
|
||||||
|
pc.code as category_code,
|
||||||
|
pc.name as category_name,
|
||||||
|
COALESCE(SUM(poi.amount), 0) as amount
|
||||||
|
`).
|
||||||
|
Joins("JOIN purchase_orders po ON poi.purchase_order_id = po.id").
|
||||||
|
Joins("JOIN purchase_categories pc ON poi.purchase_category_id = pc.id").
|
||||||
|
Where("po.organization_id = ?", organizationID).
|
||||||
|
Where("pc.type = ?", entities.PurchaseCategoryTypeExpense).
|
||||||
|
Where("po.status = ?", "received").
|
||||||
|
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo).
|
||||||
|
Group("pc.id, pc.code, pc.name, pc.sort_order").
|
||||||
|
Order("pc.sort_order ASC, pc.name ASC").
|
||||||
|
Scan(&results).Error
|
||||||
|
|
||||||
|
return results, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AnalyticsRepositoryImpl) getExclusiveSummaryDailySummary(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]entities.ExclusiveSummaryDailySummary, error) {
|
||||||
|
var results []entities.ExclusiveSummaryDailySummary
|
||||||
|
rawQuery, args := r.exclusiveSummaryPurchaseOrderItemQuery(organizationID, outletID, dateFrom, dateTo)
|
||||||
|
|
||||||
|
err := r.db.WithContext(ctx).Raw(`
|
||||||
|
SELECT date, COUNT(*) as transaction_count, COALESCE(SUM(amount), 0) as total_cost
|
||||||
|
FROM (`+rawQuery+`) transactions
|
||||||
|
GROUP BY date
|
||||||
|
ORDER BY date ASC
|
||||||
|
`, args...).Scan(&results).Error
|
||||||
|
|
||||||
|
return results, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AnalyticsRepositoryImpl) getExclusiveSummaryDailyTransactions(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]entities.ExclusiveSummaryDailyTransaction, error) {
|
||||||
|
var results []entities.ExclusiveSummaryDailyTransaction
|
||||||
|
rawQuery, args := r.exclusiveSummaryPurchaseOrderItemQuery(organizationID, outletID, dateFrom, dateTo)
|
||||||
|
|
||||||
|
err := r.db.WithContext(ctx).Raw(`
|
||||||
|
SELECT date, category_code, category_name, description, amount, source
|
||||||
|
FROM (`+rawQuery+`) transactions
|
||||||
|
ORDER BY date ASC, category_name ASC, description ASC
|
||||||
|
`, args...).Scan(&results).Error
|
||||||
|
|
||||||
|
return results, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AnalyticsRepositoryImpl) exclusiveSummaryPurchaseOrderItemQuery(organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (string, []interface{}) {
|
||||||
|
outletFilter := ""
|
||||||
|
args := []interface{}{
|
||||||
|
organizationID,
|
||||||
|
"received",
|
||||||
|
dateFrom,
|
||||||
|
dateTo,
|
||||||
|
}
|
||||||
|
|
||||||
|
if outletID != nil {
|
||||||
|
outletFilter = "AND (pc.type = ? OR i.outlet_id = ? OR u.outlet_id = ?)"
|
||||||
|
args = append(args, entities.PurchaseCategoryTypeExpense, *outletID, *outletID)
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `
|
||||||
|
SELECT
|
||||||
|
DATE(po.transaction_date) as date,
|
||||||
|
pc.code as category_code,
|
||||||
|
pc.name as category_name,
|
||||||
|
COALESCE(NULLIF(poi.description, ''), i.name, pc.name) as description,
|
||||||
|
poi.amount as amount,
|
||||||
|
'purchase_order' as source
|
||||||
|
FROM purchase_order_items poi
|
||||||
|
JOIN purchase_orders po ON poi.purchase_order_id = po.id
|
||||||
|
JOIN purchase_categories pc ON poi.purchase_category_id = pc.id
|
||||||
|
LEFT JOIN ingredients i ON poi.ingredient_id = i.id
|
||||||
|
LEFT JOIN units u ON poi.unit_id = u.id
|
||||||
|
WHERE po.organization_id = ?
|
||||||
|
AND po.status = ?
|
||||||
|
AND po.transaction_date >= ? AND po.transaction_date <= ?
|
||||||
|
` + outletFilter + `
|
||||||
|
`
|
||||||
|
|
||||||
|
return query, args
|
||||||
|
}
|
||||||
|
|||||||
@ -337,6 +337,8 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
|||||||
analytics.GET("/categories", r.analyticsHandler.GetProductAnalyticsPerCategory)
|
analytics.GET("/categories", r.analyticsHandler.GetProductAnalyticsPerCategory)
|
||||||
analytics.GET("/dashboard", r.analyticsHandler.GetDashboardAnalytics)
|
analytics.GET("/dashboard", r.analyticsHandler.GetDashboardAnalytics)
|
||||||
analytics.GET("/profit-loss", r.analyticsHandler.GetProfitLossAnalytics)
|
analytics.GET("/profit-loss", r.analyticsHandler.GetProfitLossAnalytics)
|
||||||
|
analytics.GET("/exclusive-summary/period", r.analyticsHandler.GetExclusiveSummaryPeriod)
|
||||||
|
analytics.GET("/exclusive-summary/monthly", r.analyticsHandler.GetExclusiveSummaryMonthly)
|
||||||
}
|
}
|
||||||
|
|
||||||
tables := protected.Group("/tables")
|
tables := protected.Group("/tables")
|
||||||
|
|||||||
@ -18,6 +18,8 @@ type AnalyticsService interface {
|
|||||||
GetProductAnalyticsPerCategory(ctx context.Context, req *models.ProductAnalyticsPerCategoryRequest) (*models.ProductAnalyticsPerCategoryResponse, error)
|
GetProductAnalyticsPerCategory(ctx context.Context, req *models.ProductAnalyticsPerCategoryRequest) (*models.ProductAnalyticsPerCategoryResponse, error)
|
||||||
GetDashboardAnalytics(ctx context.Context, req *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error)
|
GetDashboardAnalytics(ctx context.Context, req *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error)
|
||||||
GetProfitLossAnalytics(ctx context.Context, req *models.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsResponse, error)
|
GetProfitLossAnalytics(ctx context.Context, req *models.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsResponse, error)
|
||||||
|
GetExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error)
|
||||||
|
GetExclusiveSummaryMonthly(ctx context.Context, req *models.ExclusiveSummaryMonthlyRequest) (*models.ExclusiveSummaryMonthlyResponse, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type AnalyticsServiceImpl struct {
|
type AnalyticsServiceImpl struct {
|
||||||
@ -320,3 +322,69 @@ func (s *AnalyticsServiceImpl) validateProfitLossAnalyticsRequest(req *models.Pr
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *AnalyticsServiceImpl) GetExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error) {
|
||||||
|
if err := s.validateExclusiveSummaryPeriodRequest(req); err != nil {
|
||||||
|
return nil, fmt.Errorf("validation error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := s.analyticsProcessor.GetExclusiveSummaryPeriod(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get exclusive summary period: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AnalyticsServiceImpl) GetExclusiveSummaryMonthly(ctx context.Context, req *models.ExclusiveSummaryMonthlyRequest) (*models.ExclusiveSummaryMonthlyResponse, error) {
|
||||||
|
if err := s.validateExclusiveSummaryMonthlyRequest(req); err != nil {
|
||||||
|
return nil, fmt.Errorf("validation error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := s.analyticsProcessor.GetExclusiveSummaryMonthly(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get exclusive summary monthly: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AnalyticsServiceImpl) validateExclusiveSummaryPeriodRequest(req *models.ExclusiveSummaryPeriodRequest) error {
|
||||||
|
if req == nil {
|
||||||
|
return fmt.Errorf("request cannot be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.OrganizationID == uuid.Nil {
|
||||||
|
return fmt.Errorf("organization_id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.DateFrom.IsZero() {
|
||||||
|
return fmt.Errorf("date_from is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.DateTo.IsZero() {
|
||||||
|
return fmt.Errorf("date_to is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.DateFrom.After(req.DateTo) {
|
||||||
|
return fmt.Errorf("date_from cannot be after date_to")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AnalyticsServiceImpl) validateExclusiveSummaryMonthlyRequest(req *models.ExclusiveSummaryMonthlyRequest) error {
|
||||||
|
if req == nil {
|
||||||
|
return fmt.Errorf("request cannot be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.OrganizationID == uuid.Nil {
|
||||||
|
return fmt.Errorf("organization_id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Month.IsZero() {
|
||||||
|
return fmt.Errorf("month is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@ -41,6 +41,14 @@ func (analyticsProcessorStub) GetProfitLossAnalytics(context.Context, *models.Pr
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (analyticsProcessorStub) GetExclusiveSummaryPeriod(context.Context, *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error) {
|
||||||
|
return &models.ExclusiveSummaryPeriodResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (analyticsProcessorStub) GetExclusiveSummaryMonthly(context.Context, *models.ExclusiveSummaryMonthlyRequest) (*models.ExclusiveSummaryMonthlyResponse, error) {
|
||||||
|
return &models.ExclusiveSummaryMonthlyResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func 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)
|
||||||
@ -190,3 +198,59 @@ func TestAnalyticsServiceGetProfitLossAnalyticsAllowsEmptyGroupBy(t *testing.T)
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Nil(t, resp)
|
require.Nil(t, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAnalyticsServiceGetExclusiveSummaryPeriodValidation(t *testing.T) {
|
||||||
|
service := NewAnalyticsServiceImpl(analyticsProcessorStub{})
|
||||||
|
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
req *models.ExclusiveSummaryPeriodRequest
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "nil request",
|
||||||
|
req: nil,
|
||||||
|
wantErr: "request cannot be nil",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing organization",
|
||||||
|
req: &models.ExclusiveSummaryPeriodRequest{
|
||||||
|
DateFrom: now,
|
||||||
|
DateTo: now,
|
||||||
|
},
|
||||||
|
wantErr: "organization_id is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "reversed dates",
|
||||||
|
req: &models.ExclusiveSummaryPeriodRequest{
|
||||||
|
OrganizationID: uuid.New(),
|
||||||
|
DateFrom: now.AddDate(0, 0, 1),
|
||||||
|
DateTo: now,
|
||||||
|
},
|
||||||
|
wantErr: "date_from cannot be after date_to",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
resp, err := service.GetExclusiveSummaryPeriod(context.Background(), tt.req)
|
||||||
|
|
||||||
|
require.Nil(t, resp)
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), tt.wantErr)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnalyticsServiceGetExclusiveSummaryMonthlyValidation(t *testing.T) {
|
||||||
|
service := NewAnalyticsServiceImpl(analyticsProcessorStub{})
|
||||||
|
|
||||||
|
resp, err := service.GetExclusiveSummaryMonthly(context.Background(), &models.ExclusiveSummaryMonthlyRequest{
|
||||||
|
OrganizationID: uuid.New(),
|
||||||
|
})
|
||||||
|
|
||||||
|
require.Nil(t, resp)
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), "month is required")
|
||||||
|
}
|
||||||
|
|||||||
@ -559,3 +559,229 @@ func profitLossSummaryRowModelToContract(row models.ProfitLossSummaryRow) contra
|
|||||||
SubItems: subItems,
|
SubItems: subItems,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ExclusiveSummaryPeriodContractToModel(req *contract.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodRequest, error) {
|
||||||
|
if req == nil {
|
||||||
|
return nil, fmt.Errorf("request cannot be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
dateFrom, dateTo, err := parseFlexibleDateRangeToJakartaTime(req.DateFrom, req.DateTo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid date range: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if dateFrom == nil {
|
||||||
|
return nil, fmt.Errorf("date_from is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if dateTo == nil {
|
||||||
|
return nil, fmt.Errorf("date_to is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &models.ExclusiveSummaryPeriodRequest{
|
||||||
|
OrganizationID: req.OrganizationID,
|
||||||
|
OutletID: parseOutletID(req.OutletID),
|
||||||
|
DateFrom: *dateFrom,
|
||||||
|
DateTo: *dateTo,
|
||||||
|
ExcludeGajiStaffFromReimburse: req.ExcludeGajiStaffFromReimburse,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExclusiveSummaryMonthlyContractToModel(req *contract.ExclusiveSummaryMonthlyRequest) (*models.ExclusiveSummaryMonthlyRequest, error) {
|
||||||
|
if req == nil {
|
||||||
|
return nil, fmt.Errorf("request cannot be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
month, err := parseMonthToJakartaTime(req.Month)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid month: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &models.ExclusiveSummaryMonthlyRequest{
|
||||||
|
OrganizationID: req.OrganizationID,
|
||||||
|
OutletID: parseOutletID(req.OutletID),
|
||||||
|
Month: month,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExclusiveSummaryPeriodModelToContract(resp *models.ExclusiveSummaryPeriodResponse) *contract.ExclusiveSummaryPeriodResponse {
|
||||||
|
if resp == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
hppBreakdown := make([]contract.ExclusiveSummaryCategoryBreakdown, len(resp.HPPBreakdown))
|
||||||
|
for i, item := range resp.HPPBreakdown {
|
||||||
|
hppBreakdown[i] = exclusiveSummaryCategoryBreakdownModelToContract(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
operationalBreakdown := make([]contract.ExclusiveSummaryCategoryBreakdown, len(resp.OperationalExpenseBreakdown))
|
||||||
|
for i, item := range resp.OperationalExpenseBreakdown {
|
||||||
|
operationalBreakdown[i] = exclusiveSummaryCategoryBreakdownModelToContract(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
dailySummary := make([]contract.ExclusiveSummaryDailySummary, len(resp.DailySummary))
|
||||||
|
for i, item := range resp.DailySummary {
|
||||||
|
dailySummary[i] = contract.ExclusiveSummaryDailySummary{
|
||||||
|
Date: item.Date,
|
||||||
|
TransactionCount: item.TransactionCount,
|
||||||
|
TotalCost: item.TotalCost,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dailyTransactions := make([]contract.ExclusiveSummaryDailyTransaction, len(resp.DailyTransactions))
|
||||||
|
for i, item := range resp.DailyTransactions {
|
||||||
|
dailyTransactions[i] = contract.ExclusiveSummaryDailyTransaction{
|
||||||
|
Date: item.Date,
|
||||||
|
CategoryCode: item.CategoryCode,
|
||||||
|
CategoryName: item.CategoryName,
|
||||||
|
Description: item.Description,
|
||||||
|
Amount: item.Amount,
|
||||||
|
Source: item.Source,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &contract.ExclusiveSummaryPeriodResponse{
|
||||||
|
OrganizationID: resp.OrganizationID,
|
||||||
|
OutletID: resp.OutletID,
|
||||||
|
Period: contract.ExclusiveSummaryPeriodRange{
|
||||||
|
DateFrom: resp.Period.DateFrom,
|
||||||
|
DateTo: resp.Period.DateTo,
|
||||||
|
},
|
||||||
|
Summary: contract.ExclusiveSummaryPeriodSummary{
|
||||||
|
Sales: resp.Summary.Sales,
|
||||||
|
HPP: resp.Summary.HPP,
|
||||||
|
GrossProfit: resp.Summary.GrossProfit,
|
||||||
|
SalaryTotal: resp.Summary.SalaryTotal,
|
||||||
|
SalaryDW: resp.Summary.SalaryDW,
|
||||||
|
SalaryStaff: resp.Summary.SalaryStaff,
|
||||||
|
SalaryOther: resp.Summary.SalaryOther,
|
||||||
|
OtherOperationalExpenses: resp.Summary.OtherOperationalExpenses,
|
||||||
|
OperationalExpensesTotal: resp.Summary.OperationalExpensesTotal,
|
||||||
|
TotalCost: resp.Summary.TotalCost,
|
||||||
|
NetProfit: resp.Summary.NetProfit,
|
||||||
|
},
|
||||||
|
Reimburse: contract.ExclusiveSummaryReimburse{
|
||||||
|
TotalCost: resp.Reimburse.TotalCost,
|
||||||
|
ExcludedSalaryStaff: resp.Reimburse.ExcludedSalaryStaff,
|
||||||
|
TotalReimburse: resp.Reimburse.TotalReimburse,
|
||||||
|
},
|
||||||
|
HPPBreakdown: hppBreakdown,
|
||||||
|
OperationalExpenseBreakdown: operationalBreakdown,
|
||||||
|
DailySummary: dailySummary,
|
||||||
|
DailyTransactions: dailyTransactions,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExclusiveSummaryMonthlyModelToContract(resp *models.ExclusiveSummaryMonthlyResponse) *contract.ExclusiveSummaryMonthlyResponse {
|
||||||
|
if resp == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
periods := make([]contract.ExclusiveSummaryMonthlyPeriod, len(resp.Periods))
|
||||||
|
for i, item := range resp.Periods {
|
||||||
|
periods[i] = contract.ExclusiveSummaryMonthlyPeriod{
|
||||||
|
Label: item.Label,
|
||||||
|
DateFrom: item.DateFrom,
|
||||||
|
DateTo: item.DateTo,
|
||||||
|
Sales: item.Sales,
|
||||||
|
HPP: item.HPP,
|
||||||
|
GrossProfit: item.GrossProfit,
|
||||||
|
GrossMargin: item.GrossMargin,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bankBalance := make([]contract.ExclusiveSummaryBankBalance, len(resp.BankBalance))
|
||||||
|
for i, item := range resp.BankBalance {
|
||||||
|
bankBalance[i] = contract.ExclusiveSummaryBankBalance{
|
||||||
|
Bank: item.Bank,
|
||||||
|
OpeningBalance: item.OpeningBalance,
|
||||||
|
IncomingMutation: item.IncomingMutation,
|
||||||
|
OutgoingMutation: item.OutgoingMutation,
|
||||||
|
ClosingBalance: item.ClosingBalance,
|
||||||
|
Notes: item.Notes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &contract.ExclusiveSummaryMonthlyResponse{
|
||||||
|
OrganizationID: resp.OrganizationID,
|
||||||
|
OutletID: resp.OutletID,
|
||||||
|
Month: resp.Month,
|
||||||
|
Summary: contract.ExclusiveSummaryMonthlySummary{
|
||||||
|
TotalSales: resp.Summary.TotalSales,
|
||||||
|
HPP: resp.Summary.HPP,
|
||||||
|
GrossProfit: resp.Summary.GrossProfit,
|
||||||
|
OperationalExpensesTotal: resp.Summary.OperationalExpensesTotal,
|
||||||
|
TotalCost: resp.Summary.TotalCost,
|
||||||
|
NetProfit: resp.Summary.NetProfit,
|
||||||
|
NetProfitMargin: resp.Summary.NetProfitMargin,
|
||||||
|
},
|
||||||
|
Periods: periods,
|
||||||
|
BankBalance: bankBalance,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func exclusiveSummaryCategoryBreakdownModelToContract(item models.ExclusiveSummaryCategoryBreakdown) contract.ExclusiveSummaryCategoryBreakdown {
|
||||||
|
return contract.ExclusiveSummaryCategoryBreakdown{
|
||||||
|
CategoryCode: item.CategoryCode,
|
||||||
|
CategoryName: item.CategoryName,
|
||||||
|
Amount: item.Amount,
|
||||||
|
Percentage: item.Percentage,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFlexibleDateRangeToJakartaTime(dateFrom, dateTo string) (*time.Time, *time.Time, error) {
|
||||||
|
fromTime, toTime, err := util.ParseDateRangeToJakartaTime(dateFrom, dateTo)
|
||||||
|
if err == nil {
|
||||||
|
return fromTime, toTime, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fromTime, err = parseISODateToJakartaTime(dateFrom, false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
toTime, err = parseISODateToJakartaTime(dateTo, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return fromTime, toTime, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseISODateToJakartaTime(dateStr string, endOfDay bool) (*time.Time, error) {
|
||||||
|
if dateStr == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
date, err := time.Parse("2006-01-02", dateStr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
location, err := time.LoadLocation("Asia/Jakarta")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if endOfDay {
|
||||||
|
result := time.Date(date.Year(), date.Month(), date.Day(), 23, 59, 59, 999999999, location)
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, location)
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseMonthToJakartaTime(month string) (time.Time, error) {
|
||||||
|
location, err := time.LoadLocation("Asia/Jakarta")
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := time.ParseInLocation("2006-01", month, location)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return time.Date(parsed.Year(), parsed.Month(), 1, 0, 0, 0, 0, location), nil
|
||||||
|
}
|
||||||
|
|||||||
@ -182,3 +182,43 @@ func TestProfitLossAnalyticsModelToContractCopiesDateRange(t *testing.T) {
|
|||||||
require.Len(t, result.MainSummary, 1)
|
require.Len(t, result.MainSummary, 1)
|
||||||
require.Equal(t, "total_omset", result.MainSummary[0].ID)
|
require.Equal(t, "total_omset", result.MainSummary[0].ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExclusiveSummaryPeriodContractToModelParsesFlexibleDates(t *testing.T) {
|
||||||
|
orgID := uuid.New()
|
||||||
|
outletID := uuid.New().String()
|
||||||
|
|
||||||
|
result, err := ExclusiveSummaryPeriodContractToModel(&contract.ExclusiveSummaryPeriodRequest{
|
||||||
|
OrganizationID: orgID,
|
||||||
|
OutletID: &outletID,
|
||||||
|
DateFrom: "2026-05-26",
|
||||||
|
DateTo: "2026-05-31",
|
||||||
|
ExcludeGajiStaffFromReimburse: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, orgID, result.OrganizationID)
|
||||||
|
require.NotNil(t, result.OutletID)
|
||||||
|
require.Equal(t, outletID, result.OutletID.String())
|
||||||
|
require.True(t, result.ExcludeGajiStaffFromReimburse)
|
||||||
|
|
||||||
|
location, err := time.LoadLocation("Asia/Jakarta")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, time.Date(2026, 5, 26, 0, 0, 0, 0, location), result.DateFrom)
|
||||||
|
require.Equal(t, time.Date(2026, 5, 31, 23, 59, 59, int(time.Second-time.Nanosecond), location), result.DateTo)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExclusiveSummaryMonthlyContractToModelParsesMonth(t *testing.T) {
|
||||||
|
orgID := uuid.New()
|
||||||
|
|
||||||
|
result, err := ExclusiveSummaryMonthlyContractToModel(&contract.ExclusiveSummaryMonthlyRequest{
|
||||||
|
OrganizationID: orgID,
|
||||||
|
Month: "2026-05",
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, orgID, result.OrganizationID)
|
||||||
|
|
||||||
|
location, err := time.LoadLocation("Asia/Jakarta")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, time.Date(2026, 5, 1, 0, 0, 0, 0, location), result.Month)
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user