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