From c1d859ebdd583335b98e3f5bc11daaf42aca453d Mon Sep 17 00:00:00 2001 From: ryan Date: Thu, 18 Jun 2026 11:04:59 +0700 Subject: [PATCH] Make vendor nullable --- internal/contract/analytics_contract.go | 12 ++++----- internal/contract/purchase_order_contract.go | 4 +-- internal/entities/analytics.go | 12 ++++----- internal/entities/purchase_order.go | 2 +- internal/models/analytics.go | 12 ++++----- internal/models/purchase_order.go | 6 ++--- .../processor/purchase_order_processor.go | 12 +++++---- internal/repository/analytics_repository.go | 10 +++---- .../purchase_order_transformer_test.go | 22 +++++++++++++-- .../validator/purchase_order_validator.go | 4 +-- .../purchase_order_validator_test.go | 27 ++++++++++++++++++- ...ke_purchase_order_vendor_nullable.down.sql | 2 ++ ...make_purchase_order_vendor_nullable.up.sql | 2 ++ 13 files changed, 88 insertions(+), 39 deletions(-) create mode 100644 migrations/000081_make_purchase_order_vendor_nullable.down.sql create mode 100644 migrations/000081_make_purchase_order_vendor_nullable.up.sql diff --git a/internal/contract/analytics_contract.go b/internal/contract/analytics_contract.go index 8e3c19d..15de705 100644 --- a/internal/contract/analytics_contract.go +++ b/internal/contract/analytics_contract.go @@ -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 diff --git a/internal/contract/purchase_order_contract.go b/internal/contract/purchase_order_contract.go index 907316a..b5ae3bb 100644 --- a/internal/contract/purchase_order_contract.go +++ b/internal/contract/purchase_order_contract.go @@ -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"` diff --git a/internal/entities/analytics.go b/internal/entities/analytics.go index a388fdf..5155cf2 100644 --- a/internal/entities/analytics.go +++ b/internal/entities/analytics.go @@ -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 { diff --git a/internal/entities/purchase_order.go b/internal/entities/purchase_order.go index b98006a..7146fb6 100644 --- a/internal/entities/purchase_order.go +++ b/internal/entities/purchase_order.go @@ -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"` diff --git a/internal/models/analytics.go b/internal/models/analytics.go index 170c048..e5d3a0f 100644 --- a/internal/models/analytics.go +++ b/internal/models/analytics.go @@ -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 diff --git a/internal/models/purchase_order.go b/internal/models/purchase_order.go index 562271e..d3428e2 100644 --- a/internal/models/purchase_order.go +++ b/internal/models/purchase_order.go @@ -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"` diff --git a/internal/processor/purchase_order_processor.go b/internal/processor/purchase_order_processor.go index 169ffe9..8782d00 100644 --- a/internal/processor/purchase_order_processor.go +++ b/internal/processor/purchase_order_processor.go @@ -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 @@ -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) diff --git a/internal/repository/analytics_repository.go b/internal/repository/analytics_repository.go index a402d4f..b51f0d3 100644 --- a/internal/repository/analytics_repository.go +++ b/internal/repository/analytics_repository.go @@ -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) diff --git a/internal/transformer/purchase_order_transformer_test.go b/internal/transformer/purchase_order_transformer_test.go index 4f481a6..7d509c9 100644 --- a/internal/transformer/purchase_order_transformer_test.go +++ b/internal/transformer/purchase_order_transformer_test.go @@ -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) +} diff --git a/internal/validator/purchase_order_validator.go b/internal/validator/purchase_order_validator.go index 1b67023..b578a94 100644 --- a/internal/validator/purchase_order_validator.go +++ b/internal/validator/purchase_order_validator.go @@ -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) == "" { diff --git a/internal/validator/purchase_order_validator_test.go b/internal/validator/purchase_order_validator_test.go index 4c37b90..d7e146d 100644 --- a/internal/validator/purchase_order_validator_test.go +++ b/internal/validator/purchase_order_validator_test.go @@ -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() diff --git a/migrations/000081_make_purchase_order_vendor_nullable.down.sql b/migrations/000081_make_purchase_order_vendor_nullable.down.sql new file mode 100644 index 0000000..3d0e5f5 --- /dev/null +++ b/migrations/000081_make_purchase_order_vendor_nullable.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE purchase_orders + ALTER COLUMN vendor_id SET NOT NULL; diff --git a/migrations/000081_make_purchase_order_vendor_nullable.up.sql b/migrations/000081_make_purchase_order_vendor_nullable.up.sql new file mode 100644 index 0000000..4c85200 --- /dev/null +++ b/migrations/000081_make_purchase_order_vendor_nullable.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE purchase_orders + ALTER COLUMN vendor_id DROP NOT NULL;