diff --git a/app/assets/javascripts/application_legacy.js b/app/assets/javascripts/application_legacy.js
index 84138cd9b..18590f2a9 100644
--- a/app/assets/javascripts/application_legacy.js
+++ b/app/assets/javascripts/application_legacy.js
@@ -18,6 +18,7 @@
//= require_self
//= require big
//= require units-converter
+//= require validation-utils
//= require migrate-units-form
//= require article-form
//= require unit-conversion-field
diff --git a/app/assets/javascripts/article-form.js b/app/assets/javascripts/article-form.js
index 6345538d5..a46740ab8 100644
--- a/app/assets/javascripts/article-form.js
+++ b/app/assets/javascripts/article-form.js
@@ -13,6 +13,7 @@ class ArticleForm {
this.supplierUnitSelect$ = $(`#${this.unitFieldsIdPrefix}_supplier_order_unit`, this.articleForm$);
this.unitRatiosTable$ = $('#fc_base_price', this.articleForm$);
this.minimumOrderQuantity$ = $(`#${this.unitFieldsIdPrefix}_minimum_order_quantity`, this.articleForm$);
+ this.maximumOrderQuantity$ = $(`#${this.unitFieldsIdPrefix}_maximum_order_quantity`, this.articleForm$);
this.billingUnit$ = $(`#${this.unitFieldsIdPrefix}_billing_unit`, this.articleForm$);
this.groupOrderGranularity$ = $(`#${this.unitFieldsIdPrefix}_group_order_granularity`, this.articleForm$);
this.groupOrderUnit$ = $(`#${this.unitFieldsIdPrefix}_group_order_unit`, this.articleForm$);
@@ -60,7 +61,7 @@ class ArticleForm {
const tax = parseFloat(this.tax$.val());
const deposit = parseFloat(this.deposit$.val());
const grossPrice = (price + deposit) * (tax / 100 + 1);
- const fcPrice = grossPrice * (this.priceMarkup / 100 + 1);
+ const fcPrice = grossPrice * (this.priceMarkup / 100 + 1);
const priceUnitLabel = this.getUnitLabel(this.priceUnit$.val());
this.fcPrice$.find('.price_value').text(isNaN(fcPrice) ? '?' : I18n.l('currency', fcPrice));
this.fcPrice$.find('.price_per_text').toggle(priceUnitLabel.trim() !== '');
@@ -88,7 +89,7 @@ class ArticleForm {
this.loadRatios();
this.undoPriceConversion();
this.undoOrderAndReceivedUnitsConversion();
- } catch(err) {
+ } catch (err) {
e.preventDefault();
throw err;
}
@@ -191,6 +192,7 @@ class ArticleForm {
initializeRegularFormFields() {
this.unit$.change(() => {
this.setMinimumOrderUnitDisplay();
+ this.setMaximumOrderUnitDisplay();
this.updateAvailableBillingAndGroupOrderUnits();
this.updateUnitMultiplierLabels();
this.updateCustomUnitWarning();
@@ -204,6 +206,28 @@ class ArticleForm {
this.updateCustomUnitWarning();
});
this.onSupplierUnitChanged();
+
+ // Add validation for minimum/maximum order quantity
+ this.initializeOrderQuantityValidation();
+ }
+
+ initializeOrderQuantityValidation() {
+ const validateOrderQuantities = () => {
+ const minValue = parseFloat(this.minimumOrderQuantity$.val()) || 0;
+ const maxValue = parseFloat(this.maximumOrderQuantity$.val()) || Infinity;
+
+ if (this.minimumOrderQuantity$.val() && this.maximumOrderQuantity$.val() && minValue > maxValue) {
+ const errorMessage = I18n.t('activerecord.errors.models.article_version.attributes.minimum_order_quantity.greater_than_maximum');
+ this.minimumOrderQuantity$[0].setCustomValidity(errorMessage);
+ this.maximumOrderQuantity$[0].setCustomValidity(errorMessage);
+ } else {
+ this.minimumOrderQuantity$[0].setCustomValidity('');
+ this.maximumOrderQuantity$[0].setCustomValidity('');
+ }
+ };
+
+ this.minimumOrderQuantity$.on('input change', validateOrderQuantities);
+ this.maximumOrderQuantity$.on('input change', validateOrderQuantities);
}
updateCustomUnitWarning() {
@@ -227,6 +251,7 @@ class ArticleForm {
this.unit$.toggle(!valueChosen);
this.filterAvailableRatioUnits();
this.setMinimumOrderUnitDisplay();
+ this.setMaximumOrderUnitDisplay();
this.updateAvailableBillingAndGroupOrderUnits();
this.updateUnitMultiplierLabels();
}
@@ -245,6 +270,20 @@ class ArticleForm {
this.minimumOrderQuantity$.attr('step', converter.isUnitSiConversible(this.supplierUnitSelect$.val()) ? 'any' : 1);
}
+ setMaximumOrderUnitDisplay() {
+ const chosenOptionLabel = this.supplierUnitSelect$.val() !== ''
+ ? $(`option[value="${this.supplierUnitSelect$.val()}"]`, this.supplierUnitSelect$).text()
+ : undefined;
+ const unitVal = $(`#${this.unitFieldsIdPrefix}_unit`).val();
+ this.maximumOrderQuantity$
+ .parents('.input-group')
+ .find('.input-group-addon')
+ .text(chosenOptionLabel !== undefined ? chosenOptionLabel : unitVal);
+
+ const converter = this.getUnitsConverter();
+ this.maximumOrderQuantity$.attr('step', converter.isUnitSiConversible(this.supplierUnitSelect$.val()) ? 'any' : 1);
+ }
+
bindAddRatioButton() {
$('*[data-add-ratio]', this.articleForm$).on('click', (e) => {
e.preventDefault();
diff --git a/app/assets/javascripts/group-order-form.js b/app/assets/javascripts/group-order-form.js
index 5e6945398..81edda4d8 100644
--- a/app/assets/javascripts/group-order-form.js
+++ b/app/assets/javascripts/group-order-form.js
@@ -11,14 +11,47 @@ class GroupOrderForm {
this.groupBalance = config.groupBalance;
this.minimumBalance = config.minimumBalance;
+ ValidationUtils.setupCustomValidation(this.form$);
+
this.initializeIncreaseDecreaseButtons();
this.submitButton$.removeAttr('disabled');
+ this.initializeFormValidation();
}
initializeIncreaseDecreaseButtons() {
this.articleRows$.each((_, element) => this.initializeOrderArticleRow($(element)));
}
+ initializeFormValidation() {
+ this.form$.on('submit', (e) => {
+ // Update all validation messages first
+ this.articleRows$.each((_, row) => {
+ const row$ = $(row);
+ this.updateValidationMessages(row$);
+ });
+
+ // Check if form is valid - browser will prevent submission if invalid
+ if (!this.form$[0].checkValidity()) {
+ e.preventDefault();
+
+ // Show error messages for all invalid inputs
+ this.form$.find(':invalid').each((_, invalidInput) => {
+ const input$ = $(invalidInput);
+ if (input$.hasClass('goa-quantity')) {
+ // Trigger validation to show error message
+ ValidationUtils.validateNumericInput(input$);
+ }
+ });
+
+ // Focus on first invalid input
+ const firstInvalid = this.form$.find(':invalid').first();
+ if (firstInvalid.length > 0) {
+ firstInvalid.focus();
+ }
+ }
+ });
+ }
+
initializeOrderArticleRow(row$) {
const quantity$ = row$.find('.goa-quantity');
const tolerance$ = row$.find('.goa-tolerance');
@@ -36,8 +69,18 @@ class GroupOrderForm {
quantityAndTolerance$.change(() => {
this.updateMissingUnits(row$, quantity$);
this.updateBalance();
+ this.updateValidationMessages(row$);
});
quantityAndTolerance$.keyup(() => quantity$.trigger('change'));
+
+ // Handle validation events for quantity field only
+ quantity$.on('invalid', (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ this.updateValidationMessages(row$);
+ });
+
+ this.updateValidationMessages(row$);
}
updateBalance() {
@@ -59,8 +102,8 @@ class GroupOrderForm {
// determine bgcolor and submit button state according to balance
var bgcolor = '';
if (balance < this.minimumBalance) {
- bgcolor = '#FF0000';
- this.submitButton$.attr('disabled', 'disabled')
+ bgcolor = '#FF0000';
+ this.submitButton$.attr('disabled', 'disabled')
} else {
this.submitButton$.removeAttr('disabled')
}
@@ -226,5 +269,30 @@ class GroupOrderForm {
var remainder = Big(quantity).mod(packSize).toNumber();
return (remainder > 0 && (Big(remainder).add(tolerance).toNumber() >= packSize));
}
+
+ updateValidationMessages(row$) {
+ const quantity$ = row$.find('.goa-quantity');
+
+ if (quantity$.length === 0) return;
+
+ const inputElement = quantity$[0];
+
+ // Clear any existing validation state
+ inputElement.setCustomValidity('');
+
+ // Use ValidationUtils for validation with error spans
+ ValidationUtils.validateNumericInput(quantity$);
+
+ // Special case: item not available (max = 0)
+ const rawValue = quantity$.val().trim();
+ const inputValue = parseFloat(rawValue);
+ const maxValue = parseFloat(quantity$.attr('max'));
+
+ if (maxValue === 0 && inputValue > 0) {
+ const message = I18n.t('errors.item_not_available');
+ inputElement.setCustomValidity(message);
+ ValidationUtils.showValidationMessage(quantity$, message);
+ }
+ }
}
diff --git a/app/assets/javascripts/unit-conversion-field.js b/app/assets/javascripts/unit-conversion-field.js
index b5b0a0e15..a5773f076 100644
--- a/app/assets/javascripts/unit-conversion-field.js
+++ b/app/assets/javascripts/unit-conversion-field.js
@@ -185,13 +185,22 @@
const unitLabel = this.unitSelectOptions.find(option => option.value === unit).label;
this.conversionResult$.text('= ' + this.getConversionResult() + ' x ' + unitLabel);
this.conversionResult$.parent().find('.numeric-step-error').remove();
- if (this.quantityInput$.is(':invalid')) {
- this.applyButton$.attr('disabled', 'disabled');
- const errorSpan$ = $(`
${I18n.t('errors.step_error', {min: 0, granularity: this.quantityInput$.attr('step')})}
`);
- errorSpan$.show();
- this.conversionResult$.after(errorSpan$);
- } else {
+
+ const errorSpan$ = $('');
+
+ // Use ValidationUtils for proper custom error messages
+ const isValid = ValidationUtils.validateNumericInput(this.quantityInput$, {
+ errorSpan$: errorSpan$
+ });
+
+ if (isValid) {
this.applyButton$.removeAttr('disabled');
+ } else {
+ this.applyButton$.attr('disabled', 'disabled');
+ if (errorSpan$.text()) {
+ errorSpan$.show();
+ this.conversionResult$.after(errorSpan$);
+ }
}
}
diff --git a/app/assets/javascripts/validation-utils.js b/app/assets/javascripts/validation-utils.js
new file mode 100644
index 000000000..59cadcd08
--- /dev/null
+++ b/app/assets/javascripts/validation-utils.js
@@ -0,0 +1,155 @@
+// Shared validation utilities for form inputs
+class ValidationUtils {
+ /**
+ * Sets up custom validation messages while keeping browser validation active
+ * @param {jQuery} form$ - The form element
+ */
+ static setupCustomValidation(form$) {
+ if (form$.length === 0) return;
+
+ // Keep HTML5 validation active but override messages
+ // Don't set novalidate - we want the browser to prevent submission
+
+ // Set up custom validation for quantity inputs only
+ form$.find('input[type="number"].goa-quantity').each((_, input) => {
+ const input$ = $(input);
+
+ // Set up event handlers for custom validation messages
+ input$.on('invalid.customValidation', (e) => {
+ e.preventDefault(); // Prevent browser validation popup
+
+ // Apply our custom validation and show message in popover
+ ValidationUtils.validateNumericInput(input$, {
+ showInSpanOnly: true // Don't use setCustomValidity for display
+ });
+ });
+ });
+ }
+ /**
+ * Validates a numeric input and sets custom validation messages
+ * @param {jQuery} input$ - The input element to validate
+ * @param {Object} options - Validation options
+ * @returns {boolean} - Whether the input is valid
+ */
+ static validateNumericInput(input$, options = {}) {
+ if (input$.length === 0) return true;
+
+ const inputElement = input$[0];
+ const rawValue = input$.val().trim().replace(',', '.');
+ const inputValue = parseFloat(rawValue);
+
+ let customMessage = '';
+
+ // Only validate if we have a valid number
+ if (rawValue !== '' && !isNaN(inputValue)) {
+ // Quantity field validation only
+ customMessage = ValidationUtils.validateQuantityField(input$, inputValue);
+ }
+
+ // Show validation message in error span (avoid popover conflicts)
+ ValidationUtils.showValidationMessage(input$, customMessage);
+
+ // Update visual error span if it exists (keep for backward compatibility)
+ if (options.errorSpan$ && options.errorSpan$.length > 0) {
+ if (customMessage) {
+ options.errorSpan$.text(customMessage).show();
+ } else {
+ options.errorSpan$.hide();
+ }
+ }
+
+ // Set custom validation message for browser validation
+ // This will be used by the browser to prevent form submission
+ if (!options.showInSpanOnly) {
+ inputElement.setCustomValidity(customMessage);
+ }
+
+ // Return validation state
+ return customMessage === '';
+ }
+
+ /**
+ * Validates a quantity field
+ * @param {jQuery} input$ - The quantity input element
+ * @param {number} inputValue - The parsed input value
+ * @returns {string} - Error message or empty string if valid
+ */
+ static validateQuantityField(input$, inputValue) {
+ // Get validation constraints from HTML attributes
+ const maxValue = input$.attr('max') ? parseFloat(input$.attr('max')) : null;
+ const minValue = parseFloat(input$.attr('min')) || 0;
+ const stepValue = parseFloat(input$.attr('step')) || 1;
+
+ // Check maximum quantity first (highest priority)
+ if (maxValue !== null && inputValue > maxValue) {
+ return I18n.t('errors.maximum_quantity_error', { max: maxValue });
+ }
+ // Check minimum value
+ else if (inputValue < minValue) {
+ return I18n.t('errors.step_error', { min: minValue, granularity: stepValue });
+ }
+ // Check step/granularity (allow for floating point precision issues)
+ else if (stepValue > 0) {
+ const remainder = ((inputValue - minValue) % stepValue);
+ if (Math.abs(remainder) > 0.0001 && Math.abs(remainder - stepValue) > 0.0001) {
+ return I18n.t('errors.step_error', { min: minValue, granularity: stepValue });
+ }
+ }
+
+ return '';
+ }
+
+ /**
+ * Shows or hides validation message using error spans (avoids popover conflicts)
+ * @param {jQuery} input$ - The input element
+ * @param {string} message - The validation message (empty to hide)
+ */
+ static showValidationMessage(input$, message) {
+ if (input$.length === 0) return;
+
+ // Find the error span for this input
+ const errorSpan$ = input$.closest('.group-order-input').find('.numeric-step-error');
+
+ if (message) {
+ // Show error message in span and add error styling
+ if (errorSpan$.length > 0) {
+ errorSpan$.text(message).show();
+ }
+ input$.addClass('validation-error');
+ } else {
+ // Hide error message and remove styling
+ if (errorSpan$.length > 0) {
+ errorSpan$.hide();
+ }
+ input$.removeClass('validation-error');
+ }
+ }
+
+ /**
+ * Hides all validation messages
+ */
+ static hideAllValidationMessages() {
+ $('.validation-error').removeClass('validation-error');
+ $('.numeric-step-error').hide();
+ }
+
+
+
+ /**
+ * Sets up validation for a form input
+ * @param {jQuery} input$ - The input element
+ * @param {Object} options - Validation options
+ */
+ static setupInputValidation(input$, options = {}) {
+ if (input$.length === 0) return;
+
+ // Only set up event handlers, don't trigger validation immediately
+ // to avoid recursion when called from existing event handlers
+ input$.on('invalid.validationUtils', (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ ValidationUtils.validateNumericInput(input$, options);
+ return false;
+ });
+ }
+}
\ No newline at end of file
diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss
index dbd57ebbf..5b7785d3d 100644
--- a/app/assets/stylesheets/application.scss
+++ b/app/assets/stylesheets/application.scss
@@ -11,3 +11,4 @@
@import "list.missing";
@import "recurring_select";
@import "actiontext";
+@import "validation";
diff --git a/app/assets/stylesheets/validation.scss b/app/assets/stylesheets/validation.scss
new file mode 100644
index 000000000..b44d35826
--- /dev/null
+++ b/app/assets/stylesheets/validation.scss
@@ -0,0 +1,19 @@
+// Simple validation error styling for inputs
+input.validation-error {
+ border-color: #d9534f !important;
+ box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 6px #ce8483 !important;
+
+ &:focus {
+ border-color: #d9534f !important;
+ box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 6px #ce8483 !important;
+ }
+}
+
+// Style error message spans
+.numeric-step-error {
+ color: #d9534f;
+ font-size: 12px;
+ font-weight: 500;
+ margin-top: 2px;
+ display: block;
+}
\ No newline at end of file
diff --git a/app/controllers/order_articles_controller.rb b/app/controllers/order_articles_controller.rb
index f2e7c8e61..0ff9b5d19 100644
--- a/app/controllers/order_articles_controller.rb
+++ b/app/controllers/order_articles_controller.rb
@@ -26,7 +26,7 @@ def create
end
def update
- version_params = params.require(:article_version).permit(:id, :unit, :supplier_order_unit, :minimum_order_quantity,
+ version_params = params.require(:article_version).permit(:id, :unit, :supplier_order_unit, :minimum_order_quantity, :maximum_order_quantity,
:billing_unit, :group_order_granularity, :group_order_unit, :price, :price_unit, :tax, :deposit, article_unit_ratios_attributes: %i[id sort quantity unit _destroy])
@order_article.update_handling_versioning!(params[:order_article], version_params)
rescue StandardError
diff --git a/app/models/article.rb b/app/models/article.rb
index 8f5356291..b8ece64a7 100644
--- a/app/models/article.rb
+++ b/app/models/article.rb
@@ -158,6 +158,7 @@ def unequal_attributes(new_article, options = {})
unit: [latest_article_version.unit, new_unit],
supplier_order_unit: [latest_article_version.supplier_order_unit, new_article.supplier_order_unit],
minimum_order_quantity: [latest_article_version.minimum_order_quantity, new_article.minimum_order_quantity],
+ maximum_order_quantity: [latest_article_version.maximum_order_quantity, new_article.maximum_order_quantity],
billing_unit: [latest_article_version.billing_unit || latest_article_version.supplier_order_unit,
new_article.billing_unit || new_article.supplier_order_unit],
group_order_granularity: [latest_article_version.group_order_granularity, new_article.group_order_granularity],
diff --git a/app/models/article_version.rb b/app/models/article_version.rb
index 11bdb4e1a..b4fbae39e 100644
--- a/app/models/article_version.rb
+++ b/app/models/article_version.rb
@@ -41,10 +41,13 @@ class ArticleVersion < ApplicationRecord
validates :group_order_granularity, numericality: { greater_than_or_equal_to: 0 }
validates :deposit, :tax, numericality: true
validates :minimum_order_quantity, numericality: { allow_nil: true }
-
+ validates :maximum_order_quantity, numericality: { allow_nil: true }
+ # validates_uniqueness_of :name, :scope => [:supplier_id, :deleted_at, :type], if: Proc.new {|a| a.supplier.shared_sync_method.blank? or a.supplier.shared_sync_method == 'import' }
+ # validates_uniqueness_of :name, :scope => [:supplier_id, :deleted_at, :type, :unit, :unit_quantity]
validate :uniqueness_of_name
validate :only_one_unit_type
validate :minimum_order_quantity_as_integer, unless: :supplier_order_unit_is_si_convertible
+ validate :minimum_order_quantity_not_greater_than_maximum
# Replace numeric seperator with database format
localize_input_of :price, :tax, :deposit
@@ -120,6 +123,20 @@ def minimum_order_quantity=(value)
end
end
+ def maximum_order_quantity=(value)
+ if value.blank?
+ self[:maximum_order_quantity] = nil
+ else
+ value = value.gsub(I18n.t('number.format.separator'), '.') if value.is_a?(String)
+ begin
+ value = value.to_i if Float(value) % 1 == 0
+ rescue ArgumentError
+ # not any number -> let validation handle this
+ end
+ super
+ end
+ end
+
def self_or_ratios_changed?
changed? || @article_unit_ratios_changed || article_unit_ratios.any?(&:changed?)
end
@@ -200,6 +217,14 @@ def only_one_unit_type
errors.add :unit # not specifying a specific error message as this should be prevented by js
end
+ def minimum_order_quantity_not_greater_than_maximum
+ return if minimum_order_quantity.blank? || maximum_order_quantity.blank?
+
+ return unless minimum_order_quantity > maximum_order_quantity
+
+ errors.add(:minimum_order_quantity, :greater_than_maximum)
+ end
+
def on_article_unit_ratios_change(_some_change)
@article_unit_ratios_changed = true
end
diff --git a/app/models/group_order.rb b/app/models/group_order.rb
index b48c9f71f..62e9c1f03 100644
--- a/app/models/group_order.rb
+++ b/app/models/group_order.rb
@@ -54,14 +54,19 @@ def load_data
used_tolerance: (goa ? goa.result(:tolerance) : 0),
total_price: (goa ? goa.total_price : 0),
missing_units: order_article.missing_units,
- ratio_group_order_unit_supplier_unit: order_article.article_version.convert_quantity(1,
- order_article.article_version.supplier_order_unit, order_article.article_version.group_order_unit),
- quantity_available: (order.stockit? ? order_article.article_version.article.quantity_available : 0),
+ ratio_group_order_unit_supplier_unit: order_article.article_version.convert_quantity(1, order_article.article_version.supplier_order_unit, order_article.article_version.group_order_unit),
minimum_order_quantity: if order_article.article_version.minimum_order_quantity
order_article.article_version.convert_quantity(
order_article.article_version.minimum_order_quantity, order_article.article_version.supplier_order_unit, order_article.article_version.group_order_unit
)
- end
+ end,
+ quantity_available: (if order.stockit?
+ order_article.article_version.article.quantity_available
+ elsif order_article.article_version.maximum_order_quantity
+ order_article.article_version.convert_quantity(
+ [order_article.article_version.maximum_order_quantity - (order_article.quantity || 0) + (goa ? goa.quantity : 0), 0].max, order_article.article_version.supplier_order_unit, order_article.article_version.group_order_unit
+ )
+ end)
}
end
end
diff --git a/app/models/group_order_article.rb b/app/models/group_order_article.rb
index 50cb37963..9eb29af6a 100644
--- a/app/models/group_order_article.rb
+++ b/app/models/group_order_article.rb
@@ -11,6 +11,7 @@ class GroupOrderArticle < ApplicationRecord
validates :order_article_id, uniqueness: { scope: :group_order_id } # just once an article per group order
validate :check_order_not_closed # don't allow changes to closed (aka settled) orders
validates :quantity, :tolerance, numericality: { greater_than_or_equal_to: 0 }
+ validate :check_maximum_order_quantity
scope :ordered, -> { includes(group_order: :ordergroup).order('groups.name') }
@@ -221,4 +222,30 @@ def check_order_not_closed
errors.add(:order_article, I18n.t('model.group_order_article.order_closed'))
end
+
+ def check_maximum_order_quantity
+ return unless order_article&.article_version&.maximum_order_quantity
+ return unless quantity.present? || tolerance.present?
+
+ max_quantity = order_article.article_version.maximum_order_quantity
+
+ max_quantity_in_group_unit = order_article.article_version.convert_quantity(
+ max_quantity,
+ order_article.article_version.supplier_order_unit,
+ order_article.article_version.group_order_unit
+ )
+
+ total_ordered_by_all = order_article.group_order_articles.sum(:quantity)
+
+ total_ordered_by_all -= quantity_was || 0.0 if persisted?
+
+ available_quantity = [max_quantity_in_group_unit - total_ordered_by_all, 0.0].max
+
+ requested_quantity = quantity || 0.0
+ return unless requested_quantity > available_quantity
+
+ errors.add(:quantity, I18n.t('model.group_order_article.quantity_exceeds_maximum',
+ requested: requested_quantity,
+ available: available_quantity.round(3)))
+ end
end
diff --git a/app/models/order_article.rb b/app/models/order_article.rb
index a176675f4..00acac2f9 100644
--- a/app/models/order_article.rb
+++ b/app/models/order_article.rb
@@ -81,6 +81,7 @@ def update_results!
# 4 | 5 | 4 | 2
#
def calculate_units_to_order(quantity, tolerance = 0)
+ quantity = [price.maximum_order_quantity || quantity, quantity].min
return 0 if !price.minimum_order_quantity.nil? && quantity + tolerance < price.minimum_order_quantity
return price.minimum_order_quantity if quantity > 0 && !price.minimum_order_quantity.nil? && quantity < price.minimum_order_quantity && quantity + tolerance >= price.minimum_order_quantity
diff --git a/app/views/articles/_edit_all_table.html.haml b/app/views/articles/_edit_all_table.html.haml
index 0061ba2cf..f06d8a4e7 100644
--- a/app/views/articles/_edit_all_table.html.haml
+++ b/app/views/articles/_edit_all_table.html.haml
@@ -53,6 +53,8 @@
= link_to t('.add_ratio'), '#', 'data-add-ratio' => true, class: 'btn btn-primary', title: t(".add_ratio")
= form.input :minimum_order_quantity, label: "Mininum order quantity" do
= form.input_field :minimum_order_quantity, class: 'form form-control', title: "total minimum order quantity for this article"
+ = form.input :maximum_order_quantity, label: "Maximum order quantity" do
+ = form.input_field :maximum_order_quantity, class: 'form form-control', title: "total maximum order quantity for this article"
%div
= form.input :billing_unit, as: :select, collection: [], input_html: {'data-initial-value': article.billing_unit, class: 'form-control'}, include_blank: false
.row
diff --git a/app/views/articles/_sync_table.html.haml b/app/views/articles/_sync_table.html.haml
index c248cebbe..13feb0ef2 100644
--- a/app/views/articles/_sync_table.html.haml
+++ b/app/views/articles/_sync_table.html.haml
@@ -71,12 +71,18 @@
= "#{ratio.quantity} x #{ArticleUnitsLib.get_translated_name_for_code(ratio.unit)}"
= t 'articles.form.per'
= ArticleUnitsLib.get_translated_name_for_code(article.supplier_order_unit)
- = form.input :minimum_order_quantity, label: "Mininum order quantity" do
+ = form.input :minimum_order_quantity, label: "Minimum order quantity" do
.form
= form.input_field :minimum_order_quantity, class: 'form-control', style: highlight_new(attrs, :minimum_order_quantity), title: "total minimum order quantity for this article"
%span.input-group-addon
- unless changed_article.new_record?
%p.help-block{style: 'color: grey;'}=article.minimum_order_quantity.to_s
+ = form.input :maximum_order_quantity, label: "Maximum order quantity" do
+ .form
+ = form.input_field :maximum_order_quantity, class: 'form-control', style: highlight_new(attrs, :maximum_order_quantity), title: "total maximum order quantity for this article"
+ %span.input-group-addon
+ - unless changed_article.new_record?
+ %p.help-block{style: 'color: grey;'}=article.maximum_order_quantity.to_s
= form.input :billing_unit, hint: changed_article.new_record? ? nil : ArticleUnitsLib.get_translated_name_for_code(article.billing_unit || article.supplier_order_unit), hint_html: {style: 'color: grey;'}, as: :select, collection: [], input_html: {'data-initial-value': changed_article.billing_unit, class: 'form-control', style: highlight_new(attrs, :billing_unit)}, include_blank: false
.form-group
= form.input :group_order_granularity, hint: changed_article.new_record? ? nil : "#{article.group_order_granularity} x #{ArticleUnitsLib.get_translated_name_for_code(article.group_order_unit)}", hint_html: {style: 'color: grey;'}, label: "Allow orders per", input_html: {class: 'form-control', style: highlight_new(attrs, :group_order_granularity), title: "steps in which ordergroups can order this article"}
diff --git a/app/views/articles/upload.html.haml b/app/views/articles/upload.html.haml
index 1b4e55827..f67e8bcf5 100644
--- a/app/views/articles/upload.html.haml
+++ b/app/views/articles/upload.html.haml
@@ -12,6 +12,7 @@
%th= Article.human_attribute_name(:custom_unit)
%th= Article.human_attribute_name(:ratios_to_supplier_order_unit)
%th= Article.human_attribute_name(:minimum_order_quantity)
+ %th= Article.human_attribute_name(:maximum_order_quantity)
%th= Article.human_attribute_name(:billing_unit)
%th= Article.human_attribute_name(:group_order_granularity)
%th= Article.human_attribute_name(:group_order_unit)
diff --git a/app/views/group_orders/_form.html.haml b/app/views/group_orders/_form.html.haml
index 7d1e17ef2..698f19d6d 100644
--- a/app/views/group_orders/_form.html.haml
+++ b/app/views/group_orders/_form.html.haml
@@ -116,6 +116,7 @@
- quantity_data['used_quantity'] = @ordering_data[:order_articles][order_article.id][:used_quantity]
- quantity_data['price'] = @ordering_data[:order_articles][order_article.id][:price]
- quantity_data['minimum_order_quantity'] = @ordering_data[:order_articles][order_article.id][:minimum_order_quantity] unless @ordering_data[:order_articles][order_article.id][:minimum_order_quantity].nil?
+ - quantity_data['maximum_order_quantity'] = @ordering_data[:order_articles][order_article.id][:maximum_order_quantity] unless @ordering_data[:order_articles][order_article.id][:maximum_order_quantity].nil?
- quantity_data['e2e-order-article-id'] = order_article.id
%td.used-unused
%span.used= number_with_precision(@ordering_data[:order_articles][order_article.id][:used_quantity], precision: 3, strip_insignificant_zeros: true)
@@ -127,8 +128,8 @@
%i.glyphicon.glyphicon-minus
%a.btn.btn-ordering.increase
%i.glyphicon.glyphicon-plus
- %input.goa-quantity{type: "number", name: "group_order[group_order_articles_attributes][#{order_article.id}][quantity]", value: @ordering_data[:order_articles][order_article.id][:quantity], data: quantity_data, autocomplete: 'off', class: 'form-control numeric', style: ('display:none' if @order.stockit?), min: 0, max: (@ordering_data[:order_articles][order_article.id][:quantity_available] if @order.stockit?), step: order_article.article_version.group_order_granularity}
- %span.numeric-step-error
+ %input.goa-quantity{type: "number", name: "group_order[group_order_articles_attributes][#{order_article.id}][quantity]", value: @ordering_data[:order_articles][order_article.id][:quantity], data: quantity_data, autocomplete: 'off', class: 'form-control numeric', style: ('display:none' if @order.stockit?), min: 0, max: (@ordering_data[:order_articles][order_article.id][:quantity_available]), step: order_article.article_version.group_order_granularity}
+ %span.numeric-step-error{style: 'display: none;'}
= t('errors.step_error', granularity: order_article.article_version.group_order_granularity, min: 0)
%td.used-unused-tolerance{style: ('display:none' if @order.stockit?)}
diff --git a/app/views/shared/_article_fields_units.html.haml b/app/views/shared/_article_fields_units.html.haml
index b476a99ac..669ea800f 100644
--- a/app/views/shared/_article_fields_units.html.haml
+++ b/app/views/shared/_article_fields_units.html.haml
@@ -30,6 +30,10 @@
.input-group
= f.input_field :minimum_order_quantity, class: 'form-control'
%span.input-group-addon
+ = f.input :maximum_order_quantity do
+ .input-group
+ = f.input_field :maximum_order_quantity, class: 'form-control'
+ %span.input-group-addon
%div
= f.input :billing_unit, as: :select, collection: [], input_html: {'data-initial-value': article.billing_unit, class: 'input-group'}, include_blank: false, wrapper_html: { class: 'd-flex' }
.row
diff --git a/config/locales/de.yml b/config/locales/de.yml
index dbff0b4a5..216711773 100644
--- a/config/locales/de.yml
+++ b/config/locales/de.yml
@@ -279,6 +279,7 @@ de:
taken_with_unit: Name und Einheit sind bereits vergeben
minimum_order_quantity:
only_integer: Muss eine Ganzzahl sein, wenn die Liefereinheit eine Stückeinheit ist.
+ greater_than_maximum: muss kleiner oder gleich der maximalen Bestellmenge sein
supplier:
attributes:
shared_sync_method:
@@ -833,6 +834,8 @@ de:
text: Diese Seite existiert anscheinend nicht. Entschuldigung!
title: Seite nicht gefunden
step_error: Gib eine Zahl >=%{min} ein, die ein Vielfaches von %{granularity} ist.
+ maximum_quantity_error: Es sind maximal %{max} Einheiten verfügbar.
+ item_not_available: Dieser Artikel ist nicht mehr zum Bestellen verfügbar.
feedback:
create:
notice: Das Feedback wurde erfolgreich verschickt. Vielen Dank!
@@ -1472,6 +1475,7 @@ de:
no_ordergroup: keine Bestellgruppe
group_order_article:
order_closed: Bestellung ist geschlossen und kann nicht geändert werden
+ quantity_exceeds_maximum: 'Menge %{requested} kann verfügbare Menge %{available} nicht überschreiten'
navigation:
admin:
config: Einstellungen
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 48c5ba637..963211b30 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -281,6 +281,7 @@ en:
taken_with_unit: name and unit are already taken
minimum_order_quantity:
only_integer: Must be an integer if supplier order unit is a piece unit
+ greater_than_maximum: must be less than or equal to maximum order quantity
supplier:
attributes:
shared_sync_method:
@@ -837,6 +838,8 @@ en:
text: This page does not appear to exist, sorry!
title: Page not found
step_error: Enter a number >=%{min}, which is a multiple of %{granularity}.
+ maximum_quantity_error: There are a maximum of %{max} units available.
+ item_not_available: This item is no longer available for ordering.
feedback:
create:
notice: Your feedback was sent successfully. Thanks a lot!
@@ -1479,6 +1482,7 @@ en:
no_ordergroup: no ordergroup
group_order_article:
order_closed: Order is closed and cannot be modified
+ quantity_exceeds_maximum: 'Quantity %{requested} cannot exceed available %{available}'
navigation:
admin:
config: Configuration
diff --git a/db/migrate/20250709100306_add_maximum_order_quantity_to_article_versions.rb b/db/migrate/20250709100306_add_maximum_order_quantity_to_article_versions.rb
new file mode 100644
index 000000000..b0fd17276
--- /dev/null
+++ b/db/migrate/20250709100306_add_maximum_order_quantity_to_article_versions.rb
@@ -0,0 +1,5 @@
+class AddMaximumOrderQuantityToArticleVersions < ActiveRecord::Migration[7.0]
+ def change
+ add_column :article_versions, :maximum_order_quantity, :float
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 338d10179..c5b835603 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.0].define(version: 2025_06_01_093453) do
+ActiveRecord::Schema[7.2].define(version: 2025_07_09_100306) do
create_table "action_text_rich_texts", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
t.string "name", null: false
t.text "body", size: :long
@@ -94,6 +94,7 @@
t.string "group_order_unit"
t.decimal "group_order_granularity", precision: 8, scale: 3, default: "1.0", null: false
t.float "minimum_order_quantity"
+ t.float "maximum_order_quantity"
t.index ["article_category_id"], name: "index_article_versions_on_article_category_id"
t.index ["article_id", "created_at"], name: "index_article_versions_on_article_id_and_created_at", unique: true
end
diff --git a/spec/integration/maximum_quantity_validation_spec.rb b/spec/integration/maximum_quantity_validation_spec.rb
new file mode 100644
index 000000000..964755132
--- /dev/null
+++ b/spec/integration/maximum_quantity_validation_spec.rb
@@ -0,0 +1,102 @@
+require 'spec_helper'
+
+describe 'Maximum Quantity Validation' do
+ describe 'ArticleVersion' do
+ let(:article_version) { create(:article_version) }
+
+ it 'allows setting maximum_order_quantity' do
+ article_version.maximum_order_quantity = 5.0
+ article_version.save!
+
+ expect(article_version.reload.maximum_order_quantity).to eq(5.0)
+ end
+
+ it 'validates maximum_order_quantity as numeric' do
+ article_version.maximum_order_quantity = 'invalid'
+ expect(article_version).not_to be_valid
+ expect(article_version.errors[:maximum_order_quantity]).to be_present
+ end
+
+ it 'allows nil maximum_order_quantity' do
+ article_version.maximum_order_quantity = nil
+ expect(article_version).to be_valid
+ end
+
+ context 'minimum and maximum order quantity validation' do
+ it 'allows minimum_order_quantity equal to maximum_order_quantity' do
+ article_version.minimum_order_quantity = 5.0
+ article_version.maximum_order_quantity = 5.0
+ expect(article_version).to be_valid
+ end
+
+ it 'allows minimum_order_quantity less than maximum_order_quantity' do
+ article_version.minimum_order_quantity = 3.0
+ article_version.maximum_order_quantity = 5.0
+ expect(article_version).to be_valid
+ end
+
+ it 'rejects minimum_order_quantity greater than maximum_order_quantity' do
+ article_version.minimum_order_quantity = 10.0
+ article_version.maximum_order_quantity = 5.0
+ expect(article_version).not_to be_valid
+ expect(article_version.errors[:minimum_order_quantity]).to include('must be less than or equal to maximum order quantity')
+ end
+
+ it 'allows validation when only minimum_order_quantity is set' do
+ article_version.minimum_order_quantity = 5.0
+ article_version.maximum_order_quantity = nil
+ expect(article_version).to be_valid
+ end
+
+ it 'allows validation when only maximum_order_quantity is set' do
+ article_version.minimum_order_quantity = nil
+ article_version.maximum_order_quantity = 5.0
+ expect(article_version).to be_valid
+ end
+
+ it 'allows validation when both are nil' do
+ article_version.minimum_order_quantity = nil
+ article_version.maximum_order_quantity = nil
+ expect(article_version).to be_valid
+ end
+ end
+ end
+
+ describe 'GroupOrder quantity_available calculation' do
+ let(:user) { create(:user, :ordergroup) }
+ let(:supplier) { create(:supplier) }
+ let(:article) { create(:article, supplier: supplier) }
+ let(:order) { create(:order, supplier: supplier, article_ids: [article.id]) }
+ let(:group_order) { create(:group_order, order: order, ordergroup: user.ordergroup) }
+
+ context 'when article has maximum_order_quantity' do
+ before do
+ article.article_versions.last.update!(maximum_order_quantity: 10.0)
+ end
+
+ it 'calculates quantity_available based on maximum_order_quantity' do
+ ordering_data = group_order.load_data
+ order_article = order.order_articles.first
+
+ expect(ordering_data[:order_articles][order_article.id][:quantity_available]).to eq(10.0)
+ end
+
+ it 'reduces quantity_available when other orders exist' do
+ # Create another group order with some quantity
+ other_group_order = create(:group_order, order: order)
+ order_article = order.order_articles.first
+ create(:group_order_article,
+ group_order: other_group_order,
+ order_article: order_article,
+ quantity: 3.0)
+
+ order_article.update_results!
+
+ ordering_data = group_order.load_data
+
+ available = ordering_data[:order_articles][order_article.id][:quantity_available]
+ expect(available).to eq 7 # 10 - 3 = 7
+ end
+ end
+ end
+end
diff --git a/spec/models/group_order_article_spec.rb b/spec/models/group_order_article_spec.rb
index 36108b132..c152968e8 100644
--- a/spec/models/group_order_article_spec.rb
+++ b/spec/models/group_order_article_spec.rb
@@ -86,4 +86,98 @@
expect(res).to eq(quantity: 4, tolerance: 2, total: 6)
end
end
+
+ describe 'maximum_order_quantity validation' do
+ let(:user) { create(:user, :ordergroup) }
+ let(:supplier) { create(:supplier) }
+ let(:article) { create(:article, supplier: supplier) }
+ let(:order) { create(:order, supplier: supplier, article_ids: [article.id]) }
+ let(:group_order) { create(:group_order, order: order, ordergroup: user.ordergroup) }
+ let(:order_article) { order.order_articles.first }
+
+ context 'when article has no maximum_order_quantity' do
+ it 'allows any quantity' do
+ goa = build(:group_order_article,
+ group_order: group_order,
+ order_article: order_article,
+ quantity: 100.0)
+
+ expect(goa).to be_valid
+ end
+ end
+
+ context 'when article has maximum_order_quantity' do
+ before do
+ order_article.article_version.update!(maximum_order_quantity: 10.0)
+ end
+
+ it 'allows quantity within the limit' do
+ goa = build(:group_order_article,
+ group_order: group_order,
+ order_article: order_article,
+ quantity: 5.0)
+
+ expect(goa).to be_valid
+ end
+
+ it 'rejects quantity exceeding the limit' do
+ goa = build(:group_order_article,
+ group_order: group_order,
+ order_article: order_article,
+ quantity: 15.0)
+
+ expect(goa).not_to be_valid
+ expect(goa.errors[:quantity]).to include(match(/cannot exceed.*10/))
+ end
+
+ it 'considers existing orders from other groups' do
+ other_group_order = create(:group_order, order: order)
+ create(:group_order_article,
+ group_order: other_group_order,
+ order_article: order_article,
+ quantity: 8.0)
+
+ order_article.reload
+
+ goa = build(:group_order_article,
+ group_order: group_order,
+ order_article: order_article,
+ quantity: 3.0)
+
+ expect(goa).not_to be_valid
+ expect(goa.errors[:quantity]).to include(match(/cannot exceed.*2/))
+ end
+
+ it 'allows updating existing orders within limits' do
+ goa = create(:group_order_article,
+ group_order: group_order,
+ order_article: order_article,
+ quantity: 5.0)
+
+ goa.quantity = 8.0
+ expect(goa).to be_valid
+
+ goa.quantity = 12.0
+ expect(goa).not_to be_valid
+ end
+
+ it 'handles unit conversion correctly' do
+ order_article.article_version.update!(
+ supplier_order_unit: 'KGM',
+ group_order_unit: 'GRM',
+ maximum_order_quantity: 2.0 # 2 kg = 2000 grams
+ )
+
+ goa = build(:group_order_article,
+ group_order: group_order,
+ order_article: order_article,
+ quantity: 1500.0)
+
+ expect(goa).to be_valid
+
+ goa.quantity = 3000.0
+ expect(goa).not_to be_valid
+ end
+ end
+ end
end
diff --git a/spec/models/order_article_spec.rb b/spec/models/order_article_spec.rb
index 78f46d4e3..1dfc46e39 100644
--- a/spec/models/order_article_spec.rb
+++ b/spec/models/order_article_spec.rb
@@ -268,4 +268,64 @@ def goa_reload
end
end
end
+
+ describe 'calculate_units_to_order with si unit_quantity' do
+ let(:article) { create(:article, unit_quantity: 3) }
+ let(:order) { create(:order, article_ids: [article.id]) }
+ let(:oa) { order.order_articles.first }
+
+ it 'simple case' do
+ expect(oa.calculate_units_to_order(6)).to eq 2
+ end
+
+ it 'with tolerance' do
+ expect(oa.calculate_units_to_order(5, 0)).to eq 1
+ expect(oa.calculate_units_to_order(5, 1)).to eq 2
+ end
+
+ it 'minimum order quantity' do
+ oa.article_version.update_attribute :minimum_order_quantity, 6
+ expect(oa.calculate_units_to_order(3)).to eq 0
+ expect(oa.calculate_units_to_order(6)).to eq 2
+ end
+
+ it 'maximum order quantity' do
+ oa.article_version.update_attribute :maximum_order_quantity, 6
+ expect(oa.calculate_units_to_order(9)).to eq 2
+ end
+ end
+
+ describe 'calculate_units_to_order with article_version' do
+ let(:article_version) { create(:article_version, article_unit_ratios: [create(:article_unit_ratio, quantity: 3)]) }
+ let(:article) { article_version.article }
+ let(:order) { create(:order, article_ids: [article.id]) }
+ let(:oa) { order.order_articles.first }
+
+ it 'simple case' do
+ expect(oa.calculate_units_to_order(3)).to eq 1
+ end
+
+ it 'with tolerance' do
+ expect(oa.calculate_units_to_order(5, 0)).to eq 1
+ expect(oa.calculate_units_to_order(5, 1)).to eq 2
+ end
+
+ it 'minimum order quantity' do
+ oa.article_version.update_attribute :minimum_order_quantity, 6
+ expect(oa.calculate_units_to_order(3)).to eq 0
+ expect(oa.calculate_units_to_order(6)).to eq 2
+ end
+
+ it 'maximum order quantity' do
+ oa.article_version.update_attribute :maximum_order_quantity, 6
+ expect(oa.calculate_units_to_order(9)).to eq 2
+ end
+
+ it 'maximum and minimum' do
+ oa.article_version.update_attribute :maximum_order_quantity, 9
+ oa.article_version.update_attribute :minimum_order_quantity, 6
+ expect(oa.calculate_units_to_order(12)).to eq 3
+ expect(oa.calculate_units_to_order(3)).to eq 0
+ end
+ end
end