Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55119b3e91 | ||
|
|
c1d859ebdd | ||
|
|
2ad9e2f85f | ||
|
|
8816e4addc | ||
|
|
2921631ac3 | ||
|
|
6c19876a47 | ||
|
|
b2db56f855 | ||
|
|
8c4d9c69d0 | ||
|
|
657a201fc0 |
@@ -140,12 +140,12 @@ type PurchasingIngredientData struct {
|
||||
}
|
||||
|
||||
type PurchasingVendorData struct {
|
||||
VendorID uuid.UUID `json:"vendor_id"`
|
||||
VendorName string `json:"vendor_name"`
|
||||
TotalCost float64 `json:"total_cost"`
|
||||
PurchaseOrderCount int64 `json:"purchase_order_count"`
|
||||
IngredientCount int64 `json:"ingredient_count"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
VendorID *uuid.UUID `json:"vendor_id"`
|
||||
VendorName string `json:"vendor_name"`
|
||||
TotalCost float64 `json:"total_cost"`
|
||||
PurchaseOrderCount int64 `json:"purchase_order_count"`
|
||||
IngredientCount int64 `json:"ingredient_count"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
}
|
||||
|
||||
// ProductAnalyticsRequest represents the request for product analytics
|
||||
@@ -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"`
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
)
|
||||
|
||||
type CreatePurchaseOrderRequest struct {
|
||||
VendorID uuid.UUID `json:"vendor_id" validate:"required"`
|
||||
VendorID *uuid.UUID `json:"vendor_id,omitempty" validate:"omitempty"`
|
||||
PONumber string `json:"po_number" validate:"required,min=1,max=50"`
|
||||
TransactionDate string `json:"transaction_date" validate:"required"` // Format: YYYY-MM-DD
|
||||
DueDate *string `json:"due_date,omitempty" validate:"omitempty"` // Format: YYYY-MM-DD
|
||||
@@ -52,7 +52,7 @@ type UpdatePurchaseOrderItemRequest struct {
|
||||
type PurchaseOrderResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
VendorID uuid.UUID `json:"vendor_id"`
|
||||
VendorID *uuid.UUID `json:"vendor_id"`
|
||||
PONumber string `json:"po_number"`
|
||||
TransactionDate time.Time `json:"transaction_date"`
|
||||
DueDate *time.Time `json:"due_date"`
|
||||
|
||||
@@ -72,12 +72,12 @@ type PurchasingIngredientData struct {
|
||||
}
|
||||
|
||||
type PurchasingVendorData struct {
|
||||
VendorID uuid.UUID `json:"vendor_id"`
|
||||
VendorName string `json:"vendor_name"`
|
||||
TotalCost float64 `json:"total_cost"`
|
||||
PurchaseOrderCount int64 `json:"purchase_order_count"`
|
||||
IngredientCount int64 `json:"ingredient_count"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
VendorID *uuid.UUID `json:"vendor_id"`
|
||||
VendorName string `json:"vendor_name"`
|
||||
TotalCost float64 `json:"total_cost"`
|
||||
PurchaseOrderCount int64 `json:"purchase_order_count"`
|
||||
IngredientCount int64 `json:"ingredient_count"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
}
|
||||
|
||||
type ProductAnalytics struct {
|
||||
|
||||
@@ -11,7 +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"`
|
||||
VendorID uuid.UUID `gorm:"type:uuid;not null" json:"vendor_id" validate:"required"`
|
||||
VendorID *uuid.UUID `gorm:"type:uuid" json:"vendor_id" validate:"omitempty"`
|
||||
PONumber string `gorm:"not null;size:50" json:"po_number" validate:"required,min=1,max=50"`
|
||||
TransactionDate time.Time `gorm:"type:date;not null" json:"transaction_date" validate:"required"`
|
||||
DueDate *time.Time `gorm:"type:date" json:"due_date" validate:"omitempty"`
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -150,12 +150,12 @@ type PurchasingIngredientData struct {
|
||||
|
||||
// PurchasingVendorData represents purchasing analytics for a vendor
|
||||
type PurchasingVendorData struct {
|
||||
VendorID uuid.UUID `json:"vendor_id"`
|
||||
VendorName string `json:"vendor_name"`
|
||||
TotalCost float64 `json:"total_cost"`
|
||||
PurchaseOrderCount int64 `json:"purchase_order_count"`
|
||||
IngredientCount int64 `json:"ingredient_count"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
VendorID *uuid.UUID `json:"vendor_id"`
|
||||
VendorName string `json:"vendor_name"`
|
||||
TotalCost float64 `json:"total_cost"`
|
||||
PurchaseOrderCount int64 `json:"purchase_order_count"`
|
||||
IngredientCount int64 `json:"ingredient_count"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
}
|
||||
|
||||
// ProductAnalyticsRequest represents the request for product analytics
|
||||
@@ -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"`
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
type PurchaseOrder struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
VendorID uuid.UUID `json:"vendor_id"`
|
||||
VendorID *uuid.UUID `json:"vendor_id"`
|
||||
PONumber string `json:"po_number"`
|
||||
TransactionDate time.Time `json:"transaction_date"`
|
||||
DueDate *time.Time `json:"due_date"`
|
||||
@@ -44,7 +44,7 @@ type PurchaseOrderAttachment struct {
|
||||
type PurchaseOrderResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
VendorID uuid.UUID `json:"vendor_id"`
|
||||
VendorID *uuid.UUID `json:"vendor_id"`
|
||||
PONumber string `json:"po_number"`
|
||||
TransactionDate time.Time `json:"transaction_date"`
|
||||
DueDate *time.Time `json:"due_date"`
|
||||
@@ -84,7 +84,7 @@ type PurchaseOrderAttachmentResponse struct {
|
||||
}
|
||||
|
||||
type CreatePurchaseOrderRequest struct {
|
||||
VendorID uuid.UUID `json:"vendor_id"`
|
||||
VendorID *uuid.UUID `json:"vendor_id,omitempty"`
|
||||
PONumber string `json:"po_number"`
|
||||
TransactionDate time.Time `json:"transaction_date"`
|
||||
DueDate *time.Time `json:"due_date,omitempty"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -55,10 +55,12 @@ func NewPurchaseOrderProcessorImpl(
|
||||
}
|
||||
|
||||
func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, organizationID uuid.UUID, req *models.CreatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error) {
|
||||
// Check if vendor exists and belongs to organization
|
||||
_, err := p.vendorRepo.GetByIDAndOrganizationID(ctx, req.VendorID, organizationID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("vendor not found: %w", err)
|
||||
// Check if vendor exists and belongs to organization when provided.
|
||||
if req.VendorID != nil {
|
||||
_, err := p.vendorRepo.GetByIDAndOrganizationID(ctx, *req.VendorID, organizationID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("vendor not found: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if PO number already exists in organization
|
||||
@@ -107,7 +109,7 @@ func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, or
|
||||
// Calculate total amount
|
||||
totalAmount := 0.0
|
||||
for _, item := range req.Items {
|
||||
totalAmount += item.Amount
|
||||
totalAmount += calculatePurchaseOrderItemTotal(item.Quantity, item.Amount)
|
||||
}
|
||||
|
||||
// Create purchase order entity
|
||||
@@ -186,7 +188,7 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("vendor not found: %w", err)
|
||||
}
|
||||
poEntity.VendorID = *req.VendorID
|
||||
poEntity.VendorID = req.VendorID
|
||||
}
|
||||
|
||||
// Check if PO number already exists (if PO number is being updated)
|
||||
@@ -277,7 +279,7 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id
|
||||
UnitID: unitID,
|
||||
Amount: amount,
|
||||
}
|
||||
totalAmount += amount
|
||||
totalAmount += calculatePurchaseOrderItemTotal(quantity, amount)
|
||||
}
|
||||
|
||||
// Delete and recreate only after all replacement items are valid.
|
||||
@@ -447,7 +449,7 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte
|
||||
// Calculate unit cost in ingredient's base unit
|
||||
unitCost := 0.0
|
||||
if quantityToAdd > 0 {
|
||||
unitCost = item.Amount / quantityToAdd
|
||||
unitCost = calculatePurchaseOrderItemTotal(item.Quantity, item.Amount) / quantityToAdd
|
||||
}
|
||||
|
||||
// Create inventory movement for ingredient purchase
|
||||
@@ -506,3 +508,11 @@ func (p *PurchaseOrderProcessorImpl) validatePurchaseCategory(ctx context.Contex
|
||||
|
||||
return category, nil
|
||||
}
|
||||
|
||||
func calculatePurchaseOrderItemTotal(quantity *float64, amount float64) float64 {
|
||||
if quantity == nil {
|
||||
return amount
|
||||
}
|
||||
|
||||
return *quantity * amount
|
||||
}
|
||||
|
||||
@@ -162,7 +162,7 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex
|
||||
ELSE 0
|
||||
END as average_purchase_order_value,
|
||||
COUNT(DISTINCT i.id) as total_ingredients,
|
||||
COUNT(DISTINCT po.vendor_id) as total_vendors
|
||||
COUNT(DISTINCT COALESCE(po.vendor_id::text, 'no-vendor')) as total_vendors
|
||||
`).
|
||||
Joins("LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id").
|
||||
Joins("LEFT JOIN ingredients i ON poi.ingredient_id = i.id").
|
||||
@@ -197,7 +197,7 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex
|
||||
COUNT(DISTINCT po.id) as purchase_orders,
|
||||
COALESCE(SUM(poi.quantity), 0) as quantity,
|
||||
COUNT(DISTINCT i.id) as ingredients,
|
||||
COUNT(DISTINCT po.vendor_id) as vendors
|
||||
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 ingredients i ON poi.ingredient_id = i.id").
|
||||
@@ -247,20 +247,20 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex
|
||||
Table("purchase_orders po").
|
||||
Select(`
|
||||
v.id as vendor_id,
|
||||
v.name as vendor_name,
|
||||
COALESCE(v.name, 'No Vendor') as vendor_name,
|
||||
COALESCE(SUM(poi.amount), 0) as total_cost,
|
||||
COUNT(DISTINCT po.id) as purchase_order_count,
|
||||
COUNT(DISTINCT i.id) as ingredient_count,
|
||||
COALESCE(SUM(poi.quantity), 0) as quantity
|
||||
`).
|
||||
Joins("JOIN vendors v ON po.vendor_id = v.id").
|
||||
Joins("LEFT JOIN vendors v ON po.vendor_id = v.id").
|
||||
Joins("LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id").
|
||||
Joins("LEFT JOIN ingredients i ON poi.ingredient_id = i.id").
|
||||
Joins("LEFT JOIN units u ON poi.unit_id = u.id").
|
||||
Where("po.organization_id = ?", organizationID).
|
||||
Where("po.status != ?", "cancelled").
|
||||
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo).
|
||||
Group("v.id, v.name").
|
||||
Group("v.id, COALESCE(v.name, 'No Vendor')").
|
||||
Order("total_cost DESC")
|
||||
vendorQuery = r.applyPurchaseOrderItemOutletFilter(vendorQuery, outletID)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -12,12 +12,13 @@ import (
|
||||
)
|
||||
|
||||
func TestCreatePurchaseOrderRequestToModelAllowsMissingDueDate(t *testing.T) {
|
||||
vendorID := uuid.New()
|
||||
ingredientID := uuid.New()
|
||||
quantity := 1.0
|
||||
unitID := uuid.New()
|
||||
|
||||
result, err := CreatePurchaseOrderRequestToModel(&contract.CreatePurchaseOrderRequest{
|
||||
VendorID: uuid.New(),
|
||||
VendorID: &vendorID,
|
||||
PONumber: "PO-001",
|
||||
TransactionDate: "2026-05-29",
|
||||
Items: []contract.CreatePurchaseOrderItemRequest{
|
||||
@@ -35,9 +36,10 @@ func TestCreatePurchaseOrderRequestToModelAllowsMissingDueDate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPurchaseOrderModelResponseToResponseIncludesNullDueDate(t *testing.T) {
|
||||
vendorID := uuid.New()
|
||||
result := PurchaseOrderModelResponseToResponse(&models.PurchaseOrderResponse{
|
||||
ID: uuid.New(),
|
||||
VendorID: uuid.New(),
|
||||
VendorID: &vendorID,
|
||||
PONumber: "PO-001",
|
||||
})
|
||||
|
||||
@@ -45,3 +47,19 @@ func TestPurchaseOrderModelResponseToResponseIncludesNullDueDate(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, string(payload), `"due_date":null`)
|
||||
}
|
||||
|
||||
func TestCreatePurchaseOrderRequestToModelAllowsMissingVendor(t *testing.T) {
|
||||
result, err := CreatePurchaseOrderRequestToModel(&contract.CreatePurchaseOrderRequest{
|
||||
PONumber: "PO-001",
|
||||
TransactionDate: "2026-05-29",
|
||||
Items: []contract.CreatePurchaseOrderItemRequest{
|
||||
{
|
||||
PurchaseCategoryID: uuid.New(),
|
||||
Amount: 1000,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, result.VendorID)
|
||||
}
|
||||
|
||||
@@ -29,8 +29,8 @@ func (v *PurchaseOrderValidatorImpl) ValidateCreatePurchaseOrderRequest(req *con
|
||||
return errors.New("request body is required"), constants.MissingFieldErrorCode
|
||||
}
|
||||
|
||||
if req.VendorID == uuid.Nil {
|
||||
return errors.New("vendor_id is required"), constants.MissingFieldErrorCode
|
||||
if req.VendorID != nil && *req.VendorID == uuid.Nil {
|
||||
return errors.New("vendor_id cannot be empty"), constants.MalformedFieldErrorCode
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.PONumber) == "" {
|
||||
|
||||
@@ -11,12 +11,13 @@ import (
|
||||
)
|
||||
|
||||
func validCreatePurchaseOrderRequest() *contract.CreatePurchaseOrderRequest {
|
||||
vendorID := uuid.New()
|
||||
ingredientID := uuid.New()
|
||||
quantity := 1.0
|
||||
unitID := uuid.New()
|
||||
|
||||
return &contract.CreatePurchaseOrderRequest{
|
||||
VendorID: uuid.New(),
|
||||
VendorID: &vendorID,
|
||||
PONumber: "PO-001",
|
||||
TransactionDate: "2026-05-29",
|
||||
Items: []contract.CreatePurchaseOrderItemRequest{
|
||||
@@ -31,6 +32,30 @@ func validCreatePurchaseOrderRequest() *contract.CreatePurchaseOrderRequest {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPurchaseOrderValidatorCreateAllowsMissingVendor(t *testing.T) {
|
||||
validator := NewPurchaseOrderValidator()
|
||||
req := validCreatePurchaseOrderRequest()
|
||||
req.VendorID = nil
|
||||
|
||||
err, code := validator.ValidateCreatePurchaseOrderRequest(req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, code)
|
||||
}
|
||||
|
||||
func TestPurchaseOrderValidatorCreateRejectsEmptyVendor(t *testing.T) {
|
||||
validator := NewPurchaseOrderValidator()
|
||||
req := validCreatePurchaseOrderRequest()
|
||||
vendorID := uuid.Nil
|
||||
req.VendorID = &vendorID
|
||||
|
||||
err, code := validator.ValidateCreatePurchaseOrderRequest(req)
|
||||
|
||||
require.Error(t, err)
|
||||
require.Equal(t, constants.MalformedFieldErrorCode, code)
|
||||
require.Contains(t, err.Error(), "vendor_id cannot be empty")
|
||||
}
|
||||
|
||||
func TestPurchaseOrderValidatorCreateAllowsMissingDueDate(t *testing.T) {
|
||||
validator := NewPurchaseOrderValidator()
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE purchase_orders
|
||||
ALTER COLUMN vendor_id SET NOT NULL;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE purchase_orders
|
||||
ALTER COLUMN vendor_id DROP NOT NULL;
|
||||
Reference in New Issue
Block a user