diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index 2082e195..7f694a12 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -355,7 +355,6 @@ func (s *purchaseService) processStaffPurchaseApproval(c *fiber.Ctx, id uint64, return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase is not ready for staff approval") } - // Detect if purchase already has any receiving data on its items. hasReceivingData := false for _, item := range purchase.Items { if item.TotalQty > 0 || item.TotalUsed > 0 { @@ -364,8 +363,6 @@ func (s *purchaseService) processStaffPurchaseApproval(c *fiber.Ctx, id uint64, } } - // After there is receiving data, staff edits are allowed to change quantity - // in sync with receiving, without creating new receiving approvals here. syncReceiving := !isInitialApproval && hasReceivingData payload, err := s.buildStaffAdjustmentPayload(ctx, purchase, req, syncReceiving) @@ -611,6 +608,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint64, req *validati receivedQty float64 } + visitedItems := make(map[uint64]struct{}, len(req.Items)) prepared := make([]preparedReceiving, 0, len(req.Items)) for _, payload := range req.Items { item, exists := itemMap[payload.PurchaseItemID] @@ -647,6 +645,11 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint64, req *validati return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Received quantity for item %d cannot exceed ordered quantity (%.3f)", payload.PurchaseItemID, item.SubQty)) } + if _, dup := visitedItems[payload.PurchaseItemID]; dup { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Duplicate receiving data for item %d", payload.PurchaseItemID)) + } + visitedItems[payload.PurchaseItemID] = struct{}{} + prepared = append(prepared, preparedReceiving{ item: item, payload: payload, @@ -657,6 +660,12 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint64, req *validati }) } + // Require receiving payload to cover all purchase items so that + // receiving cannot be submitted partially item-by-item. + if len(visitedItems) != len(itemMap) { + return nil, fiber.NewError(fiber.StatusBadRequest, "Receiving data must be provided for all purchase items") + } + receivingAction := entity.ApprovalActionApproved completedAction := entity.ApprovalActionApproved actorID := uint(1) @@ -1085,57 +1094,21 @@ func (s *purchaseService) buildStaffAdjustmentPayload( return nil, fiber.NewError(fiber.StatusBadRequest, "No available warehouses for this purchase") } - productSupplierCache := make(map[uint64]bool) - for _, item := range purchase.Items { data, ok := requestItems[item.Id] if !ok { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Missing pricing data for item %d", item.Id)) } + if data.ProductID != 0 && data.ProductID != item.ProductId { + return nil, fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Cannot change product for item %d. Delete the item and create a new one instead", item.Id), + ) + } if data.WarehouseID != 0 && data.WarehouseID != item.WarehouseId { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Warehouse mismatch for item %d", item.Id)) } - effectiveProductID := item.ProductId - if data.ProductID != 0 && data.ProductID != item.ProductId { - if item.TotalQty > 0 || item.TotalUsed > 0 { - return nil, fiber.NewError( - fiber.StatusBadRequest, - fmt.Sprintf("Cannot change product for item %d because it already has receiving data", item.Id), - ) - } - - if _, checked := productSupplierCache[data.ProductID]; !checked { - linked, err := s.ProductRepo.IsLinkedToSupplier(ctx, uint(data.ProductID), uint(purchase.SupplierId)) - if err != nil { - s.Log.Errorf("Failed to validate product %d for supplier %d: %+v", data.ProductID, purchase.SupplierId, err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product for supplier") - } - if !linked { - return nil, fiber.NewError( - fiber.StatusBadRequest, - fmt.Sprintf("Product %d is not linked to supplier %d", data.ProductID, purchase.SupplierId), - ) - } - productSupplierCache[data.ProductID] = true - } - - oldKey := fmt.Sprintf("%d:%d", item.ProductId, item.WarehouseId) - newKey := fmt.Sprintf("%d:%d", data.ProductID, item.WarehouseId) - if _, exists := existingCombos[newKey]; exists && newKey != oldKey { - return nil, fiber.NewError( - fiber.StatusBadRequest, - fmt.Sprintf("Product %d in warehouse %d already exists in this purchase", data.ProductID, item.WarehouseId), - ) - } - if newKey != oldKey { - delete(existingCombos, oldKey) - existingCombos[newKey] = struct{}{} - } - - effectiveProductID = data.ProductID - } - effectiveQty := item.SubQty if data.Qty != nil { if *data.Qty <= 0 { @@ -1161,10 +1134,6 @@ func (s *purchaseService) buildStaffAdjustmentPayload( Price: data.Price, TotalPrice: totalPrice, } - if effectiveProductID != item.ProductId { - productIDCopy := effectiveProductID - update.ProductID = &productIDCopy - } if data.Qty != nil { qtyCopy := effectiveQty update.Quantity = &qtyCopy @@ -1182,6 +1151,7 @@ func (s *purchaseService) buildStaffAdjustmentPayload( return nil, fiber.NewError(fiber.StatusBadRequest, "Found pricing data for items that do not belong to this purchase") } + productSupplierCache := make(map[uint64]bool) newItems := make([]*entity.PurchaseItem, 0, len(newPayloads)) for _, payload := range newPayloads { diff --git a/internal/modules/purchases/validations/purchase.validation.go b/internal/modules/purchases/validations/purchase.validation.go index 3660263d..4994a927 100644 --- a/internal/modules/purchases/validations/purchase.validation.go +++ b/internal/modules/purchases/validations/purchase.validation.go @@ -3,7 +3,7 @@ package validation type PurchaseItemPayload struct { WarehouseID uint `json:"warehouse_id" validate:"required,gt=0"` ProductID uint `json:"product_id" validate:"required,gt=0"` - Quantity float64 `json:"quantity" validate:"required,gt=0"` + Quantity float64 `json:"qty" validate:"required,gt=0"` } type CreatePurchaseRequest struct { @@ -14,12 +14,13 @@ type CreatePurchaseRequest struct { } type StaffPurchaseApprovalItem struct { - PurchaseItemID uint64 `json:"purchase_item_id,omitempty" validate:"omitempty,gt=0"` - ProductID uint64 `json:"product_id" validate:"required,gt=0"` - WarehouseID uint64 `json:"warehouse_id,omitempty" validate:"required_without=PurchaseItemID,omitempty,gt=0"` - Qty *float64 `json:"qty,omitempty" validate:"required_without=PurchaseItemID,omitempty,gt=0"` - Price float64 `json:"price" validate:"required,gt=0"` - TotalPrice float64 `json:"total_price" validate:"required,gt=0"` + PurchaseItemID uint64 `json:"purchase_item_id,omitempty" validate:"omitempty,gt=0"` + // For new items (no purchase_item_id), product_id is required. + ProductID uint64 `json:"product_id,omitempty" validate:"required_without=PurchaseItemID,omitempty,gt=0"` + WarehouseID uint64 `json:"warehouse_id,omitempty" validate:"required_without=PurchaseItemID,omitempty,gt=0"` + Qty *float64 `json:"qty,omitempty" validate:"required_without=PurchaseItemID,omitempty,gt=0"` + Price float64 `json:"price" validate:"required,gt=0"` + TotalPrice float64 `json:"total_price" validate:"required,gt=0"` } type ApproveStaffPurchaseRequest struct {