feat(ingredients): make units nullable

This commit is contained in:
efrilm
2026-08-11 21:20:17 +07:00
parent 9ae5be2c33
commit 0726fcecf0
11 changed files with 54 additions and 36 deletions
@@ -77,7 +77,7 @@ type ListIngredientUnitConvertersResponse struct {
type IngredientUnitsResponse struct { type IngredientUnitsResponse struct {
IngredientID uuid.UUID `json:"ingredient_id"` IngredientID uuid.UUID `json:"ingredient_id"`
IngredientName string `json:"ingredient_name"` 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"` BaseUnitName string `json:"base_unit_name"`
Units []*UnitResponse `json:"units"` Units []*UnitResponse `json:"units"`
} }
+1 -1
View File
@@ -54,7 +54,7 @@ type ProductRecipeIngredientResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id"` OutletID *uuid.UUID `json:"outlet_id"`
Name string `json:"name"` Name string `json:"name"`
UnitID uuid.UUID `json:"unit_id"` UnitID *uuid.UUID `json:"unit_id"`
Cost float64 `json:"cost"` Cost float64 `json:"cost"`
Stock float64 `json:"stock"` Stock float64 `json:"stock"`
IsSemiFinished bool `json:"is_semi_finished"` IsSemiFinished bool `json:"is_semi_finished"`
+1 -1
View File
@@ -11,7 +11,7 @@ type Ingredient struct {
OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id"` OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id"`
OutletID *uuid.UUID `gorm:"type:uuid;index" json:"outlet_id"` OutletID *uuid.UUID `gorm:"type:uuid;index" json:"outlet_id"`
Name string `gorm:"not null;size:255" json:"name"` 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"` Cost float64 `gorm:"type:decimal(10,2);default:0.00" json:"cost"`
Stock float64 `gorm:"type:decimal(10,2);default:0.00" json:"stock"` Stock float64 `gorm:"type:decimal(10,2);default:0.00" json:"stock"`
IsSemiFinished bool `gorm:"default:false" json:"is_semi_finished"` IsSemiFinished bool `gorm:"default:false" json:"is_semi_finished"`
+4 -4
View File
@@ -12,7 +12,7 @@ type Ingredient struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id"` OutletID *uuid.UUID `json:"outlet_id"`
Name string `json:"name"` Name string `json:"name"`
UnitID uuid.UUID `json:"unit_id"` UnitID *uuid.UUID `json:"unit_id"`
Cost float64 `json:"cost"` Cost float64 `json:"cost"`
Stock float64 `json:"stock"` Stock float64 `json:"stock"`
IsSemiFinished bool `json:"is_semi_finished"` IsSemiFinished bool `json:"is_semi_finished"`
@@ -29,7 +29,7 @@ type CreateIngredientRequest struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id"` OutletID *uuid.UUID `json:"outlet_id"`
Name string `json:"name" validate:"required,min=1,max=255"` 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"` Cost float64 `json:"cost" validate:"min=0"`
Stock float64 `json:"stock" validate:"min=0"` Stock float64 `json:"stock" validate:"min=0"`
IsSemiFinished bool `json:"is_semi_finished"` IsSemiFinished bool `json:"is_semi_finished"`
@@ -48,7 +48,7 @@ type CompositionItemRequest struct {
type UpdateIngredientRequest struct { type UpdateIngredientRequest struct {
OutletID *uuid.UUID `json:"outlet_id"` OutletID *uuid.UUID `json:"outlet_id"`
Name string `json:"name" validate:"required,min=1,max=255"` 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"` Cost float64 `json:"cost" validate:"min=0"`
Stock float64 `json:"stock" validate:"min=0"` Stock float64 `json:"stock" validate:"min=0"`
IsSemiFinished bool `json:"is_semi_finished"` IsSemiFinished bool `json:"is_semi_finished"`
@@ -61,7 +61,7 @@ type IngredientResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id"` OutletID *uuid.UUID `json:"outlet_id"`
Name string `json:"name"` Name string `json:"name"`
UnitID uuid.UUID `json:"unit_id"` UnitID *uuid.UUID `json:"unit_id"`
Cost float64 `json:"cost"` Cost float64 `json:"cost"`
Stock float64 `json:"stock"` Stock float64 `json:"stock"`
IsSemiFinished bool `json:"is_semi_finished"` IsSemiFinished bool `json:"is_semi_finished"`
+1 -1
View File
@@ -97,7 +97,7 @@ type ListIngredientUnitConvertersResponse struct {
type IngredientUnitsResponse struct { type IngredientUnitsResponse struct {
IngredientID uuid.UUID `json:"ingredient_id"` IngredientID uuid.UUID `json:"ingredient_id"`
IngredientName string `json:"ingredient_name"` 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"` BaseUnitName string `json:"base_unit_name"`
Units []*UnitResponse `json:"units"` Units []*UnitResponse `json:"units"`
} }
+7 -4
View File
@@ -27,8 +27,11 @@ func NewIngredientProcessor(ingredientRepo IngredientRepository, unitRepo UnitRe
} }
func (p *IngredientProcessorImpl) CreateIngredient(ctx context.Context, req *models.CreateIngredientRequest) (*models.IngredientResponse, error) { 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 { // The unit is optional, so it is only validated when one is supplied.
return nil, err if req.UnitID != nil {
if _, err := p.unitRepo.GetByID(ctx, *req.UnitID, req.OrganizationID); err != nil {
return nil, err
}
} }
ingredient := &entities.Ingredient{ ingredient := &entities.Ingredient{
@@ -107,8 +110,8 @@ func (p *IngredientProcessorImpl) UpdateIngredient(ctx context.Context, id uuid.
return nil, err return nil, err
} }
if req.UnitID != existing.UnitID { if req.UnitID != nil && (existing.UnitID == nil || *req.UnitID != *existing.UnitID) {
if _, err := p.unitRepo.GetByID(ctx, req.UnitID, organizationID); err != nil { if _, err := p.unitRepo.GetByID(ctx, *req.UnitID, organizationID); err != nil {
return nil, err return nil, err
} }
} }
@@ -266,15 +266,27 @@ func (p *IngredientUnitConverterProcessorImpl) GetUnitsByIngredientID(ctx contex
return nil, fmt.Errorf("failed to get ingredient: %w", err) return nil, fmt.Errorf("failed to get ingredient: %w", err)
} }
// Get the base unit details response := &models.IngredientUnitsResponse{
baseUnit, err := p.unitRepo.GetByID(ctx, ingredient.UnitID, organizationID) IngredientID: ingredientID,
if err != nil { IngredientName: ingredient.Name,
return nil, fmt.Errorf("failed to get base unit: %w", err)
} }
// Start with the base unit units := make([]*models.UnitResponse, 0)
units := []*models.UnitResponse{ unitMap := make(map[uuid.UUID]bool)
mappers.MapUnitEntityToResponse(baseUnit),
// 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 // 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) 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 { for _, converter := range converters {
if converter.IsActive { if converter.IsActive {
// Add FromUnit if not already added // Add FromUnit if not already added
@@ -309,13 +317,7 @@ func (p *IngredientUnitConverterProcessorImpl) GetUnitsByIngredientID(ctx contex
} }
} }
response := &models.IngredientUnitsResponse{ response.Units = units
IngredientID: ingredientID,
IngredientName: ingredient.Name,
BaseUnitID: baseUnit.ID,
BaseUnitName: baseUnit.Name,
Units: units,
}
return response, nil return response, nil
} }
@@ -371,8 +371,8 @@ func (p *OrderIngredientTransactionProcessorImpl) CalculateWasteQuantities(ctx c
// Get unit name // Get unit name
unitName := "unit" // default unitName := "unit" // default
if ingredient.UnitID != uuid.Nil { if ingredient.UnitID != nil {
unit, err := p.unitRepo.GetByID(ctx, ingredient.UnitID, organizationID) unit, err := p.unitRepo.GetByID(ctx, *ingredient.UnitID, organizationID)
if err == nil { if err == nil {
unitName = unit.Name unitName = unit.Name
} }
@@ -462,13 +462,15 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte
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 // 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 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 // 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 { 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 quantityToAdd = convertedQuantity
} }
@@ -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;
@@ -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.';