From 657a201fc0367a9e8b38de99fe90c5e6ba2d6059 Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 15 Jun 2026 13:52:08 +0700 Subject: [PATCH] Revert purchase order --- internal/contract/purchase_order_contract.go | 18 +-- internal/entities/purchase_order.go | 20 +-- internal/models/purchase_order.go | 38 +++--- .../processor/purchase_order_processor.go | 128 ++++++------------ internal/repository/analytics_repository.go | 34 +++-- .../purchase_order_transformer_test.go | 10 +- .../validator/purchase_order_validator.go | 24 ++-- .../purchase_order_validator_test.go | 22 ++- ...raw_material_purchase_order_items.down.sql | 7 + ...e_raw_material_purchase_order_items.up.sql | 41 ++++++ 10 files changed, 185 insertions(+), 157 deletions(-) create mode 100644 migrations/000081_enforce_raw_material_purchase_order_items.down.sql create mode 100644 migrations/000081_enforce_raw_material_purchase_order_items.up.sql diff --git a/internal/contract/purchase_order_contract.go b/internal/contract/purchase_order_contract.go index 907316a..70e722c 100644 --- a/internal/contract/purchase_order_contract.go +++ b/internal/contract/purchase_order_contract.go @@ -19,12 +19,12 @@ type CreatePurchaseOrderRequest struct { } type CreatePurchaseOrderItemRequest struct { - IngredientID *uuid.UUID `json:"ingredient_id,omitempty" validate:"omitempty"` - PurchaseCategoryID uuid.UUID `json:"purchase_category_id" validate:"required"` - Description *string `json:"description,omitempty" validate:"omitempty"` - Quantity *float64 `json:"quantity,omitempty" validate:"omitempty,gt=0"` - UnitID *uuid.UUID `json:"unit_id,omitempty" validate:"omitempty"` - Amount float64 `json:"amount" validate:"required,gte=0"` + IngredientID uuid.UUID `json:"ingredient_id" validate:"required"` + PurchaseCategoryID uuid.UUID `json:"purchase_category_id" validate:"required"` + Description *string `json:"description,omitempty" validate:"omitempty"` + Quantity float64 `json:"quantity" validate:"required,gt=0"` + UnitID uuid.UUID `json:"unit_id" validate:"required"` + Amount float64 `json:"amount" validate:"required,gte=0"` } type UpdatePurchaseOrderRequest struct { @@ -70,11 +70,11 @@ type PurchaseOrderResponse struct { type PurchaseOrderItemResponse struct { ID uuid.UUID `json:"id"` PurchaseOrderID uuid.UUID `json:"purchase_order_id"` - IngredientID *uuid.UUID `json:"ingredient_id"` + IngredientID uuid.UUID `json:"ingredient_id"` PurchaseCategoryID uuid.UUID `json:"purchase_category_id"` Description *string `json:"description"` - Quantity *float64 `json:"quantity"` - UnitID *uuid.UUID `json:"unit_id"` + Quantity float64 `json:"quantity"` + UnitID uuid.UUID `json:"unit_id"` Amount float64 `json:"amount"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` diff --git a/internal/entities/purchase_order.go b/internal/entities/purchase_order.go index b98006a..3a455db 100644 --- a/internal/entities/purchase_order.go +++ b/internal/entities/purchase_order.go @@ -41,16 +41,16 @@ func (PurchaseOrder) TableName() string { } type PurchaseOrderItem struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - PurchaseOrderID uuid.UUID `gorm:"type:uuid;not null" json:"purchase_order_id" validate:"required"` - IngredientID *uuid.UUID `gorm:"type:uuid" json:"ingredient_id" validate:"omitempty"` - PurchaseCategoryID uuid.UUID `gorm:"type:uuid;not null;index" json:"purchase_category_id" validate:"required"` - Description *string `gorm:"type:text" json:"description" validate:"omitempty"` - Quantity *float64 `gorm:"type:decimal(10,3)" json:"quantity" validate:"omitempty,gt=0"` - UnitID *uuid.UUID `gorm:"type:uuid" json:"unit_id" validate:"omitempty"` - Amount float64 `gorm:"type:decimal(15,2);not null" json:"amount" validate:"required,gte=0"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + PurchaseOrderID uuid.UUID `gorm:"type:uuid;not null" json:"purchase_order_id" validate:"required"` + IngredientID uuid.UUID `gorm:"type:uuid;not null" json:"ingredient_id" validate:"required"` + PurchaseCategoryID uuid.UUID `gorm:"type:uuid;not null;index" json:"purchase_category_id" validate:"required"` + Description *string `gorm:"type:text" json:"description" validate:"omitempty"` + Quantity float64 `gorm:"type:decimal(10,3);not null" json:"quantity" validate:"required,gt=0"` + UnitID uuid.UUID `gorm:"type:uuid;not null" json:"unit_id" validate:"required"` + Amount float64 `gorm:"type:decimal(15,2);not null" json:"amount" validate:"required,gte=0"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` PurchaseOrder *PurchaseOrder `gorm:"foreignKey:PurchaseOrderID" json:"purchase_order,omitempty"` Ingredient *Ingredient `gorm:"foreignKey:IngredientID" json:"ingredient,omitempty"` diff --git a/internal/models/purchase_order.go b/internal/models/purchase_order.go index 562271e..1afa23f 100644 --- a/internal/models/purchase_order.go +++ b/internal/models/purchase_order.go @@ -22,16 +22,16 @@ type PurchaseOrder struct { } type PurchaseOrderItem struct { - ID uuid.UUID `json:"id"` - PurchaseOrderID uuid.UUID `json:"purchase_order_id"` - IngredientID *uuid.UUID `json:"ingredient_id"` - PurchaseCategoryID uuid.UUID `json:"purchase_category_id"` - Description *string `json:"description"` - Quantity *float64 `json:"quantity"` - UnitID *uuid.UUID `json:"unit_id"` - Amount float64 `json:"amount"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uuid.UUID `json:"id"` + PurchaseOrderID uuid.UUID `json:"purchase_order_id"` + IngredientID uuid.UUID `json:"ingredient_id"` + PurchaseCategoryID uuid.UUID `json:"purchase_category_id"` + Description *string `json:"description"` + Quantity float64 `json:"quantity"` + UnitID uuid.UUID `json:"unit_id"` + Amount float64 `json:"amount"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type PurchaseOrderAttachment struct { @@ -62,11 +62,11 @@ type PurchaseOrderResponse struct { type PurchaseOrderItemResponse struct { ID uuid.UUID `json:"id"` PurchaseOrderID uuid.UUID `json:"purchase_order_id"` - IngredientID *uuid.UUID `json:"ingredient_id"` + IngredientID uuid.UUID `json:"ingredient_id"` PurchaseCategoryID uuid.UUID `json:"purchase_category_id"` Description *string `json:"description"` - Quantity *float64 `json:"quantity"` - UnitID *uuid.UUID `json:"unit_id"` + Quantity float64 `json:"quantity"` + UnitID uuid.UUID `json:"unit_id"` Amount float64 `json:"amount"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -96,12 +96,12 @@ type CreatePurchaseOrderRequest struct { } type CreatePurchaseOrderItemRequest struct { - IngredientID *uuid.UUID `json:"ingredient_id,omitempty"` - PurchaseCategoryID uuid.UUID `json:"purchase_category_id"` - Description *string `json:"description,omitempty"` - Quantity *float64 `json:"quantity,omitempty"` - UnitID *uuid.UUID `json:"unit_id,omitempty"` - Amount float64 `json:"amount"` + IngredientID uuid.UUID `json:"ingredient_id"` + PurchaseCategoryID uuid.UUID `json:"purchase_category_id"` + Description *string `json:"description,omitempty"` + Quantity float64 `json:"quantity"` + UnitID uuid.UUID `json:"unit_id"` + Amount float64 `json:"amount"` } type UpdatePurchaseOrderRequest struct { diff --git a/internal/processor/purchase_order_processor.go b/internal/processor/purchase_order_processor.go index 7d87e90..ca0819e 100644 --- a/internal/processor/purchase_order_processor.go +++ b/internal/processor/purchase_order_processor.go @@ -67,40 +67,20 @@ func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, or return nil, fmt.Errorf("purchase order with PO number %s already exists in this organization", req.PONumber) } - // Validate categories and inventory fields per item type. + // Purchase orders are raw-material only because they affect ingredient stock. for i, item := range req.Items { - category, err := p.validatePurchaseCategory(ctx, item.PurchaseCategoryID, organizationID, i) - if err != nil { + if err := p.validateRawMaterialPurchaseCategory(ctx, item.PurchaseCategoryID, organizationID, i); err != nil { return nil, err } - switch category.Type { - case entities.PurchaseCategoryTypeRawMaterial: - if item.IngredientID == nil { - return nil, fmt.Errorf("ingredient_id is required for raw_material item %d", i) - } - if item.Quantity == nil { - return nil, fmt.Errorf("quantity is required for raw_material item %d", i) - } - if item.UnitID == nil { - return nil, fmt.Errorf("unit_id is required for raw_material item %d", i) - } + _, err := p.ingredientRepo.GetByID(ctx, item.IngredientID, organizationID) + if err != nil { + return nil, fmt.Errorf("ingredient not found for item %d: %w", i, err) + } - _, err := p.ingredientRepo.GetByID(ctx, *item.IngredientID, organizationID) - if err != nil { - return nil, fmt.Errorf("ingredient not found for item %d: %w", i, err) - } - - _, err = p.unitRepo.GetByID(ctx, *item.UnitID, organizationID) - if err != nil { - return nil, fmt.Errorf("unit not found for item %d: %w", i, err) - } - case entities.PurchaseCategoryTypeExpense: - if item.IngredientID != nil || item.Quantity != nil || item.UnitID != nil { - return nil, fmt.Errorf("ingredient_id, quantity, and unit_id must be empty for expense item %d", i) - } - default: - return nil, fmt.Errorf("purchase category for item %d has unsupported type %s", i, category.Type) + _, err = p.unitRepo.GetByID(ctx, item.UnitID, organizationID) + if err != nil { + return nil, fmt.Errorf("unit not found for item %d: %w", i, err) } } @@ -224,48 +204,38 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id return nil, fmt.Errorf("purchase_category_id is required for item %d", i) } - ingredientID := itemReq.IngredientID + if itemReq.IngredientID == nil { + return nil, fmt.Errorf("ingredient_id is required for raw_material item %d", i) + } + if itemReq.Quantity == nil { + return nil, fmt.Errorf("quantity is required for raw_material item %d", i) + } + if itemReq.UnitID == nil { + return nil, fmt.Errorf("unit_id is required for raw_material item %d", i) + } + + ingredientID := *itemReq.IngredientID purchaseCategoryID := *itemReq.PurchaseCategoryID - unitID := itemReq.UnitID - quantity := itemReq.Quantity + unitID := *itemReq.UnitID + quantity := *itemReq.Quantity amount := 0.0 if itemReq.Amount != nil { amount = *itemReq.Amount } description := itemReq.Description - category, err := p.validatePurchaseCategory(ctx, purchaseCategoryID, organizationID, i) - if err != nil { + if err := p.validateRawMaterialPurchaseCategory(ctx, purchaseCategoryID, organizationID, i); err != nil { return nil, err } - switch category.Type { - case entities.PurchaseCategoryTypeRawMaterial: - if ingredientID == nil { - return nil, fmt.Errorf("ingredient_id is required for raw_material item %d", i) - } - if quantity == nil { - return nil, fmt.Errorf("quantity is required for raw_material item %d", i) - } - if unitID == nil { - return nil, fmt.Errorf("unit_id is required for raw_material item %d", i) - } + _, err := p.ingredientRepo.GetByID(ctx, ingredientID, organizationID) + if err != nil { + return nil, fmt.Errorf("ingredient not found: %w", err) + } - _, err := p.ingredientRepo.GetByID(ctx, *ingredientID, organizationID) - if err != nil { - return nil, fmt.Errorf("ingredient not found: %w", err) - } - - _, err = p.unitRepo.GetByID(ctx, *unitID, organizationID) - if err != nil { - return nil, fmt.Errorf("unit not found: %w", err) - } - case entities.PurchaseCategoryTypeExpense: - if ingredientID != nil || quantity != nil || unitID != nil { - return nil, fmt.Errorf("ingredient_id, quantity, and unit_id must be empty for expense item %d", i) - } - default: - return nil, fmt.Errorf("purchase category for item %d has unsupported type %s", i, category.Type) + _, err = p.unitRepo.GetByID(ctx, unitID, organizationID) + if err != nil { + return nil, fmt.Errorf("unit not found: %w", err) } items[i] = &entities.PurchaseOrderItem{ @@ -407,8 +377,6 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte return nil, fmt.Errorf("purchase order not found: %w", err) } - fmt.Println("status:", po.Status) - // Check if status is changing to "received" and current status is not "received" if status == "received" && po.Status != "received" { // Get purchase order with items for inventory update @@ -419,27 +387,19 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte // Update inventory for each item for _, item := range poWithItems.Items { - if item.PurchaseCategory != nil && item.PurchaseCategory.Type == entities.PurchaseCategoryTypeExpense { - continue - } - - if item.IngredientID == nil || item.UnitID == nil || item.Quantity == nil { - return nil, fmt.Errorf("purchase order item %s is missing raw material inventory fields", item.ID) - } - // Get ingredient to find its base unit - ingredient, err := p.ingredientRepo.GetByID(ctx, *item.IngredientID, organizationID) + ingredient, err := p.ingredientRepo.GetByID(ctx, item.IngredientID, organizationID) if err != nil { - return nil, fmt.Errorf("failed to get ingredient %s: %w", *item.IngredientID, err) + return nil, fmt.Errorf("failed to get ingredient %s: %w", item.IngredientID, err) } // Convert quantity to ingredient's base unit if needed - quantityToAdd := *item.Quantity - if *item.UnitID != ingredient.UnitID { + quantityToAdd := item.Quantity + if item.UnitID != ingredient.UnitID { // Convert from purchase unit to ingredient's base unit - convertedQuantity, err := p.unitConverterRepo.ConvertQuantity(ctx, *item.IngredientID, *item.UnitID, ingredient.UnitID, organizationID, *item.Quantity) + convertedQuantity, err := p.unitConverterRepo.ConvertQuantity(ctx, item.IngredientID, item.UnitID, ingredient.UnitID, organizationID, item.Quantity) if err != nil { - return nil, fmt.Errorf("failed to convert quantity for ingredient %s from unit %s to %s: %w", *item.IngredientID, *item.UnitID, ingredient.UnitID, err) + return nil, fmt.Errorf("failed to convert quantity for ingredient %s from unit %s to %s: %w", item.IngredientID, item.UnitID, ingredient.UnitID, err) } quantityToAdd = convertedQuantity } @@ -457,7 +417,7 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte err = p.inventoryMovementService.CreateIngredientMovement( ctx, - *item.IngredientID, + item.IngredientID, organizationID, outletID, userID, @@ -470,7 +430,7 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte &item.ID, ) if err != nil { - return nil, fmt.Errorf("failed to create inventory movement for ingredient %s: %w", *item.IngredientID, err) + return nil, fmt.Errorf("failed to create inventory movement for ingredient %s: %w", item.IngredientID, err) } } } @@ -490,19 +450,19 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte return mappers.PurchaseOrderEntityToResponse(updatedPO), nil } -func (p *PurchaseOrderProcessorImpl) validatePurchaseCategory(ctx context.Context, categoryID, organizationID uuid.UUID, itemIndex int) (*entities.PurchaseCategory, error) { +func (p *PurchaseOrderProcessorImpl) validateRawMaterialPurchaseCategory(ctx context.Context, categoryID, organizationID uuid.UUID, itemIndex int) error { category, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, categoryID, organizationID) if err != nil { - return nil, fmt.Errorf("purchase category not found for item %d: %w", itemIndex, err) + return fmt.Errorf("purchase category not found for item %d: %w", itemIndex, err) } if !category.IsActive { - return nil, fmt.Errorf("purchase category for item %d is inactive", itemIndex) + return fmt.Errorf("purchase category for item %d is inactive", itemIndex) } - if category.Type != entities.PurchaseCategoryTypeRawMaterial && category.Type != entities.PurchaseCategoryTypeExpense { - return nil, fmt.Errorf("purchase category for item %d must be raw_material or expense", itemIndex) + if category.Type != entities.PurchaseCategoryTypeRawMaterial { + return fmt.Errorf("purchase category for item %d must be raw_material", itemIndex) } - return category, nil + return nil } diff --git a/internal/repository/analytics_repository.go b/internal/repository/analytics_repository.go index 9667788..0ace0cb 100644 --- a/internal/repository/analytics_repository.go +++ b/internal/repository/analytics_repository.go @@ -152,7 +152,11 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex Table("purchase_orders po"). Select(` COALESCE(SUM(poi.amount), 0) as total_purchases, + COALESCE(SUM(poi.amount), 0) as raw_material_purchases, + 0 as expense_purchases, COUNT(DISTINCT po.id) as total_purchase_orders, + COUNT(DISTINCT po.id) as raw_material_purchase_orders, + 0 as expense_count, COALESCE(SUM(poi.quantity), 0) as total_quantity, CASE WHEN COUNT(DISTINCT po.id) > 0 @@ -162,10 +166,12 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex COUNT(DISTINCT i.id) as total_ingredients, COUNT(DISTINCT po.vendor_id) 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"). - Joins("LEFT JOIN units u ON poi.unit_id = u.id"). + Joins("JOIN purchase_order_items poi 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("JOIN units u ON poi.unit_id = u.id"). Where("po.organization_id = ?", organizationID). + Where("pc.type = ?", entities.PurchaseCategoryTypeRawMaterial). Where("po.status != ?", "cancelled"). Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo) summaryQuery = r.applyPurchaseOrderItemOutletFilter(summaryQuery, outletID) @@ -192,15 +198,21 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex Select(` `+dateFormat+` as date, COALESCE(SUM(poi.amount), 0) as purchases, + COALESCE(SUM(poi.amount), 0) as raw_material_purchases, + 0 as expense_purchases, COUNT(DISTINCT po.id) as purchase_orders, + COUNT(DISTINCT po.id) as raw_material_purchase_orders, + 0 as expense_count, COALESCE(SUM(poi.quantity), 0) as quantity, COUNT(DISTINCT i.id) as ingredients, COUNT(DISTINCT po.vendor_id) 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"). - Joins("LEFT JOIN units u ON poi.unit_id = u.id"). + Joins("JOIN purchase_order_items poi 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("JOIN units u ON poi.unit_id = u.id"). Where("po.organization_id = ?", organizationID). + Where("pc.type = ?", entities.PurchaseCategoryTypeRawMaterial). Where("po.status != ?", "cancelled"). Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo). Group(dateFormat). @@ -227,9 +239,11 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex 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). + Where("pc.type = ?", entities.PurchaseCategoryTypeRawMaterial). Where("po.status != ?", "cancelled"). Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo). Group("i.id, i.name"). @@ -252,10 +266,12 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex COALESCE(SUM(poi.quantity), 0) as quantity `). Joins("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"). + Joins("JOIN purchase_order_items poi 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("JOIN units u ON poi.unit_id = u.id"). Where("po.organization_id = ?", organizationID). + Where("pc.type = ?", entities.PurchaseCategoryTypeRawMaterial). Where("po.status != ?", "cancelled"). Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo). Group("v.id, v.name"). diff --git a/internal/transformer/purchase_order_transformer_test.go b/internal/transformer/purchase_order_transformer_test.go index 4f481a6..24aea4c 100644 --- a/internal/transformer/purchase_order_transformer_test.go +++ b/internal/transformer/purchase_order_transformer_test.go @@ -12,19 +12,15 @@ import ( ) func TestCreatePurchaseOrderRequestToModelAllowsMissingDueDate(t *testing.T) { - ingredientID := uuid.New() - quantity := 1.0 - unitID := uuid.New() - result, err := CreatePurchaseOrderRequestToModel(&contract.CreatePurchaseOrderRequest{ VendorID: uuid.New(), PONumber: "PO-001", TransactionDate: "2026-05-29", Items: []contract.CreatePurchaseOrderItemRequest{ { - IngredientID: &ingredientID, - Quantity: &quantity, - UnitID: &unitID, + IngredientID: uuid.New(), + Quantity: 1, + UnitID: uuid.New(), Amount: 1000, }, }, diff --git a/internal/validator/purchase_order_validator.go b/internal/validator/purchase_order_validator.go index 1b67023..a62a7c7 100644 --- a/internal/validator/purchase_order_validator.go +++ b/internal/validator/purchase_order_validator.go @@ -181,20 +181,20 @@ func (v *PurchaseOrderValidatorImpl) ValidateListPurchaseOrdersRequest(req *cont } func (v *PurchaseOrderValidatorImpl) validatePurchaseOrderItem(item *contract.CreatePurchaseOrderItemRequest, index int) (error, string) { + if item.IngredientID == uuid.Nil { + return errors.New("items[" + strconv.Itoa(index) + "].ingredient_id is required"), constants.MissingFieldErrorCode + } + if item.PurchaseCategoryID == uuid.Nil { return errors.New("items[" + strconv.Itoa(index) + "].purchase_category_id is required"), constants.MissingFieldErrorCode } - if item.IngredientID != nil && *item.IngredientID == uuid.Nil { - return errors.New("items[" + strconv.Itoa(index) + "].ingredient_id cannot be empty"), constants.MalformedFieldErrorCode - } - - if item.Quantity != nil && *item.Quantity <= 0 { + if item.Quantity <= 0 { return errors.New("items[" + strconv.Itoa(index) + "].quantity must be greater than 0"), constants.MalformedFieldErrorCode } - if item.UnitID != nil && *item.UnitID == uuid.Nil { - return errors.New("items[" + strconv.Itoa(index) + "].unit_id cannot be empty"), constants.MalformedFieldErrorCode + if item.UnitID == uuid.Nil { + return errors.New("items[" + strconv.Itoa(index) + "].unit_id is required"), constants.MissingFieldErrorCode } if item.Amount < 0 { @@ -209,15 +209,15 @@ func (v *PurchaseOrderValidatorImpl) validateUpdatePurchaseOrderItem(item *contr return errors.New("items[" + strconv.Itoa(index) + "].purchase_category_id is required"), constants.MissingFieldErrorCode } - if item.IngredientID != nil && *item.IngredientID == uuid.Nil { - return errors.New("items[" + strconv.Itoa(index) + "].ingredient_id cannot be empty"), constants.MalformedFieldErrorCode + if item.IngredientID == nil || *item.IngredientID == uuid.Nil { + return errors.New("items[" + strconv.Itoa(index) + "].ingredient_id is required"), constants.MissingFieldErrorCode } - if item.UnitID != nil && *item.UnitID == uuid.Nil { - return errors.New("items[" + strconv.Itoa(index) + "].unit_id cannot be empty"), constants.MalformedFieldErrorCode + if item.UnitID == nil || *item.UnitID == uuid.Nil { + return errors.New("items[" + strconv.Itoa(index) + "].unit_id is required"), constants.MissingFieldErrorCode } - if item.Quantity != nil && *item.Quantity <= 0 { + if item.Quantity == nil || *item.Quantity <= 0 { return errors.New("items[" + strconv.Itoa(index) + "].quantity must be greater than 0"), constants.MalformedFieldErrorCode } diff --git a/internal/validator/purchase_order_validator_test.go b/internal/validator/purchase_order_validator_test.go index 4c37b90..c07f9a7 100644 --- a/internal/validator/purchase_order_validator_test.go +++ b/internal/validator/purchase_order_validator_test.go @@ -11,26 +11,34 @@ import ( ) func validCreatePurchaseOrderRequest() *contract.CreatePurchaseOrderRequest { - ingredientID := uuid.New() - quantity := 1.0 - unitID := uuid.New() - return &contract.CreatePurchaseOrderRequest{ VendorID: uuid.New(), PONumber: "PO-001", TransactionDate: "2026-05-29", Items: []contract.CreatePurchaseOrderItemRequest{ { - IngredientID: &ingredientID, + IngredientID: uuid.New(), PurchaseCategoryID: uuid.New(), - Quantity: &quantity, - UnitID: &unitID, + Quantity: 1, + UnitID: uuid.New(), Amount: 1000, }, }, } } +func TestPurchaseOrderValidatorCreateRejectsMissingRawMaterialFields(t *testing.T) { + validator := NewPurchaseOrderValidator() + req := validCreatePurchaseOrderRequest() + req.Items[0].IngredientID = uuid.Nil + + err, code := validator.ValidateCreatePurchaseOrderRequest(req) + + require.Error(t, err) + require.Equal(t, constants.MissingFieldErrorCode, code) + require.Contains(t, err.Error(), "ingredient_id is required") +} + func TestPurchaseOrderValidatorCreateAllowsMissingDueDate(t *testing.T) { validator := NewPurchaseOrderValidator() diff --git a/migrations/000081_enforce_raw_material_purchase_order_items.down.sql b/migrations/000081_enforce_raw_material_purchase_order_items.down.sql new file mode 100644 index 0000000..e413239 --- /dev/null +++ b/migrations/000081_enforce_raw_material_purchase_order_items.down.sql @@ -0,0 +1,7 @@ +DROP TRIGGER IF EXISTS trigger_validate_purchase_order_item_raw_material ON purchase_order_items; +DROP FUNCTION IF EXISTS validate_purchase_order_item_raw_material(); + +ALTER TABLE purchase_order_items + ALTER COLUMN ingredient_id DROP NOT NULL, + ALTER COLUMN quantity DROP NOT NULL, + ALTER COLUMN unit_id DROP NOT NULL; diff --git a/migrations/000081_enforce_raw_material_purchase_order_items.up.sql b/migrations/000081_enforce_raw_material_purchase_order_items.up.sql new file mode 100644 index 0000000..b3fcae7 --- /dev/null +++ b/migrations/000081_enforce_raw_material_purchase_order_items.up.sql @@ -0,0 +1,41 @@ +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM purchase_order_items poi + JOIN purchase_categories pc ON pc.id = poi.purchase_category_id + WHERE pc.type <> 'raw_material' + OR poi.ingredient_id IS NULL + OR poi.quantity IS NULL + OR poi.unit_id IS NULL + ) THEN + RAISE EXCEPTION 'purchase_order_items contains non-raw-material or incomplete raw-material rows. Move expense rows to expenses and fill ingredient_id, quantity, and unit_id before running this migration.'; + END IF; +END $$; + +ALTER TABLE purchase_order_items + ALTER COLUMN ingredient_id SET NOT NULL, + ALTER COLUMN quantity SET NOT NULL, + ALTER COLUMN unit_id SET NOT NULL; + +CREATE OR REPLACE FUNCTION validate_purchase_order_item_raw_material() +RETURNS TRIGGER AS $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM purchase_categories pc + WHERE pc.id = NEW.purchase_category_id + AND pc.type = 'raw_material' + ) THEN + RAISE EXCEPTION 'purchase_order_items.purchase_category_id must reference a raw_material purchase category'; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trigger_validate_purchase_order_item_raw_material ON purchase_order_items; +CREATE TRIGGER trigger_validate_purchase_order_item_raw_material + BEFORE INSERT OR UPDATE OF purchase_category_id ON purchase_order_items + FOR EACH ROW + EXECUTE FUNCTION validate_purchase_order_item_raw_material();