From 0726fcecf0e6284efb4ea00e2ac9226c6db21d3d Mon Sep 17 00:00:00 2001 From: efrilm Date: Tue, 11 Aug 2026 21:20:17 +0700 Subject: [PATCH] feat(ingredients): make units nullable --- .../ingredient_unit_converter_contract.go | 2 +- internal/contract/product_recipe_contract.go | 2 +- internal/entities/ingredient.go | 2 +- internal/models/ingredient.go | 8 ++-- internal/models/ingredient_unit_converter.go | 2 +- internal/processor/ingredient_processor.go | 11 ++++-- .../ingredient_unit_converter_processor.go | 38 ++++++++++--------- .../order_ingredient_transaction_processor.go | 4 +- .../processor/purchase_order_processor.go | 10 +++-- ...make_ingredients_unit_id_nullable.down.sql | 6 +++ ...6_make_ingredients_unit_id_nullable.up.sql | 5 +++ 11 files changed, 54 insertions(+), 36 deletions(-) create mode 100644 migrations/000086_make_ingredients_unit_id_nullable.down.sql create mode 100644 migrations/000086_make_ingredients_unit_id_nullable.up.sql diff --git a/internal/contract/ingredient_unit_converter_contract.go b/internal/contract/ingredient_unit_converter_contract.go index 9741814..9c42d6e 100644 --- a/internal/contract/ingredient_unit_converter_contract.go +++ b/internal/contract/ingredient_unit_converter_contract.go @@ -77,7 +77,7 @@ type ListIngredientUnitConvertersResponse struct { type IngredientUnitsResponse struct { IngredientID uuid.UUID `json:"ingredient_id"` IngredientName string `json:"ingredient_name"` - BaseUnitID uuid.UUID `json:"base_unit_id"` + BaseUnitID *uuid.UUID `json:"base_unit_id"` BaseUnitName string `json:"base_unit_name"` Units []*UnitResponse `json:"units"` } diff --git a/internal/contract/product_recipe_contract.go b/internal/contract/product_recipe_contract.go index cc73de2..8806601 100644 --- a/internal/contract/product_recipe_contract.go +++ b/internal/contract/product_recipe_contract.go @@ -54,7 +54,7 @@ type ProductRecipeIngredientResponse struct { OrganizationID uuid.UUID `json:"organization_id"` OutletID *uuid.UUID `json:"outlet_id"` Name string `json:"name"` - UnitID uuid.UUID `json:"unit_id"` + UnitID *uuid.UUID `json:"unit_id"` Cost float64 `json:"cost"` Stock float64 `json:"stock"` IsSemiFinished bool `json:"is_semi_finished"` diff --git a/internal/entities/ingredient.go b/internal/entities/ingredient.go index d8e7f91..4db67ca 100644 --- a/internal/entities/ingredient.go +++ b/internal/entities/ingredient.go @@ -11,7 +11,7 @@ type Ingredient struct { OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id"` OutletID *uuid.UUID `gorm:"type:uuid;index" json:"outlet_id"` Name string `gorm:"not null;size:255" json:"name"` - UnitID uuid.UUID `gorm:"type:uuid;not null;index" json:"unit_id"` + UnitID *uuid.UUID `gorm:"type:uuid;index" json:"unit_id"` Cost float64 `gorm:"type:decimal(10,2);default:0.00" json:"cost"` Stock float64 `gorm:"type:decimal(10,2);default:0.00" json:"stock"` IsSemiFinished bool `gorm:"default:false" json:"is_semi_finished"` diff --git a/internal/models/ingredient.go b/internal/models/ingredient.go index 7a3ac3d..e4293e6 100644 --- a/internal/models/ingredient.go +++ b/internal/models/ingredient.go @@ -12,7 +12,7 @@ type Ingredient struct { OrganizationID uuid.UUID `json:"organization_id"` OutletID *uuid.UUID `json:"outlet_id"` Name string `json:"name"` - UnitID uuid.UUID `json:"unit_id"` + UnitID *uuid.UUID `json:"unit_id"` Cost float64 `json:"cost"` Stock float64 `json:"stock"` IsSemiFinished bool `json:"is_semi_finished"` @@ -29,7 +29,7 @@ type CreateIngredientRequest struct { OrganizationID uuid.UUID `json:"organization_id"` OutletID *uuid.UUID `json:"outlet_id"` Name string `json:"name" validate:"required,min=1,max=255"` - UnitID uuid.UUID `json:"unit_id" validate:"required"` + UnitID *uuid.UUID `json:"unit_id" validate:"omitempty"` Cost float64 `json:"cost" validate:"min=0"` Stock float64 `json:"stock" validate:"min=0"` IsSemiFinished bool `json:"is_semi_finished"` @@ -48,7 +48,7 @@ type CompositionItemRequest struct { type UpdateIngredientRequest struct { OutletID *uuid.UUID `json:"outlet_id"` Name string `json:"name" validate:"required,min=1,max=255"` - UnitID uuid.UUID `json:"unit_id" validate:"required"` + UnitID *uuid.UUID `json:"unit_id" validate:"omitempty"` Cost float64 `json:"cost" validate:"min=0"` Stock float64 `json:"stock" validate:"min=0"` IsSemiFinished bool `json:"is_semi_finished"` @@ -61,7 +61,7 @@ type IngredientResponse struct { OrganizationID uuid.UUID `json:"organization_id"` OutletID *uuid.UUID `json:"outlet_id"` Name string `json:"name"` - UnitID uuid.UUID `json:"unit_id"` + UnitID *uuid.UUID `json:"unit_id"` Cost float64 `json:"cost"` Stock float64 `json:"stock"` IsSemiFinished bool `json:"is_semi_finished"` diff --git a/internal/models/ingredient_unit_converter.go b/internal/models/ingredient_unit_converter.go index af14c0c..5290284 100644 --- a/internal/models/ingredient_unit_converter.go +++ b/internal/models/ingredient_unit_converter.go @@ -97,7 +97,7 @@ type ListIngredientUnitConvertersResponse struct { type IngredientUnitsResponse struct { IngredientID uuid.UUID `json:"ingredient_id"` IngredientName string `json:"ingredient_name"` - BaseUnitID uuid.UUID `json:"base_unit_id"` + BaseUnitID *uuid.UUID `json:"base_unit_id"` BaseUnitName string `json:"base_unit_name"` Units []*UnitResponse `json:"units"` } diff --git a/internal/processor/ingredient_processor.go b/internal/processor/ingredient_processor.go index 6a3ff3d..f804422 100644 --- a/internal/processor/ingredient_processor.go +++ b/internal/processor/ingredient_processor.go @@ -27,8 +27,11 @@ func NewIngredientProcessor(ingredientRepo IngredientRepository, unitRepo UnitRe } func (p *IngredientProcessorImpl) CreateIngredient(ctx context.Context, req *models.CreateIngredientRequest) (*models.IngredientResponse, error) { - if _, err := p.unitRepo.GetByID(ctx, req.UnitID, req.OrganizationID); err != nil { - return nil, err + // The unit is optional, so it is only validated when one is supplied. + if req.UnitID != nil { + if _, err := p.unitRepo.GetByID(ctx, *req.UnitID, req.OrganizationID); err != nil { + return nil, err + } } ingredient := &entities.Ingredient{ @@ -107,8 +110,8 @@ func (p *IngredientProcessorImpl) UpdateIngredient(ctx context.Context, id uuid. return nil, err } - if req.UnitID != existing.UnitID { - if _, err := p.unitRepo.GetByID(ctx, req.UnitID, organizationID); err != nil { + if req.UnitID != nil && (existing.UnitID == nil || *req.UnitID != *existing.UnitID) { + if _, err := p.unitRepo.GetByID(ctx, *req.UnitID, organizationID); err != nil { return nil, err } } diff --git a/internal/processor/ingredient_unit_converter_processor.go b/internal/processor/ingredient_unit_converter_processor.go index 9998fe7..dd91983 100644 --- a/internal/processor/ingredient_unit_converter_processor.go +++ b/internal/processor/ingredient_unit_converter_processor.go @@ -266,15 +266,27 @@ func (p *IngredientUnitConverterProcessorImpl) GetUnitsByIngredientID(ctx contex return nil, fmt.Errorf("failed to get ingredient: %w", err) } - // Get the base unit details - baseUnit, err := p.unitRepo.GetByID(ctx, ingredient.UnitID, organizationID) - if err != nil { - return nil, fmt.Errorf("failed to get base unit: %w", err) + response := &models.IngredientUnitsResponse{ + IngredientID: ingredientID, + IngredientName: ingredient.Name, } - // Start with the base unit - units := []*models.UnitResponse{ - mappers.MapUnitEntityToResponse(baseUnit), + units := make([]*models.UnitResponse, 0) + unitMap := make(map[uuid.UUID]bool) + + // An ingredient does not necessarily have a unit assigned yet. When it has + // none there is no base unit to start from, so the only units on offer are + // the ones its converters mention. + if ingredient.UnitID != nil { + baseUnit, err := p.unitRepo.GetByID(ctx, *ingredient.UnitID, organizationID) + if err != nil { + return nil, fmt.Errorf("failed to get base unit: %w", err) + } + + units = append(units, mappers.MapUnitEntityToResponse(baseUnit)) + unitMap[baseUnit.ID] = true + response.BaseUnitID = &baseUnit.ID + response.BaseUnitName = baseUnit.Name } // Get all converters for this ingredient @@ -283,10 +295,6 @@ func (p *IngredientUnitConverterProcessorImpl) GetUnitsByIngredientID(ctx contex return nil, fmt.Errorf("failed to get converters: %w", err) } - // Add unique units from converters - unitMap := make(map[uuid.UUID]bool) - unitMap[baseUnit.ID] = true - for _, converter := range converters { if converter.IsActive { // Add FromUnit if not already added @@ -309,13 +317,7 @@ func (p *IngredientUnitConverterProcessorImpl) GetUnitsByIngredientID(ctx contex } } - response := &models.IngredientUnitsResponse{ - IngredientID: ingredientID, - IngredientName: ingredient.Name, - BaseUnitID: baseUnit.ID, - BaseUnitName: baseUnit.Name, - Units: units, - } + response.Units = units return response, nil } diff --git a/internal/processor/order_ingredient_transaction_processor.go b/internal/processor/order_ingredient_transaction_processor.go index ba84237..0729b5f 100644 --- a/internal/processor/order_ingredient_transaction_processor.go +++ b/internal/processor/order_ingredient_transaction_processor.go @@ -371,8 +371,8 @@ func (p *OrderIngredientTransactionProcessorImpl) CalculateWasteQuantities(ctx c // Get unit name unitName := "unit" // default - if ingredient.UnitID != uuid.Nil { - unit, err := p.unitRepo.GetByID(ctx, ingredient.UnitID, organizationID) + if ingredient.UnitID != nil { + unit, err := p.unitRepo.GetByID(ctx, *ingredient.UnitID, organizationID) if err == nil { unitName = unit.Name } diff --git a/internal/processor/purchase_order_processor.go b/internal/processor/purchase_order_processor.go index 3d0e150..945745a 100644 --- a/internal/processor/purchase_order_processor.go +++ b/internal/processor/purchase_order_processor.go @@ -462,13 +462,15 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte return nil, fmt.Errorf("failed to get ingredient %s: %w", *item.IngredientID, err) } - // Convert quantity to ingredient's base unit if needed + // Convert quantity to ingredient's base unit if needed. An ingredient + // without a unit has no base unit to convert into, so the purchased + // quantity is taken as-is. quantityToAdd := *item.Quantity - if *item.UnitID != ingredient.UnitID { + if ingredient.UnitID != nil && *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 } diff --git a/migrations/000086_make_ingredients_unit_id_nullable.down.sql b/migrations/000086_make_ingredients_unit_id_nullable.down.sql new file mode 100644 index 0000000..a0de2d3 --- /dev/null +++ b/migrations/000086_make_ingredients_unit_id_nullable.down.sql @@ -0,0 +1,6 @@ +-- Restoring NOT NULL fails if any ingredient still has a NULL unit_id. Assign a +-- unit to those rows first: +-- SELECT id, name FROM ingredients WHERE unit_id IS NULL; +COMMENT ON COLUMN ingredients.unit_id IS NULL; + +ALTER TABLE ingredients ALTER COLUMN unit_id SET NOT NULL; diff --git a/migrations/000086_make_ingredients_unit_id_nullable.up.sql b/migrations/000086_make_ingredients_unit_id_nullable.up.sql new file mode 100644 index 0000000..ba15a45 --- /dev/null +++ b/migrations/000086_make_ingredients_unit_id_nullable.up.sql @@ -0,0 +1,5 @@ +-- An ingredient can be registered before its unit has been decided, so unit_id +-- is optional. Existing rows are untouched: they already have a unit. +ALTER TABLE ingredients ALTER COLUMN unit_id DROP NOT NULL; + +COMMENT ON COLUMN ingredients.unit_id IS 'Base unit of the ingredient. NULL means no unit has been assigned yet.';