feature/exclusive-summary #15
@ -19,11 +19,11 @@ type CreatePurchaseOrderRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type CreatePurchaseOrderItemRequest struct {
|
type CreatePurchaseOrderItemRequest struct {
|
||||||
IngredientID *uuid.UUID `json:"ingredient_id,omitempty" validate:"omitempty"`
|
IngredientID uuid.UUID `json:"ingredient_id" validate:"required"`
|
||||||
PurchaseCategoryID uuid.UUID `json:"purchase_category_id" validate:"required"`
|
PurchaseCategoryID uuid.UUID `json:"purchase_category_id" validate:"required"`
|
||||||
Description *string `json:"description,omitempty" validate:"omitempty"`
|
Description *string `json:"description,omitempty" validate:"omitempty"`
|
||||||
Quantity *float64 `json:"quantity,omitempty" validate:"omitempty,gt=0"`
|
Quantity float64 `json:"quantity" validate:"required,gt=0"`
|
||||||
UnitID *uuid.UUID `json:"unit_id,omitempty" validate:"omitempty"`
|
UnitID uuid.UUID `json:"unit_id" validate:"required"`
|
||||||
Amount float64 `json:"amount" validate:"required,gte=0"`
|
Amount float64 `json:"amount" validate:"required,gte=0"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -70,11 +70,11 @@ type PurchaseOrderResponse struct {
|
|||||||
type PurchaseOrderItemResponse struct {
|
type PurchaseOrderItemResponse struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
PurchaseOrderID uuid.UUID `json:"purchase_order_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"`
|
PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
|
||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
Quantity *float64 `json:"quantity"`
|
Quantity float64 `json:"quantity"`
|
||||||
UnitID *uuid.UUID `json:"unit_id"`
|
UnitID uuid.UUID `json:"unit_id"`
|
||||||
Amount float64 `json:"amount"`
|
Amount float64 `json:"amount"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
|||||||
@ -43,11 +43,11 @@ func (PurchaseOrder) TableName() string {
|
|||||||
type PurchaseOrderItem struct {
|
type PurchaseOrderItem struct {
|
||||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
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"`
|
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"`
|
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"`
|
PurchaseCategoryID uuid.UUID `gorm:"type:uuid;not null;index" json:"purchase_category_id" validate:"required"`
|
||||||
Description *string `gorm:"type:text" json:"description" validate:"omitempty"`
|
Description *string `gorm:"type:text" json:"description" validate:"omitempty"`
|
||||||
Quantity *float64 `gorm:"type:decimal(10,3)" json:"quantity" validate:"omitempty,gt=0"`
|
Quantity float64 `gorm:"type:decimal(10,3);not null" json:"quantity" validate:"required,gt=0"`
|
||||||
UnitID *uuid.UUID `gorm:"type:uuid" json:"unit_id" validate:"omitempty"`
|
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"`
|
Amount float64 `gorm:"type:decimal(15,2);not null" json:"amount" validate:"required,gte=0"`
|
||||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||||
|
|||||||
@ -24,11 +24,11 @@ type PurchaseOrder struct {
|
|||||||
type PurchaseOrderItem struct {
|
type PurchaseOrderItem struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
PurchaseOrderID uuid.UUID `json:"purchase_order_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"`
|
PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
|
||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
Quantity *float64 `json:"quantity"`
|
Quantity float64 `json:"quantity"`
|
||||||
UnitID *uuid.UUID `json:"unit_id"`
|
UnitID uuid.UUID `json:"unit_id"`
|
||||||
Amount float64 `json:"amount"`
|
Amount float64 `json:"amount"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
@ -62,11 +62,11 @@ type PurchaseOrderResponse struct {
|
|||||||
type PurchaseOrderItemResponse struct {
|
type PurchaseOrderItemResponse struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
PurchaseOrderID uuid.UUID `json:"purchase_order_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"`
|
PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
|
||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
Quantity *float64 `json:"quantity"`
|
Quantity float64 `json:"quantity"`
|
||||||
UnitID *uuid.UUID `json:"unit_id"`
|
UnitID uuid.UUID `json:"unit_id"`
|
||||||
Amount float64 `json:"amount"`
|
Amount float64 `json:"amount"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
@ -96,11 +96,11 @@ type CreatePurchaseOrderRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type CreatePurchaseOrderItemRequest struct {
|
type CreatePurchaseOrderItemRequest struct {
|
||||||
IngredientID *uuid.UUID `json:"ingredient_id,omitempty"`
|
IngredientID uuid.UUID `json:"ingredient_id"`
|
||||||
PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
|
PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
|
||||||
Description *string `json:"description,omitempty"`
|
Description *string `json:"description,omitempty"`
|
||||||
Quantity *float64 `json:"quantity,omitempty"`
|
Quantity float64 `json:"quantity"`
|
||||||
UnitID *uuid.UUID `json:"unit_id,omitempty"`
|
UnitID uuid.UUID `json:"unit_id"`
|
||||||
Amount float64 `json:"amount"`
|
Amount float64 `json:"amount"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -67,41 +67,21 @@ 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)
|
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 {
|
for i, item := range req.Items {
|
||||||
category, err := p.validatePurchaseCategory(ctx, item.PurchaseCategoryID, organizationID, i)
|
if err := p.validateRawMaterialPurchaseCategory(ctx, item.PurchaseCategoryID, organizationID, i); err != nil {
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
switch category.Type {
|
_, err := p.ingredientRepo.GetByID(ctx, item.IngredientID, organizationID)
|
||||||
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("ingredient not found for item %d: %w", i, err)
|
return nil, fmt.Errorf("ingredient not found for item %d: %w", i, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = p.unitRepo.GetByID(ctx, *item.UnitID, organizationID)
|
_, err = p.unitRepo.GetByID(ctx, item.UnitID, organizationID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("unit not found for item %d: %w", i, err)
|
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate total amount
|
// Calculate total amount
|
||||||
@ -224,49 +204,39 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id
|
|||||||
return nil, fmt.Errorf("purchase_category_id is required for item %d", i)
|
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
|
purchaseCategoryID := *itemReq.PurchaseCategoryID
|
||||||
unitID := itemReq.UnitID
|
unitID := *itemReq.UnitID
|
||||||
quantity := itemReq.Quantity
|
quantity := *itemReq.Quantity
|
||||||
amount := 0.0
|
amount := 0.0
|
||||||
if itemReq.Amount != nil {
|
if itemReq.Amount != nil {
|
||||||
amount = *itemReq.Amount
|
amount = *itemReq.Amount
|
||||||
}
|
}
|
||||||
description := itemReq.Description
|
description := itemReq.Description
|
||||||
|
|
||||||
category, err := p.validatePurchaseCategory(ctx, purchaseCategoryID, organizationID, i)
|
if err := p.validateRawMaterialPurchaseCategory(ctx, purchaseCategoryID, organizationID, i); err != nil {
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
switch category.Type {
|
_, err := p.ingredientRepo.GetByID(ctx, ingredientID, organizationID)
|
||||||
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("ingredient not found: %w", err)
|
return nil, fmt.Errorf("ingredient not found: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = p.unitRepo.GetByID(ctx, *unitID, organizationID)
|
_, err = p.unitRepo.GetByID(ctx, unitID, organizationID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("unit not found: %w", err)
|
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
items[i] = &entities.PurchaseOrderItem{
|
items[i] = &entities.PurchaseOrderItem{
|
||||||
PurchaseOrderID: poEntity.ID,
|
PurchaseOrderID: poEntity.ID,
|
||||||
@ -407,8 +377,6 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte
|
|||||||
return nil, fmt.Errorf("purchase order not found: %w", err)
|
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"
|
// Check if status is changing to "received" and current status is not "received"
|
||||||
if status == "received" && po.Status != "received" {
|
if status == "received" && po.Status != "received" {
|
||||||
// Get purchase order with items for inventory update
|
// Get purchase order with items for inventory update
|
||||||
@ -419,27 +387,19 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte
|
|||||||
|
|
||||||
// Update inventory for each item
|
// Update inventory for each item
|
||||||
for _, item := range poWithItems.Items {
|
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
|
// 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 {
|
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
|
// Convert quantity to ingredient's base unit if needed
|
||||||
quantityToAdd := *item.Quantity
|
quantityToAdd := item.Quantity
|
||||||
if *item.UnitID != ingredient.UnitID {
|
if 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
|
||||||
}
|
}
|
||||||
@ -457,7 +417,7 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte
|
|||||||
|
|
||||||
err = p.inventoryMovementService.CreateIngredientMovement(
|
err = p.inventoryMovementService.CreateIngredientMovement(
|
||||||
ctx,
|
ctx,
|
||||||
*item.IngredientID,
|
item.IngredientID,
|
||||||
organizationID,
|
organizationID,
|
||||||
outletID,
|
outletID,
|
||||||
userID,
|
userID,
|
||||||
@ -470,7 +430,7 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte
|
|||||||
&item.ID,
|
&item.ID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
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
|
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)
|
category, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, categoryID, organizationID)
|
||||||
if err != nil {
|
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 {
|
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 {
|
if category.Type != entities.PurchaseCategoryTypeRawMaterial {
|
||||||
return nil, fmt.Errorf("purchase category for item %d must be raw_material or expense", itemIndex)
|
return fmt.Errorf("purchase category for item %d must be raw_material", itemIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
return category, nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -152,7 +152,11 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex
|
|||||||
Table("purchase_orders po").
|
Table("purchase_orders po").
|
||||||
Select(`
|
Select(`
|
||||||
COALESCE(SUM(poi.amount), 0) as total_purchases,
|
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 total_purchase_orders,
|
||||||
|
COUNT(DISTINCT po.id) as raw_material_purchase_orders,
|
||||||
|
0 as expense_count,
|
||||||
COALESCE(SUM(poi.quantity), 0) as total_quantity,
|
COALESCE(SUM(poi.quantity), 0) as total_quantity,
|
||||||
CASE
|
CASE
|
||||||
WHEN COUNT(DISTINCT po.id) > 0
|
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 i.id) as total_ingredients,
|
||||||
COUNT(DISTINCT po.vendor_id) as total_vendors
|
COUNT(DISTINCT po.vendor_id) as total_vendors
|
||||||
`).
|
`).
|
||||||
Joins("LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id").
|
Joins("JOIN purchase_order_items poi ON poi.purchase_order_id = po.id").
|
||||||
Joins("LEFT JOIN ingredients i ON poi.ingredient_id = i.id").
|
Joins("JOIN purchase_categories pc ON poi.purchase_category_id = pc.id").
|
||||||
Joins("LEFT JOIN units u ON poi.unit_id = u.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("po.organization_id = ?", organizationID).
|
||||||
|
Where("pc.type = ?", entities.PurchaseCategoryTypeRawMaterial).
|
||||||
Where("po.status != ?", "cancelled").
|
Where("po.status != ?", "cancelled").
|
||||||
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo)
|
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo)
|
||||||
summaryQuery = r.applyPurchaseOrderItemOutletFilter(summaryQuery, outletID)
|
summaryQuery = r.applyPurchaseOrderItemOutletFilter(summaryQuery, outletID)
|
||||||
@ -192,15 +198,21 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex
|
|||||||
Select(`
|
Select(`
|
||||||
`+dateFormat+` as date,
|
`+dateFormat+` as date,
|
||||||
COALESCE(SUM(poi.amount), 0) as purchases,
|
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 purchase_orders,
|
||||||
|
COUNT(DISTINCT po.id) as raw_material_purchase_orders,
|
||||||
|
0 as expense_count,
|
||||||
COALESCE(SUM(poi.quantity), 0) as quantity,
|
COALESCE(SUM(poi.quantity), 0) as quantity,
|
||||||
COUNT(DISTINCT i.id) as ingredients,
|
COUNT(DISTINCT i.id) as ingredients,
|
||||||
COUNT(DISTINCT po.vendor_id) as vendors
|
COUNT(DISTINCT po.vendor_id) as vendors
|
||||||
`).
|
`).
|
||||||
Joins("LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id").
|
Joins("JOIN purchase_order_items poi ON poi.purchase_order_id = po.id").
|
||||||
Joins("LEFT JOIN ingredients i ON poi.ingredient_id = i.id").
|
Joins("JOIN purchase_categories pc ON poi.purchase_category_id = pc.id").
|
||||||
Joins("LEFT JOIN units u ON poi.unit_id = u.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("po.organization_id = ?", organizationID).
|
||||||
|
Where("pc.type = ?", entities.PurchaseCategoryTypeRawMaterial).
|
||||||
Where("po.status != ?", "cancelled").
|
Where("po.status != ?", "cancelled").
|
||||||
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo).
|
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo).
|
||||||
Group(dateFormat).
|
Group(dateFormat).
|
||||||
@ -227,9 +239,11 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex
|
|||||||
COUNT(DISTINCT po.id) as purchase_order_count
|
COUNT(DISTINCT po.id) as purchase_order_count
|
||||||
`).
|
`).
|
||||||
Joins("JOIN purchase_orders po ON poi.purchase_order_id = po.id").
|
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("JOIN ingredients i ON poi.ingredient_id = i.id").
|
||||||
Joins("LEFT JOIN units u ON poi.unit_id = u.id").
|
Joins("LEFT JOIN units u ON poi.unit_id = u.id").
|
||||||
Where("po.organization_id = ?", organizationID).
|
Where("po.organization_id = ?", organizationID).
|
||||||
|
Where("pc.type = ?", entities.PurchaseCategoryTypeRawMaterial).
|
||||||
Where("po.status != ?", "cancelled").
|
Where("po.status != ?", "cancelled").
|
||||||
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo).
|
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo).
|
||||||
Group("i.id, i.name").
|
Group("i.id, i.name").
|
||||||
@ -252,10 +266,12 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex
|
|||||||
COALESCE(SUM(poi.quantity), 0) as quantity
|
COALESCE(SUM(poi.quantity), 0) as quantity
|
||||||
`).
|
`).
|
||||||
Joins("JOIN vendors v ON po.vendor_id = v.id").
|
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("JOIN purchase_order_items poi ON poi.purchase_order_id = po.id").
|
||||||
Joins("LEFT JOIN ingredients i ON poi.ingredient_id = i.id").
|
Joins("JOIN purchase_categories pc ON poi.purchase_category_id = pc.id").
|
||||||
Joins("LEFT JOIN units u ON poi.unit_id = u.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("po.organization_id = ?", organizationID).
|
||||||
|
Where("pc.type = ?", entities.PurchaseCategoryTypeRawMaterial).
|
||||||
Where("po.status != ?", "cancelled").
|
Where("po.status != ?", "cancelled").
|
||||||
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo).
|
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo).
|
||||||
Group("v.id, v.name").
|
Group("v.id, v.name").
|
||||||
|
|||||||
@ -12,19 +12,15 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestCreatePurchaseOrderRequestToModelAllowsMissingDueDate(t *testing.T) {
|
func TestCreatePurchaseOrderRequestToModelAllowsMissingDueDate(t *testing.T) {
|
||||||
ingredientID := uuid.New()
|
|
||||||
quantity := 1.0
|
|
||||||
unitID := uuid.New()
|
|
||||||
|
|
||||||
result, err := CreatePurchaseOrderRequestToModel(&contract.CreatePurchaseOrderRequest{
|
result, err := CreatePurchaseOrderRequestToModel(&contract.CreatePurchaseOrderRequest{
|
||||||
VendorID: uuid.New(),
|
VendorID: uuid.New(),
|
||||||
PONumber: "PO-001",
|
PONumber: "PO-001",
|
||||||
TransactionDate: "2026-05-29",
|
TransactionDate: "2026-05-29",
|
||||||
Items: []contract.CreatePurchaseOrderItemRequest{
|
Items: []contract.CreatePurchaseOrderItemRequest{
|
||||||
{
|
{
|
||||||
IngredientID: &ingredientID,
|
IngredientID: uuid.New(),
|
||||||
Quantity: &quantity,
|
Quantity: 1,
|
||||||
UnitID: &unitID,
|
UnitID: uuid.New(),
|
||||||
Amount: 1000,
|
Amount: 1000,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -181,20 +181,20 @@ func (v *PurchaseOrderValidatorImpl) ValidateListPurchaseOrdersRequest(req *cont
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (v *PurchaseOrderValidatorImpl) validatePurchaseOrderItem(item *contract.CreatePurchaseOrderItemRequest, index int) (error, string) {
|
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 {
|
if item.PurchaseCategoryID == uuid.Nil {
|
||||||
return errors.New("items[" + strconv.Itoa(index) + "].purchase_category_id is required"), constants.MissingFieldErrorCode
|
return errors.New("items[" + strconv.Itoa(index) + "].purchase_category_id is required"), constants.MissingFieldErrorCode
|
||||||
}
|
}
|
||||||
|
|
||||||
if item.IngredientID != nil && *item.IngredientID == uuid.Nil {
|
if item.Quantity <= 0 {
|
||||||
return errors.New("items[" + strconv.Itoa(index) + "].ingredient_id cannot be empty"), constants.MalformedFieldErrorCode
|
|
||||||
}
|
|
||||||
|
|
||||||
if item.Quantity != nil && *item.Quantity <= 0 {
|
|
||||||
return errors.New("items[" + strconv.Itoa(index) + "].quantity must be greater than 0"), constants.MalformedFieldErrorCode
|
return errors.New("items[" + strconv.Itoa(index) + "].quantity must be greater than 0"), constants.MalformedFieldErrorCode
|
||||||
}
|
}
|
||||||
|
|
||||||
if item.UnitID != nil && *item.UnitID == uuid.Nil {
|
if item.UnitID == uuid.Nil {
|
||||||
return errors.New("items[" + strconv.Itoa(index) + "].unit_id cannot be empty"), constants.MalformedFieldErrorCode
|
return errors.New("items[" + strconv.Itoa(index) + "].unit_id is required"), constants.MissingFieldErrorCode
|
||||||
}
|
}
|
||||||
|
|
||||||
if item.Amount < 0 {
|
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
|
return errors.New("items[" + strconv.Itoa(index) + "].purchase_category_id is required"), constants.MissingFieldErrorCode
|
||||||
}
|
}
|
||||||
|
|
||||||
if item.IngredientID != nil && *item.IngredientID == uuid.Nil {
|
if item.IngredientID == nil || *item.IngredientID == uuid.Nil {
|
||||||
return errors.New("items[" + strconv.Itoa(index) + "].ingredient_id cannot be empty"), constants.MalformedFieldErrorCode
|
return errors.New("items[" + strconv.Itoa(index) + "].ingredient_id is required"), constants.MissingFieldErrorCode
|
||||||
}
|
}
|
||||||
|
|
||||||
if item.UnitID != nil && *item.UnitID == uuid.Nil {
|
if item.UnitID == nil || *item.UnitID == uuid.Nil {
|
||||||
return errors.New("items[" + strconv.Itoa(index) + "].unit_id cannot be empty"), constants.MalformedFieldErrorCode
|
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
|
return errors.New("items[" + strconv.Itoa(index) + "].quantity must be greater than 0"), constants.MalformedFieldErrorCode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -11,26 +11,34 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func validCreatePurchaseOrderRequest() *contract.CreatePurchaseOrderRequest {
|
func validCreatePurchaseOrderRequest() *contract.CreatePurchaseOrderRequest {
|
||||||
ingredientID := uuid.New()
|
|
||||||
quantity := 1.0
|
|
||||||
unitID := uuid.New()
|
|
||||||
|
|
||||||
return &contract.CreatePurchaseOrderRequest{
|
return &contract.CreatePurchaseOrderRequest{
|
||||||
VendorID: uuid.New(),
|
VendorID: uuid.New(),
|
||||||
PONumber: "PO-001",
|
PONumber: "PO-001",
|
||||||
TransactionDate: "2026-05-29",
|
TransactionDate: "2026-05-29",
|
||||||
Items: []contract.CreatePurchaseOrderItemRequest{
|
Items: []contract.CreatePurchaseOrderItemRequest{
|
||||||
{
|
{
|
||||||
IngredientID: &ingredientID,
|
IngredientID: uuid.New(),
|
||||||
PurchaseCategoryID: uuid.New(),
|
PurchaseCategoryID: uuid.New(),
|
||||||
Quantity: &quantity,
|
Quantity: 1,
|
||||||
UnitID: &unitID,
|
UnitID: uuid.New(),
|
||||||
Amount: 1000,
|
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) {
|
func TestPurchaseOrderValidatorCreateAllowsMissingDueDate(t *testing.T) {
|
||||||
validator := NewPurchaseOrderValidator()
|
validator := NewPurchaseOrderValidator()
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
@ -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();
|
||||||
Loading…
x
Reference in New Issue
Block a user