From 55119b3e916caed14fd46b9cf67ef78fb0e95955 Mon Sep 17 00:00:00 2001 From: ryan Date: Thu, 18 Jun 2026 14:19:45 +0700 Subject: [PATCH 1/3] Add MTD --- internal/contract/analytics_contract.go | 7 +++ internal/handler/analytics_handler.go | 28 ++++++++++ internal/models/analytics.go | 7 +++ internal/processor/analytics_processor.go | 13 +++++ .../processor/analytics_processor_test.go | 55 ++++++++++++++++++- internal/router/router.go | 1 + internal/service/analytics_service.go | 30 ++++++++++ internal/service/analytics_service_test.go | 45 +++++++++++++++ internal/transformer/analytics_transformer.go | 37 +++++++++++++ 9 files changed, 222 insertions(+), 1 deletion(-) diff --git a/internal/contract/analytics_contract.go b/internal/contract/analytics_contract.go index 15de705..04e2d61 100644 --- a/internal/contract/analytics_contract.go +++ b/internal/contract/analytics_contract.go @@ -339,6 +339,13 @@ type ExclusiveSummaryMonthlyRequest struct { Month string `form:"month" validate:"required"` } +type ExclusiveSummaryMTDRequest struct { + OrganizationID uuid.UUID + OutletID *string `form:"outlet_id,omitempty"` + DateTo string `form:"date_to" validate:"required"` + ExcludeGajiStaffFromReimburse bool `form:"exclude_gaji_staff_from_reimburse"` +} + type ExclusiveSummaryPeriodResponse struct { OrganizationID uuid.UUID `json:"organization_id"` OutletID *uuid.UUID `json:"outlet_id,omitempty"` diff --git a/internal/handler/analytics_handler.go b/internal/handler/analytics_handler.go index a1db87d..438f932 100644 --- a/internal/handler/analytics_handler.go +++ b/internal/handler/analytics_handler.go @@ -266,3 +266,31 @@ func (h *AnalyticsHandler) GetExclusiveSummaryMonthly(c *gin.Context) { contractResp := transformer.ExclusiveSummaryMonthlyModelToContract(response) util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetExclusiveSummaryMonthly") } + +func (h *AnalyticsHandler) GetExclusiveSummaryMTD(c *gin.Context) { + ctx := c.Request.Context() + contextInfo := appcontext.FromGinContext(ctx) + + var req contract.ExclusiveSummaryMTDRequest + if err := c.ShouldBindQuery(&req); err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetExclusiveSummaryMTD", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryMTD") + return + } + + req.OrganizationID = contextInfo.OrganizationID + req.OutletID = h.resolveOutletID(c, contextInfo.OutletID) + modelReq, err := transformer.ExclusiveSummaryMTDContractToModel(&req) + if err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetExclusiveSummaryMTD", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryMTD") + return + } + + response, err := h.analyticsService.GetExclusiveSummaryMTD(ctx, modelReq) + if err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "AnalyticsHandler::GetExclusiveSummaryMTD", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryMTD") + return + } + + contractResp := transformer.ExclusiveSummaryPeriodModelToContract(response) + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetExclusiveSummaryMTD") +} diff --git a/internal/models/analytics.go b/internal/models/analytics.go index e5d3a0f..fe51e64 100644 --- a/internal/models/analytics.go +++ b/internal/models/analytics.go @@ -349,6 +349,13 @@ type ExclusiveSummaryMonthlyRequest struct { Month time.Time `validate:"required"` } +type ExclusiveSummaryMTDRequest struct { + OrganizationID uuid.UUID `validate:"required"` + OutletID *uuid.UUID `validate:"omitempty"` + DateTo time.Time `validate:"required"` + ExcludeGajiStaffFromReimburse bool `validate:"omitempty"` +} + type ExclusiveSummaryPeriodResponse struct { OrganizationID uuid.UUID `json:"organization_id"` OutletID *uuid.UUID `json:"outlet_id,omitempty"` diff --git a/internal/processor/analytics_processor.go b/internal/processor/analytics_processor.go index 880555f..728f06a 100644 --- a/internal/processor/analytics_processor.go +++ b/internal/processor/analytics_processor.go @@ -21,6 +21,7 @@ type AnalyticsProcessor interface { GetProfitLossAnalytics(ctx context.Context, req *models.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsResponse, error) GetExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error) GetExclusiveSummaryMonthly(ctx context.Context, req *models.ExclusiveSummaryMonthlyRequest) (*models.ExclusiveSummaryMonthlyResponse, error) + GetExclusiveSummaryMTD(ctx context.Context, req *models.ExclusiveSummaryMTDRequest) (*models.ExclusiveSummaryPeriodResponse, error) } type AnalyticsProcessorImpl struct { @@ -735,6 +736,18 @@ func (p *AnalyticsProcessorImpl) GetExclusiveSummaryMonthly(ctx context.Context, }, nil } +func (p *AnalyticsProcessorImpl) GetExclusiveSummaryMTD(ctx context.Context, req *models.ExclusiveSummaryMTDRequest) (*models.ExclusiveSummaryPeriodResponse, error) { + mtdStart := time.Date(req.DateTo.Year(), req.DateTo.Month(), 1, 0, 0, 0, 0, req.DateTo.Location()) + + return p.buildExclusiveSummaryPeriod(ctx, &models.ExclusiveSummaryPeriodRequest{ + OrganizationID: req.OrganizationID, + OutletID: req.OutletID, + DateFrom: mtdStart, + DateTo: req.DateTo, + ExcludeGajiStaffFromReimburse: req.ExcludeGajiStaffFromReimburse, + }) +} + func (p *AnalyticsProcessorImpl) buildExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error) { result, err := p.analyticsRepo.GetExclusiveSummaryAnalytics(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo) if err != nil { diff --git a/internal/processor/analytics_processor_test.go b/internal/processor/analytics_processor_test.go index 8a4d9c0..52b5c9e 100644 --- a/internal/processor/analytics_processor_test.go +++ b/internal/processor/analytics_processor_test.go @@ -19,6 +19,8 @@ type analyticsRepositoryStub struct { bankBalances []entities.ExclusiveSummaryBankBalance profitLossGroup string exclusiveSummaryCalls int + exclusiveSummaryFrom []time.Time + exclusiveSummaryTo []time.Time } func (analyticsRepositoryStub) GetPaymentMethodAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.PaymentMethodAnalytics, error) { @@ -50,7 +52,9 @@ func (s analyticsRepositoryStub) GetProfitLossAnalytics(_ context.Context, _ uui return s.profitLossResult, nil } -func (s *analyticsRepositoryStub) GetExclusiveSummaryAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) (*entities.ExclusiveSummaryAnalytics, error) { +func (s *analyticsRepositoryStub) GetExclusiveSummaryAnalytics(_ context.Context, _ uuid.UUID, _ *uuid.UUID, dateFrom, dateTo time.Time) (*entities.ExclusiveSummaryAnalytics, error) { + s.exclusiveSummaryFrom = append(s.exclusiveSummaryFrom, dateFrom) + s.exclusiveSummaryTo = append(s.exclusiveSummaryTo, dateTo) if s.exclusiveSummaryCalls < len(s.exclusiveSummaryResults) { result := s.exclusiveSummaryResults[s.exclusiveSummaryCalls] s.exclusiveSummaryCalls++ @@ -393,3 +397,52 @@ func TestAnalyticsProcessorGetExclusiveSummaryMonthlyBuildsSummaryAndBuckets(t * require.Equal(t, notes, *result.BankBalance[0].Notes) require.Equal(t, 6, stub.exclusiveSummaryCalls) } + +func TestAnalyticsProcessorGetExclusiveSummaryMTDBuildsMonthToDateBreakdown(t *testing.T) { + location, err := time.LoadLocation("Asia/Jakarta") + require.NoError(t, err) + dateTo := time.Date(2026, 6, 18, 23, 59, 59, int(time.Second-time.Nanosecond), location) + stub := &analyticsRepositoryStub{ + exclusiveSummaryResults: []*entities.ExclusiveSummaryAnalytics{ + { + SalesTotal: 1000, + HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{ + {CategoryCode: "RAW", CategoryName: "Raw Material", Amount: 400}, + }, + OperationalExpenseBreakdown: []entities.ExclusiveSummaryCategoryTotal{ + {CategoryCode: "OPS", CategoryName: "Operational", Amount: 100}, + }, + DailySummary: []entities.ExclusiveSummaryDailySummary{ + {Date: dateTo, TransactionCount: 2, TotalCost: 500}, + }, + DailyTransactions: []entities.ExclusiveSummaryDailyTransaction{ + {Date: dateTo, CategoryCode: "RAW", CategoryName: "Raw Material", Description: "beras", Amount: 400, Source: "purchase_order"}, + {Date: dateTo, CategoryCode: "OPS", CategoryName: "Operational", Description: "atk", Amount: 100, Source: "expense"}, + }, + }, + }, + } + processor := NewAnalyticsProcessorImpl(stub, expenseRepositoryStub{}) + + result, err := processor.GetExclusiveSummaryMTD(context.Background(), &models.ExclusiveSummaryMTDRequest{ + OrganizationID: uuid.New(), + DateTo: dateTo, + }) + + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, stub.exclusiveSummaryFrom, 1) + require.Equal(t, time.Date(2026, 6, 1, 0, 0, 0, 0, location), stub.exclusiveSummaryFrom[0]) + require.Equal(t, dateTo, stub.exclusiveSummaryTo[0]) + require.Equal(t, stub.exclusiveSummaryFrom[0], result.Period.DateFrom) + require.Equal(t, dateTo, result.Period.DateTo) + require.Equal(t, float64(1000), result.Summary.Sales) + require.Equal(t, float64(400), result.Summary.HPP) + require.Equal(t, float64(500), result.Summary.TotalCost) + require.Equal(t, float64(500), result.Summary.NetProfit) + require.Len(t, result.HPPBreakdown, 1) + require.Equal(t, float64(100), result.HPPBreakdown[0].Percentage) + require.Len(t, result.OperationalExpenseBreakdown, 1) + require.Len(t, result.DailySummary, 1) + require.Len(t, result.DailyTransactions, 2) +} diff --git a/internal/router/router.go b/internal/router/router.go index 35aec41..415ef04 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -339,6 +339,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { analytics.GET("/profit-loss", r.analyticsHandler.GetProfitLossAnalytics) analytics.GET("/exclusive-summary/period", r.analyticsHandler.GetExclusiveSummaryPeriod) analytics.GET("/exclusive-summary/monthly", r.analyticsHandler.GetExclusiveSummaryMonthly) + analytics.GET("/exclusive-summary/mtd", r.analyticsHandler.GetExclusiveSummaryMTD) } tables := protected.Group("/tables") diff --git a/internal/service/analytics_service.go b/internal/service/analytics_service.go index b9dc137..740aaf0 100644 --- a/internal/service/analytics_service.go +++ b/internal/service/analytics_service.go @@ -20,6 +20,7 @@ type AnalyticsService interface { GetProfitLossAnalytics(ctx context.Context, req *models.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsResponse, error) GetExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error) GetExclusiveSummaryMonthly(ctx context.Context, req *models.ExclusiveSummaryMonthlyRequest) (*models.ExclusiveSummaryMonthlyResponse, error) + GetExclusiveSummaryMTD(ctx context.Context, req *models.ExclusiveSummaryMTDRequest) (*models.ExclusiveSummaryPeriodResponse, error) } type AnalyticsServiceImpl struct { @@ -349,6 +350,19 @@ func (s *AnalyticsServiceImpl) GetExclusiveSummaryMonthly(ctx context.Context, r return response, nil } +func (s *AnalyticsServiceImpl) GetExclusiveSummaryMTD(ctx context.Context, req *models.ExclusiveSummaryMTDRequest) (*models.ExclusiveSummaryPeriodResponse, error) { + if err := s.validateExclusiveSummaryMTDRequest(req); err != nil { + return nil, fmt.Errorf("validation error: %w", err) + } + + response, err := s.analyticsProcessor.GetExclusiveSummaryMTD(ctx, req) + if err != nil { + return nil, fmt.Errorf("failed to get exclusive summary mtd: %w", err) + } + + return response, nil +} + func (s *AnalyticsServiceImpl) validateExclusiveSummaryPeriodRequest(req *models.ExclusiveSummaryPeriodRequest) error { if req == nil { return fmt.Errorf("request cannot be nil") @@ -373,6 +387,22 @@ func (s *AnalyticsServiceImpl) validateExclusiveSummaryPeriodRequest(req *models return nil } +func (s *AnalyticsServiceImpl) validateExclusiveSummaryMTDRequest(req *models.ExclusiveSummaryMTDRequest) error { + if req == nil { + return fmt.Errorf("request cannot be nil") + } + + if req.OrganizationID == uuid.Nil { + return fmt.Errorf("organization_id is required") + } + + if req.DateTo.IsZero() { + return fmt.Errorf("date_to is required") + } + + return nil +} + func (s *AnalyticsServiceImpl) validateExclusiveSummaryMonthlyRequest(req *models.ExclusiveSummaryMonthlyRequest) error { if req == nil { return fmt.Errorf("request cannot be nil") diff --git a/internal/service/analytics_service_test.go b/internal/service/analytics_service_test.go index d1aab7d..fa4db25 100644 --- a/internal/service/analytics_service_test.go +++ b/internal/service/analytics_service_test.go @@ -49,6 +49,10 @@ func (analyticsProcessorStub) GetExclusiveSummaryMonthly(context.Context, *model return &models.ExclusiveSummaryMonthlyResponse{}, nil } +func (analyticsProcessorStub) GetExclusiveSummaryMTD(context.Context, *models.ExclusiveSummaryMTDRequest) (*models.ExclusiveSummaryPeriodResponse, error) { + return &models.ExclusiveSummaryPeriodResponse{}, nil +} + func TestAnalyticsServiceGetPurchasingAnalyticsValidation(t *testing.T) { service := NewAnalyticsServiceImpl(analyticsProcessorStub{}) now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) @@ -254,3 +258,44 @@ func TestAnalyticsServiceGetExclusiveSummaryMonthlyValidation(t *testing.T) { require.Error(t, err) require.Contains(t, err.Error(), "month is required") } + +func TestAnalyticsServiceGetExclusiveSummaryMTDValidation(t *testing.T) { + service := NewAnalyticsServiceImpl(analyticsProcessorStub{}) + now := time.Date(2026, 6, 18, 23, 59, 59, 0, time.UTC) + + tests := []struct { + name string + req *models.ExclusiveSummaryMTDRequest + wantErr string + }{ + { + name: "nil request", + req: nil, + wantErr: "request cannot be nil", + }, + { + name: "missing organization", + req: &models.ExclusiveSummaryMTDRequest{ + DateTo: now, + }, + wantErr: "organization_id is required", + }, + { + name: "missing date_to", + req: &models.ExclusiveSummaryMTDRequest{ + OrganizationID: uuid.New(), + }, + wantErr: "date_to is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp, err := service.GetExclusiveSummaryMTD(context.Background(), tt.req) + + require.Nil(t, resp) + require.Error(t, err) + require.Contains(t, err.Error(), tt.wantErr) + }) + } +} diff --git a/internal/transformer/analytics_transformer.go b/internal/transformer/analytics_transformer.go index 9cd5dcd..03deab4 100644 --- a/internal/transformer/analytics_transformer.go +++ b/internal/transformer/analytics_transformer.go @@ -604,6 +604,27 @@ func ExclusiveSummaryMonthlyContractToModel(req *contract.ExclusiveSummaryMonthl }, nil } +func ExclusiveSummaryMTDContractToModel(req *contract.ExclusiveSummaryMTDRequest) (*models.ExclusiveSummaryMTDRequest, error) { + if req == nil { + return nil, fmt.Errorf("request cannot be nil") + } + + dateTo, err := parseFlexibleDateToJakartaTime(req.DateTo, true) + if err != nil { + return nil, fmt.Errorf("invalid date_to: %w", err) + } + if dateTo == nil { + return nil, fmt.Errorf("date_to is required") + } + + return &models.ExclusiveSummaryMTDRequest{ + OrganizationID: req.OrganizationID, + OutletID: parseOutletID(req.OutletID), + DateTo: *dateTo, + ExcludeGajiStaffFromReimburse: req.ExcludeGajiStaffFromReimburse, + }, nil +} + func ExclusiveSummaryPeriodModelToContract(resp *models.ExclusiveSummaryPeriodResponse) *contract.ExclusiveSummaryPeriodResponse { if resp == nil { return nil @@ -772,6 +793,22 @@ func parseISODateToJakartaTime(dateStr string, endOfDay bool) (*time.Time, error return &result, nil } +func parseFlexibleDateToJakartaTime(dateStr string, endOfDay bool) (*time.Time, error) { + if dateStr == "" { + return nil, nil + } + + fromTime, toTime, err := util.ParseDateRangeToJakartaTime(dateStr, dateStr) + if err == nil { + if endOfDay { + return toTime, nil + } + return fromTime, nil + } + + return parseISODateToJakartaTime(dateStr, endOfDay) +} + func parseMonthToJakartaTime(month string) (time.Time, error) { location, err := time.LoadLocation("Asia/Jakarta") if err != nil { -- 2.47.2 From 66d4c9f0af2c906128765445fce38b566c5858ea Mon Sep 17 00:00:00 2001 From: ryan Date: Thu, 18 Jun 2026 15:27:20 +0700 Subject: [PATCH 2/3] Update purchase order with outlet id --- internal/contract/purchase_order_contract.go | 1 + internal/entities/purchase_order.go | 2 + internal/mappers/purchase_order_mapper.go | 3 ++ internal/models/purchase_order.go | 3 ++ .../processor/purchase_order_processor.go | 19 +++++-- .../processor/purchase_order_repository.go | 1 + internal/repository/analytics_repository.go | 17 +++--- .../repository/purchase_order_repository.go | 12 +++++ internal/service/purchase_order_service.go | 14 ++++- .../transformer/purchase_order_transformer.go | 1 + ..._add_outlet_id_to_purchase_orders.down.sql | 4 ++ ...82_add_outlet_id_to_purchase_orders.up.sql | 52 +++++++++++++++++++ 12 files changed, 115 insertions(+), 14 deletions(-) create mode 100644 migrations/000082_add_outlet_id_to_purchase_orders.down.sql create mode 100644 migrations/000082_add_outlet_id_to_purchase_orders.up.sql diff --git a/internal/contract/purchase_order_contract.go b/internal/contract/purchase_order_contract.go index b5ae3bb..7038b4b 100644 --- a/internal/contract/purchase_order_contract.go +++ b/internal/contract/purchase_order_contract.go @@ -52,6 +52,7 @@ type UpdatePurchaseOrderItemRequest struct { type PurchaseOrderResponse struct { ID uuid.UUID `json:"id"` OrganizationID uuid.UUID `json:"organization_id"` + OutletID *uuid.UUID `json:"outlet_id"` VendorID *uuid.UUID `json:"vendor_id"` PONumber string `json:"po_number"` TransactionDate time.Time `json:"transaction_date"` diff --git a/internal/entities/purchase_order.go b/internal/entities/purchase_order.go index 7146fb6..ba5209e 100644 --- a/internal/entities/purchase_order.go +++ b/internal/entities/purchase_order.go @@ -11,6 +11,7 @@ import ( type PurchaseOrder struct { ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id" validate:"required"` + OutletID *uuid.UUID `gorm:"type:uuid;index" json:"outlet_id" validate:"omitempty"` VendorID *uuid.UUID `gorm:"type:uuid" json:"vendor_id" validate:"omitempty"` PONumber string `gorm:"not null;size:50" json:"po_number" validate:"required,min=1,max=50"` TransactionDate time.Time `gorm:"type:date;not null" json:"transaction_date" validate:"required"` @@ -23,6 +24,7 @@ type PurchaseOrder struct { UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` Organization *Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"` + Outlet *Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"` Vendor *Vendor `gorm:"foreignKey:VendorID" json:"vendor,omitempty"` Items []PurchaseOrderItem `gorm:"foreignKey:PurchaseOrderID" json:"items,omitempty"` Attachments []PurchaseOrderAttachment `gorm:"foreignKey:PurchaseOrderID" json:"attachments,omitempty"` diff --git a/internal/mappers/purchase_order_mapper.go b/internal/mappers/purchase_order_mapper.go index b08327f..5be72d2 100644 --- a/internal/mappers/purchase_order_mapper.go +++ b/internal/mappers/purchase_order_mapper.go @@ -13,6 +13,7 @@ func PurchaseOrderEntityToModel(entity *entities.PurchaseOrder) *models.Purchase return &models.PurchaseOrder{ ID: entity.ID, OrganizationID: entity.OrganizationID, + OutletID: entity.OutletID, VendorID: entity.VendorID, PONumber: entity.PONumber, TransactionDate: entity.TransactionDate, @@ -34,6 +35,7 @@ func PurchaseOrderModelToEntity(model *models.PurchaseOrder) *entities.PurchaseO return &entities.PurchaseOrder{ ID: model.ID, OrganizationID: model.OrganizationID, + OutletID: model.OutletID, VendorID: model.VendorID, PONumber: model.PONumber, TransactionDate: model.TransactionDate, @@ -55,6 +57,7 @@ func PurchaseOrderEntityToResponse(entity *entities.PurchaseOrder) *models.Purch response := &models.PurchaseOrderResponse{ ID: entity.ID, OrganizationID: entity.OrganizationID, + OutletID: entity.OutletID, VendorID: entity.VendorID, PONumber: entity.PONumber, TransactionDate: entity.TransactionDate, diff --git a/internal/models/purchase_order.go b/internal/models/purchase_order.go index d3428e2..7122ed8 100644 --- a/internal/models/purchase_order.go +++ b/internal/models/purchase_order.go @@ -9,6 +9,7 @@ import ( type PurchaseOrder struct { ID uuid.UUID `json:"id"` OrganizationID uuid.UUID `json:"organization_id"` + OutletID *uuid.UUID `json:"outlet_id"` VendorID *uuid.UUID `json:"vendor_id"` PONumber string `json:"po_number"` TransactionDate time.Time `json:"transaction_date"` @@ -44,6 +45,7 @@ type PurchaseOrderAttachment struct { type PurchaseOrderResponse struct { ID uuid.UUID `json:"id"` OrganizationID uuid.UUID `json:"organization_id"` + OutletID *uuid.UUID `json:"outlet_id"` VendorID *uuid.UUID `json:"vendor_id"` PONumber string `json:"po_number"` TransactionDate time.Time `json:"transaction_date"` @@ -85,6 +87,7 @@ type PurchaseOrderAttachmentResponse struct { type CreatePurchaseOrderRequest struct { VendorID *uuid.UUID `json:"vendor_id,omitempty"` + OutletID *uuid.UUID `json:"outlet_id,omitempty"` PONumber string `json:"po_number"` TransactionDate time.Time `json:"transaction_date"` DueDate *time.Time `json:"due_date,omitempty"` diff --git a/internal/processor/purchase_order_processor.go b/internal/processor/purchase_order_processor.go index 8782d00..fe8de6e 100644 --- a/internal/processor/purchase_order_processor.go +++ b/internal/processor/purchase_order_processor.go @@ -11,8 +11,8 @@ import ( ) type PurchaseOrderProcessor interface { - CreatePurchaseOrder(ctx context.Context, organizationID uuid.UUID, req *models.CreatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error) - UpdatePurchaseOrder(ctx context.Context, id, organizationID uuid.UUID, req *models.UpdatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error) + CreatePurchaseOrder(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, req *models.CreatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error) + UpdatePurchaseOrder(ctx context.Context, id, organizationID uuid.UUID, outletID *uuid.UUID, req *models.UpdatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error) DeletePurchaseOrder(ctx context.Context, id, organizationID uuid.UUID) error GetPurchaseOrderByID(ctx context.Context, id, organizationID uuid.UUID) (*models.PurchaseOrderResponse, error) ListPurchaseOrders(ctx context.Context, organizationID uuid.UUID, filters map[string]interface{}, page, limit int) ([]*models.PurchaseOrderResponse, int, error) @@ -54,7 +54,7 @@ func NewPurchaseOrderProcessorImpl( } } -func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, organizationID uuid.UUID, req *models.CreatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error) { +func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, req *models.CreatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error) { // Check if vendor exists and belongs to organization when provided. if req.VendorID != nil { _, err := p.vendorRepo.GetByIDAndOrganizationID(ctx, *req.VendorID, organizationID) @@ -115,6 +115,7 @@ func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, or // Create purchase order entity poEntity := &entities.PurchaseOrder{ OrganizationID: organizationID, + OutletID: outletID, VendorID: req.VendorID, PONumber: req.PONumber, TransactionDate: req.TransactionDate, @@ -175,12 +176,15 @@ func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, or return mappers.PurchaseOrderEntityToResponse(createdPO), nil } -func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id, organizationID uuid.UUID, req *models.UpdatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error) { +func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id, organizationID uuid.UUID, outletID *uuid.UUID, req *models.UpdatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error) { // Get existing purchase order poEntity, err := p.purchaseOrderRepo.GetByIDAndOrganizationID(ctx, id, organizationID) if err != nil { return nil, fmt.Errorf("purchase order not found: %w", err) } + if poEntity.OutletID == nil && outletID != nil { + poEntity.OutletID = outletID + } // Check if vendor exists and belongs to organization (if vendor is being updated) if req.VendorID != nil { @@ -478,7 +482,12 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte } // Update the purchase order status - err = p.purchaseOrderRepo.UpdateStatus(ctx, id, status) + statusOutletID := po.OutletID + if statusOutletID == nil && outletID != uuid.Nil { + statusOutletID = &outletID + } + + err = p.purchaseOrderRepo.UpdateStatusAndOutlet(ctx, id, status, statusOutletID) if err != nil { return nil, fmt.Errorf("failed to update purchase order status: %w", err) } diff --git a/internal/processor/purchase_order_repository.go b/internal/processor/purchase_order_repository.go index 98fa3d4..776e627 100644 --- a/internal/processor/purchase_order_repository.go +++ b/internal/processor/purchase_order_repository.go @@ -19,6 +19,7 @@ type PurchaseOrderRepository interface { GetByStatus(ctx context.Context, organizationID uuid.UUID, status string) ([]*entities.PurchaseOrder, error) GetOverdue(ctx context.Context, organizationID uuid.UUID) ([]*entities.PurchaseOrder, error) UpdateStatus(ctx context.Context, id uuid.UUID, status string) error + UpdateStatusAndOutlet(ctx context.Context, id uuid.UUID, status string, outletID *uuid.UUID) error UpdateTotalAmount(ctx context.Context, id uuid.UUID, totalAmount float64) error CreateItem(ctx context.Context, item *entities.PurchaseOrderItem) error UpdateItem(ctx context.Context, item *entities.PurchaseOrderItem) error diff --git a/internal/repository/analytics_repository.go b/internal/repository/analytics_repository.go index b51f0d3..8387181 100644 --- a/internal/repository/analytics_repository.go +++ b/internal/repository/analytics_repository.go @@ -281,7 +281,7 @@ func (r *AnalyticsRepositoryImpl) applyPurchaseOrderItemOutletFilter(query *gorm if outletID == nil { return query } - return query.Where("(i.outlet_id = ? OR u.outlet_id = ?)", *outletID, *outletID) + return query.Where("po.outlet_id = ?", *outletID) } func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, limit int) ([]*entities.ProductAnalytics, error) { @@ -717,7 +717,7 @@ func (r *AnalyticsRepositoryImpl) GetExclusiveSummaryAnalytics(ctx context.Conte return nil, err } - operationalExpenseBreakdown, err := r.getExclusiveSummaryOperationalExpenseBreakdown(ctx, organizationID, dateFrom, dateTo) + operationalExpenseBreakdown, err := r.getExclusiveSummaryOperationalExpenseBreakdown(ctx, organizationID, outletID, dateFrom, dateTo) if err != nil { return nil, err } @@ -770,10 +770,10 @@ func (r *AnalyticsRepositoryImpl) getExclusiveSummaryHPPBreakdown(ctx context.Co return results, err } -func (r *AnalyticsRepositoryImpl) getExclusiveSummaryOperationalExpenseBreakdown(ctx context.Context, organizationID uuid.UUID, dateFrom, dateTo time.Time) ([]entities.ExclusiveSummaryCategoryTotal, error) { +func (r *AnalyticsRepositoryImpl) getExclusiveSummaryOperationalExpenseBreakdown(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]entities.ExclusiveSummaryCategoryTotal, error) { var results []entities.ExclusiveSummaryCategoryTotal - err := r.db.WithContext(ctx). + query := r.db.WithContext(ctx). Table("purchase_order_items poi"). Select(` pc.code as category_code, @@ -785,7 +785,10 @@ func (r *AnalyticsRepositoryImpl) getExclusiveSummaryOperationalExpenseBreakdown Where("po.organization_id = ?", organizationID). Where("pc.type = ?", entities.PurchaseCategoryTypeExpense). Where("po.status = ?", "received"). - Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo). + Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo) + query = r.applyPurchaseOrderItemOutletFilter(query, outletID) + + err := query. Group("pc.id, pc.code, pc.name, pc.sort_order"). Order("pc.sort_order ASC, pc.name ASC"). Scan(&results).Error @@ -830,8 +833,8 @@ func (r *AnalyticsRepositoryImpl) exclusiveSummaryPurchaseOrderItemQuery(organiz } if outletID != nil { - outletFilter = "AND (pc.type = ? OR i.outlet_id = ? OR u.outlet_id = ?)" - args = append(args, entities.PurchaseCategoryTypeExpense, *outletID, *outletID) + outletFilter = "AND po.outlet_id = ?" + args = append(args, *outletID) } query := ` diff --git a/internal/repository/purchase_order_repository.go b/internal/repository/purchase_order_repository.go index 8383eec..d65cd6b 100644 --- a/internal/repository/purchase_order_repository.go +++ b/internal/repository/purchase_order_repository.go @@ -196,6 +196,18 @@ func (r *PurchaseOrderRepositoryImpl) UpdateStatus(ctx context.Context, id uuid. Update("status", status).Error } +func (r *PurchaseOrderRepositoryImpl) UpdateStatusAndOutlet(ctx context.Context, id uuid.UUID, status string, outletID *uuid.UUID) error { + updates := map[string]interface{}{"status": status} + if outletID != nil { + updates["outlet_id"] = *outletID + } + + return r.db.WithContext(ctx). + Model(&entities.PurchaseOrder{}). + Where("id = ?", id). + Updates(updates).Error +} + func (r *PurchaseOrderRepositoryImpl) UpdateTotalAmount(ctx context.Context, id uuid.UUID, totalAmount float64) error { return r.db.WithContext(ctx). Model(&entities.PurchaseOrder{}). diff --git a/internal/service/purchase_order_service.go b/internal/service/purchase_order_service.go index 4c9df39..27fbcb7 100644 --- a/internal/service/purchase_order_service.go +++ b/internal/service/purchase_order_service.go @@ -40,7 +40,12 @@ func (s *PurchaseOrderServiceImpl) CreatePurchaseOrder(ctx context.Context, apct return contract.BuildErrorResponse([]*contract.ResponseError{errorResp}) } - poResponse, err := s.purchaseOrderProcessor.CreatePurchaseOrder(ctx, apctx.OrganizationID, modelReq) + var outletID *uuid.UUID + if apctx.OutletID != uuid.Nil { + outletID = &apctx.OutletID + } + + poResponse, err := s.purchaseOrderProcessor.CreatePurchaseOrder(ctx, apctx.OrganizationID, outletID, modelReq) if err != nil { errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.PurchaseOrderServiceEntity, err.Error()) return contract.BuildErrorResponse([]*contract.ResponseError{errorResp}) @@ -57,7 +62,12 @@ func (s *PurchaseOrderServiceImpl) UpdatePurchaseOrder(ctx context.Context, apct return contract.BuildErrorResponse([]*contract.ResponseError{errorResp}) } - poResponse, err := s.purchaseOrderProcessor.UpdatePurchaseOrder(ctx, id, apctx.OrganizationID, modelReq) + var outletID *uuid.UUID + if apctx.OutletID != uuid.Nil { + outletID = &apctx.OutletID + } + + poResponse, err := s.purchaseOrderProcessor.UpdatePurchaseOrder(ctx, id, apctx.OrganizationID, outletID, modelReq) if err != nil { errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.PurchaseOrderServiceEntity, err.Error()) return contract.BuildErrorResponse([]*contract.ResponseError{errorResp}) diff --git a/internal/transformer/purchase_order_transformer.go b/internal/transformer/purchase_order_transformer.go index b61e2ef..814b8c4 100644 --- a/internal/transformer/purchase_order_transformer.go +++ b/internal/transformer/purchase_order_transformer.go @@ -120,6 +120,7 @@ func PurchaseOrderModelResponseToResponse(po *models.PurchaseOrderResponse) *con response := &contract.PurchaseOrderResponse{ ID: po.ID, OrganizationID: po.OrganizationID, + OutletID: po.OutletID, VendorID: po.VendorID, PONumber: po.PONumber, TransactionDate: po.TransactionDate, diff --git a/migrations/000082_add_outlet_id_to_purchase_orders.down.sql b/migrations/000082_add_outlet_id_to_purchase_orders.down.sql new file mode 100644 index 0000000..5a1a97f --- /dev/null +++ b/migrations/000082_add_outlet_id_to_purchase_orders.down.sql @@ -0,0 +1,4 @@ +DROP INDEX IF EXISTS idx_purchase_orders_outlet_id; + +ALTER TABLE purchase_orders +DROP COLUMN IF EXISTS outlet_id; diff --git a/migrations/000082_add_outlet_id_to_purchase_orders.up.sql b/migrations/000082_add_outlet_id_to_purchase_orders.up.sql new file mode 100644 index 0000000..a9dea93 --- /dev/null +++ b/migrations/000082_add_outlet_id_to_purchase_orders.up.sql @@ -0,0 +1,52 @@ +ALTER TABLE purchase_orders +ADD COLUMN IF NOT EXISTS outlet_id UUID REFERENCES outlets(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_purchase_orders_outlet_id +ON purchase_orders(outlet_id); + +WITH movement_outlets AS ( + SELECT + poi.purchase_order_id, + MIN(im.outlet_id) AS outlet_id + FROM inventory_movements im + JOIN purchase_order_items poi ON im.purchase_order_item_id = poi.id + WHERE im.outlet_id IS NOT NULL + AND im.purchase_order_item_id IS NOT NULL + GROUP BY poi.purchase_order_id + HAVING COUNT(DISTINCT im.outlet_id) = 1 +) +UPDATE purchase_orders po +SET outlet_id = movement_outlets.outlet_id +FROM movement_outlets +WHERE po.id = movement_outlets.purchase_order_id + AND po.outlet_id IS NULL; + +WITH candidate_item_outlets AS ( + SELECT + poi.purchase_order_id, + i.outlet_id + FROM purchase_order_items poi + JOIN ingredients i ON poi.ingredient_id = i.id + WHERE i.outlet_id IS NOT NULL + + UNION ALL + + SELECT + poi.purchase_order_id, + u.outlet_id + FROM purchase_order_items poi + JOIN units u ON poi.unit_id = u.id + WHERE u.outlet_id IS NOT NULL +), item_outlets AS ( + SELECT + purchase_order_id, + MIN(outlet_id) AS outlet_id + FROM candidate_item_outlets + GROUP BY purchase_order_id + HAVING COUNT(DISTINCT outlet_id) = 1 +) +UPDATE purchase_orders po +SET outlet_id = item_outlets.outlet_id +FROM item_outlets +WHERE po.id = item_outlets.purchase_order_id + AND po.outlet_id IS NULL; -- 2.47.2 From 87540fa1b7007d59a37ec173bb358986335875fe Mon Sep 17 00:00:00 2001 From: ryan Date: Thu, 18 Jun 2026 15:44:15 +0700 Subject: [PATCH 3/3] Update exclusive summary --- internal/repository/analytics_repository.go | 26 ++++++++++++------- ...82_add_outlet_id_to_purchase_orders.up.sql | 14 ++++++++++ 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/internal/repository/analytics_repository.go b/internal/repository/analytics_repository.go index 8387181..922330a 100644 --- a/internal/repository/analytics_repository.go +++ b/internal/repository/analytics_repository.go @@ -39,6 +39,10 @@ func (r *AnalyticsRepositoryImpl) resolveOutletID(query *gorm.DB, outletID *uuid return query } +func purchaseOrderItemTotalAmountSQL() string { + return "CASE WHEN pc.type = '" + string(entities.PurchaseCategoryTypeRawMaterial) + "' THEN COALESCE(poi.quantity, 0) * poi.amount ELSE poi.amount END" +} + func (r *AnalyticsRepositoryImpl) GetPaymentMethodAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]*entities.PaymentMethodAnalytics, error) { var results []*entities.PaymentMethodAnalytics @@ -153,18 +157,19 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex summaryQuery := r.db.WithContext(ctx). Table("purchase_orders po"). Select(` - COALESCE(SUM(poi.amount), 0) as total_purchases, + COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as total_purchases, COUNT(DISTINCT po.id) as total_purchase_orders, COALESCE(SUM(poi.quantity), 0) as total_quantity, CASE WHEN COUNT(DISTINCT po.id) > 0 - THEN COALESCE(SUM(poi.amount), 0) / COUNT(DISTINCT po.id) + THEN COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) / COUNT(DISTINCT po.id) ELSE 0 END as average_purchase_order_value, COUNT(DISTINCT i.id) as total_ingredients, COUNT(DISTINCT COALESCE(po.vendor_id::text, 'no-vendor')) as total_vendors `). Joins("LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id"). + Joins("LEFT JOIN purchase_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). @@ -193,13 +198,14 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex Table("purchase_orders po"). Select(` `+dateFormat+` as date, - COALESCE(SUM(poi.amount), 0) as purchases, + COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as purchases, COUNT(DISTINCT po.id) as purchase_orders, COALESCE(SUM(poi.quantity), 0) as quantity, COUNT(DISTINCT i.id) as ingredients, COUNT(DISTINCT COALESCE(po.vendor_id::text, 'no-vendor')) as vendors `). Joins("LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id"). + Joins("LEFT JOIN purchase_categories pc ON poi.purchase_category_id = pc.id"). Joins("LEFT JOIN ingredients i ON poi.ingredient_id = i.id"). Joins("LEFT JOIN units u ON poi.unit_id = u.id"). Where("po.organization_id = ?", organizationID). @@ -220,15 +226,16 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex i.id as ingredient_id, i.name as ingredient_name, COALESCE(SUM(poi.quantity), 0) as quantity, - COALESCE(SUM(poi.amount), 0) as total_cost, + COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as total_cost, CASE WHEN SUM(poi.quantity) > 0 - THEN COALESCE(SUM(poi.amount), 0) / SUM(poi.quantity) + THEN COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) / SUM(poi.quantity) ELSE 0 END as average_unit_cost, COUNT(DISTINCT po.id) as purchase_order_count `). Joins("JOIN purchase_orders po ON poi.purchase_order_id = po.id"). + Joins("JOIN purchase_categories pc ON poi.purchase_category_id = pc.id"). Joins("JOIN ingredients i ON poi.ingredient_id = i.id"). Joins("LEFT JOIN units u ON poi.unit_id = u.id"). Where("po.organization_id = ?", organizationID). @@ -248,13 +255,14 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex Select(` v.id as vendor_id, COALESCE(v.name, 'No Vendor') as vendor_name, - COALESCE(SUM(poi.amount), 0) as total_cost, + COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as total_cost, COUNT(DISTINCT po.id) as purchase_order_count, COUNT(DISTINCT i.id) as ingredient_count, COALESCE(SUM(poi.quantity), 0) as quantity `). Joins("LEFT JOIN vendors v ON po.vendor_id = v.id"). Joins("LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id"). + Joins("LEFT JOIN purchase_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). @@ -750,7 +758,7 @@ func (r *AnalyticsRepositoryImpl) getExclusiveSummaryHPPBreakdown(ctx context.Co Select(` pc.code as category_code, pc.name as category_name, - COALESCE(SUM(poi.amount), 0) as amount + COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as amount `). Joins("JOIN purchase_orders po ON poi.purchase_order_id = po.id"). Joins("JOIN purchase_categories pc ON poi.purchase_category_id = pc.id"). @@ -778,7 +786,7 @@ func (r *AnalyticsRepositoryImpl) getExclusiveSummaryOperationalExpenseBreakdown Select(` pc.code as category_code, pc.name as category_name, - COALESCE(SUM(poi.amount), 0) as amount + COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as amount `). Joins("JOIN purchase_orders po ON poi.purchase_order_id = po.id"). Joins("JOIN purchase_categories pc ON poi.purchase_category_id = pc.id"). @@ -843,7 +851,7 @@ func (r *AnalyticsRepositoryImpl) exclusiveSummaryPurchaseOrderItemQuery(organiz pc.code as category_code, pc.name as category_name, COALESCE(NULLIF(poi.description, ''), i.name, pc.name) as description, - poi.amount as amount, + ` + purchaseOrderItemTotalAmountSQL() + ` as amount, 'purchase_order' as source FROM purchase_order_items poi JOIN purchase_orders po ON poi.purchase_order_id = po.id diff --git a/migrations/000082_add_outlet_id_to_purchase_orders.up.sql b/migrations/000082_add_outlet_id_to_purchase_orders.up.sql index a9dea93..c7d696c 100644 --- a/migrations/000082_add_outlet_id_to_purchase_orders.up.sql +++ b/migrations/000082_add_outlet_id_to_purchase_orders.up.sql @@ -50,3 +50,17 @@ SET outlet_id = item_outlets.outlet_id FROM item_outlets WHERE po.id = item_outlets.purchase_order_id AND po.outlet_id IS NULL; + +WITH single_outlet_organizations AS ( + SELECT + organization_id, + MIN(id) AS outlet_id + FROM outlets + GROUP BY organization_id + HAVING COUNT(*) = 1 +) +UPDATE purchase_orders po +SET outlet_id = single_outlet_organizations.outlet_id +FROM single_outlet_organizations +WHERE po.organization_id = single_outlet_organizations.organization_id + AND po.outlet_id IS NULL; -- 2.47.2