Compare commits

...
Author SHA1 Message Date
efrilm 3977370079 fix: product price 2026-06-22 13:33:25 +07:00
ryan 2138b44c53 Update profit-loss 2026-06-18 19:51:43 +07:00
ryan 87540fa1b7 Update exclusive summary 2026-06-18 15:44:15 +07:00
ryan 66d4c9f0af Update purchase order with outlet id 2026-06-18 15:27:20 +07:00
ryan 55119b3e91 Add MTD 2026-06-18 14:19:45 +07:00
ryan c1d859ebdd Make vendor nullable 2026-06-18 11:04:59 +07:00
ryan 2ad9e2f85f Fix total_amount to use base price 2026-06-18 09:52:16 +07:00
ryan 8816e4addc Merge branch 'feature/expense-revisi' into feature/exclusive-summary 2026-06-17 18:36:46 +07:00
ryan 2921631ac3 Revert "Revert purchase order"
This reverts commit 657a201fc0.
2026-06-17 18:31:10 +07:00
ryan 6c19876a47 Fix issue 2026-06-15 17:44:25 +07:00
ryan b2db56f855 Add excusive-summary endpoint 2026-06-15 17:01:47 +07:00
ryan 8c4d9c69d0 Update analytic to support new categories change 2026-06-15 14:17:48 +07:00
ryan 657a201fc0 Revert purchase order 2026-06-15 13:52:08 +07:00
28 changed files with 725 additions and 68 deletions
+2 -1
View File
@@ -351,6 +351,8 @@ github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM=
github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
@@ -380,7 +382,6 @@ go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI=
go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
+8 -1
View File
@@ -140,7 +140,7 @@ type PurchasingIngredientData struct {
}
type PurchasingVendorData struct {
VendorID uuid.UUID `json:"vendor_id"`
VendorID *uuid.UUID `json:"vendor_id"`
VendorName string `json:"vendor_name"`
TotalCost float64 `json:"total_cost"`
PurchaseOrderCount int64 `json:"purchase_order_count"`
@@ -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"`
+3 -2
View File
@@ -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,8 @@ type UpdatePurchaseOrderItemRequest struct {
type PurchaseOrderResponse struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
VendorID uuid.UUID `json:"vendor_id"`
OutletID *uuid.UUID `json:"outlet_id"`
VendorID *uuid.UUID `json:"vendor_id"`
PONumber string `json:"po_number"`
TransactionDate time.Time `json:"transaction_date"`
DueDate *time.Time `json:"due_date"`
+1 -1
View File
@@ -72,7 +72,7 @@ type PurchasingIngredientData struct {
}
type PurchasingVendorData struct {
VendorID uuid.UUID `json:"vendor_id"`
VendorID *uuid.UUID `json:"vendor_id"`
VendorName string `json:"vendor_name"`
TotalCost float64 `json:"total_cost"`
PurchaseOrderCount int64 `json:"purchase_order_count"`
+3 -1
View File
@@ -11,7 +11,8 @@ 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"`
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"`
DueDate *time.Time `gorm:"type:date" json:"due_date" validate:"omitempty"`
@@ -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"`
+28
View File
@@ -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")
}
@@ -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,
+8 -1
View File
@@ -150,7 +150,7 @@ type PurchasingIngredientData struct {
// PurchasingVendorData represents purchasing analytics for a vendor
type PurchasingVendorData struct {
VendorID uuid.UUID `json:"vendor_id"`
VendorID *uuid.UUID `json:"vendor_id"`
VendorName string `json:"vendor_name"`
TotalCost float64 `json:"total_cost"`
PurchaseOrderCount int64 `json:"purchase_order_count"`
@@ -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"`
+6 -3
View File
@@ -9,7 +9,8 @@ import (
type PurchaseOrder struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
VendorID uuid.UUID `json:"vendor_id"`
OutletID *uuid.UUID `json:"outlet_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 +45,8 @@ type PurchaseOrderAttachment struct {
type PurchaseOrderResponse struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
VendorID uuid.UUID `json:"vendor_id"`
OutletID *uuid.UUID `json:"outlet_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 +86,8 @@ type PurchaseOrderAttachmentResponse struct {
}
type CreatePurchaseOrderRequest struct {
VendorID uuid.UUID `json:"vendor_id"`
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"`
+13
View File
@@ -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 {
+54 -1
View File
@@ -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)
}
+30 -11
View File
@@ -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,12 +54,14 @@ 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)
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)
if err != nil {
return nil, fmt.Errorf("vendor not found: %w", err)
}
}
// Check if PO number already exists in organization
existingPO, err := p.purchaseOrderRepo.GetByPONumber(ctx, req.PONumber, organizationID)
@@ -107,12 +109,13 @@ 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
poEntity := &entities.PurchaseOrder{
OrganizationID: organizationID,
OutletID: outletID,
VendorID: req.VendorID,
PONumber: req.PONumber,
TransactionDate: req.TransactionDate,
@@ -173,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 {
@@ -186,7 +192,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 +283,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 +453,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
@@ -476,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)
}
@@ -506,3 +517,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
}
@@ -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
+288 -22
View File
@@ -2,6 +2,7 @@ package repository
import (
"context"
"sort"
"time"
"apskel-pos-be/internal/entities"
@@ -39,6 +40,18 @@ 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 purchaseOrderRawMaterialAmountSQL() string {
return "CASE WHEN pc.type = '" + string(entities.PurchaseCategoryTypeRawMaterial) + "' THEN " + purchaseOrderItemTotalAmountSQL() + " ELSE 0 END"
}
func purchaseOrderExpenseAmountSQL() string {
return "CASE WHEN pc.type = '" + string(entities.PurchaseCategoryTypeExpense) + "' THEN " + purchaseOrderItemTotalAmountSQL() + " ELSE 0 END"
}
func (r *AnalyticsRepositoryImpl) GetPaymentMethodAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]*entities.PaymentMethodAnalytics, error) {
var results []*entities.PaymentMethodAnalytics
@@ -153,18 +166,23 @@ 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,
COALESCE(SUM(`+purchaseOrderRawMaterialAmountSQL()+`), 0) as raw_material_purchases,
COALESCE(SUM(`+purchaseOrderExpenseAmountSQL()+`), 0) as expense_purchases,
COUNT(DISTINCT po.id) as total_purchase_orders,
COUNT(DISTINCT CASE WHEN pc.type = '`+string(entities.PurchaseCategoryTypeRawMaterial)+`' THEN po.id END) as raw_material_purchase_orders,
COUNT(CASE WHEN pc.type = '`+string(entities.PurchaseCategoryTypeExpense)+`' THEN poi.id END) as expense_count,
COALESCE(SUM(poi.quantity), 0) as total_quantity,
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 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 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,17 +211,23 @@ 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,
COALESCE(SUM(`+purchaseOrderRawMaterialAmountSQL()+`), 0) as raw_material_purchases,
COALESCE(SUM(`+purchaseOrderExpenseAmountSQL()+`), 0) as expense_purchases,
COUNT(DISTINCT po.id) as purchase_orders,
COUNT(DISTINCT CASE WHEN pc.type = '`+string(entities.PurchaseCategoryTypeRawMaterial)+`' THEN po.id END) as raw_material_purchase_orders,
COUNT(CASE WHEN pc.type = '`+string(entities.PurchaseCategoryTypeExpense)+`' THEN poi.id END) as expense_count,
COALESCE(SUM(poi.quantity), 0) as quantity,
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 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("po.status != ?", "cancelled").
Where("pc.type = ?", entities.PurchaseCategoryTypeRawMaterial).
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo).
Group(dateFormat).
Order(dateFormat)
@@ -220,15 +244,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).
@@ -247,20 +272,21 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex
Table("purchase_orders po").
Select(`
v.id as vendor_id,
v.name as vendor_name,
COALESCE(SUM(poi.amount), 0) as total_cost,
COALESCE(v.name, 'No Vendor') as vendor_name,
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("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 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("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)
@@ -281,7 +307,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) {
@@ -292,6 +318,13 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organ
Select(`
p.id as product_id,
p.name as product_name,
p.sku as product_sku,
COALESCE(
NULLIF(pop.price, 0),
(SELECT price FROM product_outlet_prices WHERE product_id = p.id ORDER BY updated_at DESC LIMIT 1),
NULLIF(p.price, 0),
0
) as product_price,
c.id as category_id,
c.name as category_name,
c.order as category_order,
@@ -325,6 +358,7 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organ
Joins("JOIN products p ON oi.product_id = p.id").
Joins("JOIN categories c ON p.category_id = c.id").
Joins("JOIN orders o ON oi.order_id = o.id").
Joins("LEFT JOIN product_outlet_prices pop ON pop.product_id = p.id AND pop.outlet_id = o.outlet_id").
Joins("LEFT JOIN (?) mahpp ON mahpp.product_id = p.id",
r.db.Table("product_recipes pr2").
Select("pr2.product_id, SUM(pr2.quantity * (1 + COALESCE(pr2.waste_percentage, 0)/100.0) * COALESCE(ma.moving_avg_cost, ing.cost)) as hpp_per_unit").
@@ -350,7 +384,7 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organ
query = r.resolveOutletID(query, outletID, "o.outlet_id")
err := query.
Group("p.id, p.name, p.cost, c.id, c.name, c.order, mahpp.hpp_per_unit").
Group("p.id, p.name, p.sku, p.price, p.cost, pop.price, c.id, c.name, c.order, mahpp.hpp_per_unit").
Order("revenue DESC").
Limit(limit).
Scan(&results).Error
@@ -486,6 +520,11 @@ func (r *AnalyticsRepositoryImpl) GetProfitLossAnalytics(ctx context.Context, or
if err := summaryQuery.Scan(&summary).Error; err != nil {
return nil, err
}
periodHPP, err := r.getPurchaseOrderRawMaterialTotal(ctx, organizationID, outletID, dateFrom, dateTo)
if err != nil {
return nil, err
}
applyProfitLossSummaryCost(&summary, periodHPP)
var timeFormat string
switch groupBy {
@@ -533,6 +572,11 @@ func (r *AnalyticsRepositoryImpl) GetProfitLossAnalytics(ctx context.Context, or
if err := dataQuery.Scan(&data).Error; err != nil {
return nil, err
}
poCostData, err := r.getPurchaseOrderRawMaterialCostByPeriod(ctx, organizationID, outletID, dateFrom, dateTo, groupBy)
if err != nil {
return nil, err
}
data = mergeProfitLossDataWithPurchaseOrderCost(data, poCostData)
var productData []entities.ProductProfitData
productQuery := r.db.WithContext(ctx).
@@ -593,6 +637,11 @@ func (r *AnalyticsRepositoryImpl) GetProfitLossAnalytics(ctx context.Context, or
if err := todayQuery.Scan(&todayRC).Error; err != nil {
return nil, err
}
todayHPP, err := r.getPurchaseOrderRawMaterialTotal(ctx, organizationID, outletID, todayStart, todayEnd)
if err != nil {
return nil, err
}
todayRC.Cost = todayHPP
var mtdRC revenueCostResult
mtdQuery := r.db.WithContext(ctx).
@@ -610,21 +659,41 @@ func (r *AnalyticsRepositoryImpl) GetProfitLossAnalytics(ctx context.Context, or
if err := mtdQuery.Scan(&mtdRC).Error; err != nil {
return nil, err
}
mtdHPP, err := r.getPurchaseOrderRawMaterialTotal(ctx, organizationID, outletID, mtdStart, todayEnd)
if err != nil {
return nil, err
}
mtdRC.Cost = mtdHPP
todayExpenseByCategory, err := r.getExpenseByCategory(ctx, organizationID, outletID, todayStart, todayEnd)
if err != nil {
return nil, err
}
todayPOExpenseByCategory, err := r.getPurchaseOrderExpenseByCategory(ctx, organizationID, outletID, todayStart, todayEnd)
if err != nil {
return nil, err
}
todayExpenseByCategory = mergeExpenseCategoryTotals(todayExpenseByCategory, todayPOExpenseByCategory)
mtdExpenseByCategory, err := r.getExpenseByCategory(ctx, organizationID, outletID, mtdStart, todayEnd)
if err != nil {
return nil, err
}
mtdPOExpenseByCategory, err := r.getPurchaseOrderExpenseByCategory(ctx, organizationID, outletID, mtdStart, todayEnd)
if err != nil {
return nil, err
}
mtdExpenseByCategory = mergeExpenseCategoryTotals(mtdExpenseByCategory, mtdPOExpenseByCategory)
opsItems, err := r.getOperationalExpenseItems(ctx, organizationID, outletID, mtdStart, todayEnd)
if err != nil {
return nil, err
}
poOpsItems, err := r.getPurchaseOrderExpenseItems(ctx, organizationID, outletID, mtdStart, todayEnd)
if err != nil {
return nil, err
}
opsItems = mergeOperationalExpenseItems(opsItems, poOpsItems)
return &entities.ProfitLossAnalytics{
Summary: summary,
@@ -640,6 +709,200 @@ func (r *AnalyticsRepositoryImpl) GetProfitLossAnalytics(ctx context.Context, or
}, nil
}
func (r *AnalyticsRepositoryImpl) getPurchaseOrderRawMaterialTotal(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (float64, error) {
type totalResult struct {
Total float64
}
var result totalResult
query := r.db.WithContext(ctx).
Table("purchase_order_items poi").
Select(`COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as total`).
Joins("JOIN purchase_orders po ON poi.purchase_order_id = po.id").
Joins("JOIN purchase_categories pc ON poi.purchase_category_id = pc.id").
Where("po.organization_id = ?", organizationID).
Where("po.status = ?", "received").
Where("pc.type = ?", entities.PurchaseCategoryTypeRawMaterial).
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo)
query = r.applyPurchaseOrderItemOutletFilter(query, outletID)
if err := query.Scan(&result).Error; err != nil {
return 0, err
}
return result.Total, nil
}
func (r *AnalyticsRepositoryImpl) getPurchaseOrderRawMaterialCostByPeriod(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) ([]entities.ProfitLossData, error) {
var dateFormat string
switch groupBy {
case "hour":
dateFormat = "DATE_TRUNC('hour', po.transaction_date::timestamp)"
case "week":
dateFormat = "DATE_TRUNC('week', po.transaction_date::timestamp)"
case "month":
dateFormat = "DATE_TRUNC('month', po.transaction_date::timestamp)"
default:
dateFormat = "DATE_TRUNC('day', po.transaction_date::timestamp)"
}
var results []entities.ProfitLossData
query := r.db.WithContext(ctx).
Table("purchase_order_items poi").
Select(`
`+dateFormat+` as date,
COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as cost
`).
Joins("JOIN purchase_orders po ON poi.purchase_order_id = po.id").
Joins("JOIN purchase_categories pc ON poi.purchase_category_id = pc.id").
Where("po.organization_id = ?", organizationID).
Where("po.status = ?", "received").
Where("pc.type = ?", entities.PurchaseCategoryTypeRawMaterial).
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo).
Group(dateFormat).
Order(dateFormat)
query = r.applyPurchaseOrderItemOutletFilter(query, outletID)
if err := query.Scan(&results).Error; err != nil {
return nil, err
}
return results, nil
}
func applyProfitLossSummaryCost(summary *entities.ProfitLossSummary, cost float64) {
summary.TotalCost = cost
summary.GrossProfit = summary.TotalRevenue - cost
summary.GrossProfitMargin = ratio(summary.GrossProfit, summary.TotalRevenue)
summary.NetProfit = summary.TotalRevenue - cost - summary.TotalDiscount
summary.NetProfitMargin = ratio(summary.NetProfit, summary.TotalRevenue)
if summary.TotalOrders > 0 {
summary.AverageProfit = summary.NetProfit / float64(summary.TotalOrders)
} else {
summary.AverageProfit = 0
}
summary.ProfitabilityRatio = ratio(summary.GrossProfit, cost)
}
func mergeProfitLossDataWithPurchaseOrderCost(data, costs []entities.ProfitLossData) []entities.ProfitLossData {
indexByDate := make(map[time.Time]int, len(data))
for i, item := range data {
data[i].Cost = 0
indexByDate[item.Date] = i
}
for _, cost := range costs {
if i, ok := indexByDate[cost.Date]; ok {
data[i].Cost = cost.Cost
continue
}
indexByDate[cost.Date] = len(data)
data = append(data, entities.ProfitLossData{Date: cost.Date, Cost: cost.Cost})
}
for i := range data {
data[i].GrossProfit = data[i].Revenue - data[i].Cost
data[i].GrossProfitMargin = ratio(data[i].GrossProfit, data[i].Revenue)
data[i].NetProfit = data[i].Revenue - data[i].Cost - data[i].Discount
data[i].NetProfitMargin = ratio(data[i].NetProfit, data[i].Revenue)
}
sort.Slice(data, func(i, j int) bool {
return data[i].Date.Before(data[j].Date)
})
return data
}
func ratio(numerator, denominator float64) float64 {
if denominator == 0 {
return 0
}
return (numerator / denominator) * 100
}
func (r *AnalyticsRepositoryImpl) getPurchaseOrderExpenseByCategory(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]entities.ExpenseCategoryTotal, error) {
var results []entities.ExpenseCategoryTotal
query := r.db.WithContext(ctx).
Table("purchase_order_items poi").
Select(`
pc.name as category_name,
COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as amount
`).
Joins("JOIN purchase_orders po ON poi.purchase_order_id = po.id").
Joins("JOIN purchase_categories pc ON poi.purchase_category_id = pc.id").
Where("po.organization_id = ?", organizationID).
Where("po.status = ?", "received").
Where("pc.type = ?", entities.PurchaseCategoryTypeExpense).
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo).
Group("pc.id, pc.name, pc.sort_order").
Order("pc.sort_order ASC, pc.name ASC")
query = r.applyPurchaseOrderItemOutletFilter(query, outletID)
if err := query.Scan(&results).Error; err != nil {
return nil, err
}
return results, nil
}
func (r *AnalyticsRepositoryImpl) getPurchaseOrderExpenseItems(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]entities.OperationalExpenseItem, error) {
var results []entities.OperationalExpenseItem
query := r.db.WithContext(ctx).
Table("purchase_order_items poi").
Select(`
COALESCE(NULLIF(poi.description, ''), pc.name) as item,
COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as amount
`).
Joins("JOIN purchase_orders po ON poi.purchase_order_id = po.id").
Joins("JOIN purchase_categories pc ON poi.purchase_category_id = pc.id").
Where("po.organization_id = ?", organizationID).
Where("po.status = ?", "received").
Where("pc.type = ?", entities.PurchaseCategoryTypeExpense).
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo).
Group("COALESCE(NULLIF(poi.description, ''), pc.name)").
Order("amount DESC")
query = r.applyPurchaseOrderItemOutletFilter(query, outletID)
if err := query.Scan(&results).Error; err != nil {
return nil, err
}
return results, nil
}
func mergeExpenseCategoryTotals(base, extra []entities.ExpenseCategoryTotal) []entities.ExpenseCategoryTotal {
indexByName := make(map[string]int, len(base))
for i, item := range base {
indexByName[item.CategoryName] = i
}
for _, item := range extra {
if i, ok := indexByName[item.CategoryName]; ok {
base[i].Amount += item.Amount
continue
}
indexByName[item.CategoryName] = len(base)
base = append(base, item)
}
return base
}
func mergeOperationalExpenseItems(base, extra []entities.OperationalExpenseItem) []entities.OperationalExpenseItem {
indexByName := make(map[string]int, len(base))
for i, item := range base {
indexByName[item.Item] = i
}
for _, item := range extra {
if i, ok := indexByName[item.Item]; ok {
base[i].Amount += item.Amount
continue
}
indexByName[item.Item] = len(base)
base = append(base, item)
}
return base
}
func (r *AnalyticsRepositoryImpl) getExpenseByCategory(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]entities.ExpenseCategoryTotal, error) {
var results []entities.ExpenseCategoryTotal
@@ -717,7 +980,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
}
@@ -750,7 +1013,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").
@@ -770,22 +1033,25 @@ 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,
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").
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 +1096,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 := `
@@ -840,7 +1106,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
@@ -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{}).
+1
View File
@@ -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")
+30
View File
@@ -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)
})
}
}
+12 -2
View File
@@ -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})
@@ -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 {
@@ -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,
@@ -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;
@@ -0,0 +1,4 @@
DROP INDEX IF EXISTS idx_purchase_orders_outlet_id;
ALTER TABLE purchase_orders
DROP COLUMN IF EXISTS outlet_id;
@@ -0,0 +1,66 @@
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;
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;